dsh-memu-memory 1.2.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +12 -5
  3. package/README.zh.md +4 -2
  4. package/docs/contributing.md +16 -3
  5. package/docs/host-api.md +3 -1
  6. package/docs/memory-semantics.md +21 -2
  7. package/docs/troubleshooting.md +42 -4
  8. package/lib/backup.js +24 -2
  9. package/lib/binding-diagnostics.js +2 -2
  10. package/lib/client.js +1361 -146
  11. package/lib/consolidation-worker.js +30 -8
  12. package/lib/curator-evidence.js +131 -0
  13. package/lib/curator-policy.js +81 -0
  14. package/lib/curator.js +1252 -91
  15. package/lib/cycle.js +3 -0
  16. package/lib/embedding-download.js +39 -4
  17. package/lib/engine.js +6 -3
  18. package/lib/global-graph.js +282 -0
  19. package/lib/graph.js +51 -9
  20. package/lib/inbound.js +19 -0
  21. package/lib/inject.js +277 -17
  22. package/lib/lexicon.js +1115 -0
  23. package/lib/local-vector-service.js +8 -6
  24. package/lib/managed-runtime.js +33 -2
  25. package/lib/memory.js +282 -17
  26. package/lib/memu.js +2 -2
  27. package/lib/migrate.js +83 -2
  28. package/lib/private-links.js +587 -0
  29. package/lib/private.js +32 -3
  30. package/lib/prompts/global-curator.md +76 -19
  31. package/lib/prompts/private-consolidation.coding.md +4 -1
  32. package/lib/prompts/private-consolidation.md +3 -1
  33. package/lib/prompts/private-consolidation.mixed.md +3 -1
  34. package/lib/prompts/private-consolidation.rp.md +3 -1
  35. package/lib/prompts.js +1 -1
  36. package/lib/rpc.js +577 -9
  37. package/lib/state.js +4 -1
  38. package/lib/text-retrieval.js +661 -0
  39. package/lib/text-similarity.js +36 -18
  40. package/lib/tools.js +168 -10
  41. package/lib/webui/standalone.css +17 -0
  42. package/lib/webui/standalone.js +2 -0
  43. package/package.json +1 -1
  44. package/scripts/acceptance-local.mjs +8 -5
  45. package/scripts/curator-pair-calibrate.mjs +259 -0
  46. package/scripts/lexicon-calibrate.mjs +230 -0
  47. package/scripts/lexicon-dedupe.mjs +318 -0
  48. package/tests/absorb-review.test.mjs +353 -0
  49. package/tests/appendix-b2.test.mjs +2 -2
  50. package/tests/client-export.test.mjs +520 -0
  51. package/tests/client-render.test.mjs +19 -3
  52. package/tests/consolidation-fork.test.mjs +14 -12
  53. package/tests/consolidation-snapshot.test.mjs +39 -0
  54. package/tests/curator-absorb.test.mjs +443 -0
  55. package/tests/curator-calibrate.test.mjs +173 -0
  56. package/tests/curator-clusters.test.mjs +281 -0
  57. package/tests/curator-commit.test.mjs +52 -5
  58. package/tests/curator-evidence.test.mjs +102 -0
  59. package/tests/curator-link.test.mjs +316 -0
  60. package/tests/curator-pairing.test.mjs +252 -0
  61. package/tests/curator-probe.test.mjs +15 -6
  62. package/tests/curator-review-mode.test.mjs +314 -0
  63. package/tests/disclosure.test.mjs +2 -1
  64. package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
  65. package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
  66. package/tests/fixtures/lexicon-stub.mjs +284 -0
  67. package/tests/graph-cascade.test.mjs +35 -2
  68. package/tests/graph.test.mjs +96 -0
  69. package/tests/help-page.test.mjs +415 -0
  70. package/tests/inbound.test.mjs +6 -3
  71. package/tests/inject-guide.test.mjs +28 -0
  72. package/tests/inject.test.mjs +157 -5
  73. package/tests/lexicon-calibrate.test.mjs +48 -0
  74. package/tests/lexicon-dedupe.test.mjs +171 -0
  75. package/tests/lexicon-frequency.test.mjs +179 -0
  76. package/tests/lexicon-inject.test.mjs +410 -0
  77. package/tests/lexicon-perf.test.mjs +436 -0
  78. package/tests/lexicon-ui.test.mjs +547 -0
  79. package/tests/lexicon.test.mjs +922 -0
  80. package/tests/managed-runtime.test.mjs +32 -0
  81. package/tests/private-links.test.mjs +445 -0
  82. package/tests/prompts.test.mjs +35 -0
  83. package/tests/recall-dedup.test.mjs +33 -4
  84. package/tests/tool-schema.test.mjs +1 -1
  85. package/tests/tools.test.mjs +112 -3
  86. package/tests/webui-auth.test.mjs +348 -2
  87. package/tests/webui.test.mjs +188 -0
package/lib/client.js CHANGED
@@ -8,7 +8,7 @@ window.__ModuleLoader__.load({
8
8
  const exports = module.exports
9
9
 
10
10
  const NS = 'dsh-memu-memory'
11
- const SETTINGS_PAGES = ['engine', 'connection', 'settings', 'folders', 'libraries', 'browser', 'global', 'prompts', 'stats', 'memories', 'logs']
11
+ const SETTINGS_PAGES = ['engine', 'connection', 'settings', 'folders', 'libraries', 'browser', 'global', 'prompts', 'stats', 'memories', 'logs', 'lexicon', 'help']
12
12
  const EMPTY_SESSIONS = Object.freeze({ byId: Object.freeze({}), ids: Object.freeze([]) })
13
13
  const TAB_ID = `${NS}:memory`
14
14
  const TAB_KIND = 'dsh-memu-memory'
@@ -135,6 +135,7 @@ window.__ModuleLoader__.load({
135
135
  logClear: '清空日志',
136
136
  runLogTitle: '整理记录',
137
137
  runLogEmpty: '还没有整理记录。',
138
+ runLogOpen: '全部日志',
138
139
  dedupedNotice: '未写入:库里已有几乎相同的记忆 [{id}]。要补充内容请在原条目上编辑合并;确属两次独立观测时才需要 allow_duplicate 重写。',
139
140
  consolidateFeedTitle: '整理提醒',
140
141
  consolidateFailedCol: '失败',
@@ -149,6 +150,9 @@ window.__ModuleLoader__.load({
149
150
  consolidateClearHint: '只删除失败的整理日志;成功记录与其它日志不动',
150
151
  consolidateCleared: '已清除 {n} 条失败记录',
151
152
  allLibraries: '全部记忆库',
153
+ wallAllBlocked: '当前宿主只允许单库读取,已显示第一个记忆库',
154
+ linkCountTag: '关联 {n}',
155
+ linkRemoved: '关联已移除',
152
156
  searchMemories: '搜索标题、内容或标签',
153
157
  poolReview: '待复核',
154
158
  poolInactive: '已失效',
@@ -156,6 +160,10 @@ window.__ModuleLoader__.load({
156
160
  wallLimit: '仅展示最近 300 条',
157
161
  wallScopePrivate: '私有',
158
162
  wallScopeGlobal: '全局',
163
+ wallFilterScope: '范围',
164
+ wallFilterStatus: '状态',
165
+ wallFilterLayer: '层级',
166
+ wallAllLayers: '全部层级',
159
167
  multiSelect: '多选',
160
168
  wallSelected: '已选 {n} 条',
161
169
  wallSelectAll: '全选本页',
@@ -185,7 +193,17 @@ window.__ModuleLoader__.load({
185
193
  characterId: '当前角色 ID',
186
194
  worldId: '世界线 ID',
187
195
  branchId: '剧情分支 ID',
188
- exportImages: '导出含图片',
196
+ exportZip: '导出 ZIP(含图片)',
197
+ exportStarted: '已开始导出:{file}',
198
+ memorySaved: '记忆已保存',
199
+ memoryArchived: '记忆已归档',
200
+ memoryRestored: '记忆已恢复',
201
+ memoryDeleted: '记忆已删除',
202
+ bulkDone: '批量操作完成:{n} 条',
203
+ curatorReview: '全局记忆审核',
204
+ descCuratorReview: '控制全局提炼的写入方式:自动模式只暂扣冲突、缺证据与高风险改写;全部待审把每个内容改动都送进复核队列。',
205
+ curatorReviewAuto: '自动(默认)',
206
+ curatorReviewAll: '全部待审',
189
207
  archiveTooLarge: '附件包超过单次导入上限(16 MB),请拆分',
190
208
  openSettings: '打开设置页',
191
209
  sidebarSettings: '在侧边栏保留完整设置',
@@ -196,7 +214,7 @@ window.__ModuleLoader__.load({
196
214
  adjacency: '邻接列表',
197
215
  graphView: '关系图',
198
216
  viewMemories: '记忆',
199
- viewRelations: '关系',
217
+ viewRelations: '节点图',
200
218
  close: '关闭',
201
219
  openInBrowser: '在记忆浏览中打开',
202
220
  globalReadOnly: '全局记忆在这里只读;需要修改请去全局记忆页。',
@@ -215,6 +233,7 @@ window.__ModuleLoader__.load({
215
233
  entityAliases: '别名(逗号分隔)',
216
234
  entityKind: '实体类型',
217
235
  graphEmpty: '还没有关联',
236
+ graphAutoLink: '自动',
218
237
  deletedNode: '[已删除]',
219
238
  graphLimited: '仅显示最多 200 个节点',
220
239
  graphFit: '适应全图',
@@ -296,6 +315,13 @@ window.__ModuleLoader__.load({
296
315
  reviews: '待复核',
297
316
  reviewsEmpty: '没有等待复核的记忆。',
298
317
  reviewScopePrivate: '私有库',
318
+ // 包 F2:集合吸收(absorb)复核与撤销。
319
+ absorbTag: '集合吸收 · {n} 条',
320
+ absorbSourcesTag: '集合记忆 · {n} 条来源',
321
+ absorbUndo: '撤销合并',
322
+ absorbUndone: '已撤销合并,来源记忆已恢复',
323
+ absorbSourcesLabel: '吸收来源',
324
+ absorbLinksLabel: '关联记忆',
299
325
  accuracyTag: '细节未必准',
300
326
  accept: '接受',
301
327
  reject: '丢弃',
@@ -633,7 +659,170 @@ window.__ModuleLoader__.load({
633
659
  rememberChoice: '记住我的选择,下次新对话直接用',
634
660
  confirm: '确定',
635
661
  later: '稍后',
662
+ // —— 词典页 ——
663
+ pageLexicon: '词典',
664
+ descLexicon: '每个记忆库一本词典:黑话、梗、触发词与术语的释义、归档与命中统计。',
665
+ lexiconScope: '词条管理',
666
+ lexiconNoLibrary: '还没有记忆库;词典随私有记忆库创建。',
667
+ lexiconTotal: '共 {total} 条',
668
+ lexiconTerm: '词条',
669
+ lexiconKind: '类型',
670
+ lexiconAllKinds: '全部类型',
671
+ lexiconKindSlang: '黑话',
672
+ lexiconKindMeme: '梗',
673
+ lexiconKindTrigger: '触发词',
674
+ lexiconKindTerm: '术语',
675
+ lexiconMeaning: '释义',
676
+ lexiconUsage: '用法',
677
+ lexiconAliases: '别名(逗号分隔)',
678
+ lexiconExamples: '例句(每行一条)',
679
+ lexiconSensitivity: '敏感度',
680
+ lexiconSensitivityAll: '全部敏感度',
681
+ lexiconNormal: '普通',
682
+ lexiconSensitive: '敏感',
683
+ lexiconStatus: '状态',
684
+ lexiconAllStatus: '全部状态',
685
+ lexiconStPending: '待审',
686
+ lexiconStRejected: '已驳回',
687
+ lexiconPending: '待审',
688
+ lexiconApprove: '通过',
689
+ lexiconReject: '驳回',
690
+ lexiconHits: '命中次数',
691
+ lexiconStrength: '强度',
692
+ lexiconSourceMemory: '来源记忆 ↗',
693
+ lexiconSearch: '搜索词条 / 释义 / 别名',
694
+ lexiconNew: '新建词条',
695
+ lexiconCreate: '写入词典',
696
+ lexiconEdit: '编辑词条',
697
+ lexiconSaveDone: '词条已保存。',
698
+ lexiconReviewDone: '审核完成。',
699
+ lexiconAddDone: '词条已写入。',
700
+ lexiconDelete: '删除词条',
701
+ lexiconDeleteHint: '删除是永久的:先点一下「删除词条」,再点「确认删除」。',
702
+ lexiconDeleted: '已删除 {n} 条词条。',
703
+ lexiconPrev: '上一页',
704
+ lexiconNext: '下一页',
705
+ lexiconPageOf: '第 {page} / {pages} 页',
706
+ lexiconEmpty: '这个词库还没有词条。',
707
+ lexiconNoMatch: '没有匹配的词条。',
708
+ lexiconTabActive: '使用中 ({n})',
709
+ lexiconTabArchived: '已归档 ({n})',
710
+ lexiconArchivedEmpty: '没有已归档的词条。',
711
+ lexiconArchive: '归档',
712
+ lexiconRestore: '恢复',
713
+ lexiconArchiveDone: '词条已归档,可随时恢复。',
714
+ lexiconRestoreDone: '词条已恢复。',
715
+ lexiconArchivedCount: '已归档 {n} 条词条。',
716
+ lexiconRestoredCount: '已恢复 {n} 条词条。',
717
+ lexiconCardUsage: '用法',
718
+ lexiconCardAliases: '别名',
636
719
  loading: '加载中…',
720
+ // ── 包 C/D/E 新增键(词典提案态 + 实体可见性 + 帮助页)──
721
+ lexiconProposal: '提案',
722
+ lexiconPendingEmpty: '没有待审的词条提案。',
723
+ lexiconHitsDetail: '命中明细:候选 {candidate} · 注入 {injection} · 查询 {query}',
724
+ orphanEntities: '未关联实体 ({n})',
725
+ orphanPruneHint: '这些实体没有被任何记忆或关系引用,插件下次启动时会自动清理。',
726
+ entityEmpty: '这个词库还没有实体。',
727
+ firstRunTitle: '先建一个记忆库',
728
+ firstRunDesc: '还没有任何记忆库。新建一个私有库后,会话里的记忆才有地方落。',
729
+ firstRunGoLibraries: '去新建库',
730
+ firstRunGuide: '查看快速上手',
731
+ pageHelp: '帮助',
732
+ descHelp: '快速上手、作用域/标注说明、常见问题与反馈入口',
733
+ helpQuickStart: '快速开始',
734
+ helpGoTo: '前往 →',
735
+ helpStep1: '新建一个私有库:在「记忆库」页新建。成功时列表里出现当前项目的库,文件夹页也能看到它。',
736
+ helpStep2: '在会话里绑定库:在对话中让助手调用 memu_bind(或发送 /memu 绑定),或在新会话卡里选定作用域。成功后侧栏会话卡显示已绑定这个库。',
737
+ helpStep3: '选择注入模式:在设置的「注入模式」里选 smart(按需)/ anchor-only(只锚点)/ per-turn(每轮)/ off(关闭)。成功时状态卡显示已选模式,下一轮按它注入。',
738
+ helpStep4: '在记忆页确认与修正:MemU 记忆页列出召回与写入的条目。点开抽屉看全文、改错字、归档不需要的内容;成功时改动立即生效。',
739
+ helpStep5: '在词典页维护黑话:AI 写入的词条会自动 active 并参与注入;你可以编辑、归档/恢复、彻底删除,sensitive trigger 只能由你添加或修改。成功时词条出现在「使用中」,归档的进入「已归档」可恢复。',
740
+ helpScopes: '私有与全局怎么判断',
741
+ helpScopesDesc: '先看文件夹上限,再看本会话开关,最后得到生效作用域。',
742
+ helpScopesChain: '判断顺序:工作区 → 文件夹策略(useLibrary / useGlobal 上限)→ 会话绑定(实际开关)→ effectiveScope。文件夹是天花板,会话只能在天花板以内生效。',
743
+ helpScopesPrivate: '私有 = 本文件夹内所有会话共享同一个库:换对话仍读同一份,写入对所有会话可见,可编辑、归档与恢复。',
744
+ helpScopesGlobal: '全局 = curator 在后台跨会话整理写入,按 domain 与 applies_to 过滤;界面上只读,只能复核、撤销合并或移除链接,不能手改正文。',
745
+ helpScopesWhere: '在哪里改:文件夹页设上限;侧栏会话卡设本对话开关;全局页只审阅。',
746
+ helpScopesPitfallsTitle: '常见误区',
747
+ helpScopesPitfall1: '文件夹关掉全局后,会话无法再打开它——上限优先,开关会显示为禁用。',
748
+ helpScopesPitfall2: '只开全局、不开私有的对话没有私有库,私有记忆工具会明确报错而不是凭空建库。',
749
+ helpScopesPitfall3: '条目带上下文(频道/角色/世界线/分支/knownBy)时,不匹配的会话看不到它;这是过滤,不是丢失。',
750
+ helpTitleCards: '标题卡与召回',
751
+ helpTitleCardsBody: '锚点与本回合召回只显示标题卡:标题、标签和 id,正文不注入;全文仍在记忆页抽屉与召回工具里。',
752
+ helpTitleCardsRecall: '标题是目录:用标题关键词,或标题卡里方括号内的 id 调 memu_private_recall / memu_global_recall,工具返回仍是全文。',
753
+ helpTitleCardsRule: '标题写作:8–40 字,写具体的人/物/事件或结论(如「Yuike 名字来自小时候的白猫」),不要空标题或「关于XX的事」;细节放进 content。',
754
+ helpArchiveDelete: '归档、删除与撤回',
755
+ helpArchiveDeleteArchive: '归档 = 软删除:随时可恢复,关系与链接保留,不级联;条目移到「已归档」页签,不再注入。',
756
+ helpArchiveDeleteDelete: '彻底删除:界面只对已归档条目开放,需两段确认,永久且不可撤销;会级联清掉指向它的入边,并清理因此孤立的实体。',
757
+ helpArchiveDeleteLexicon: '词典同口径:归档可恢复,删除永久;模型没有删除或复核路径,sensitive 词条只有用户能改。',
758
+ helpArchiveDeleteAbsorb: '合并(absorb):合并稿成为新 active,目标标记 superseded,来源归档;在全局页对合并稿点「撤销合并」可整体撤回,全局链接也可逐条移除。',
759
+ helpArchiveDeleteSessionGone: '删除的对话不会触发整理失败:扫描会标记 sessionGone 并跳过,不反复重试。',
760
+ helpVector: '向量库',
761
+ helpVectorSetup: 'memoryBackend=api + vectorSource=local 表示:MemU 负责存储与检索,本地受管 embedding 模型提供向量。到「连接与向量」选本地来源,并在同页的受管向量区下载运行时与模型,就绪后切换。',
762
+ helpVectorCount: '「当前已入向量库 N 条」统计的是当前 active 代际里已有向量的条数;条目上的「已入向量库 · gN」是该条写入时的代际标签。',
763
+ helpVectorFallback: '向量未激活(只有 requested 或重建失败)时,召回自动回退词法检索:仍然可用,只是语义匹配更弱。',
764
+ helpVectorRebuild: '切换模型或修复失败要重建:新代际建成前旧代际保持只读,成功后的新记忆写入新代际;失败不会改当前代际,可以再点一次续建。',
765
+ helpAutoLink: '私有自动关联与实体枢纽',
766
+ helpAutoLinkRule: "先跑算法,不额外花模型 token:同库、同上下文/轨道、可见且非 secret 的两条记忆,若共享稀有名称/别名、内容不冲突,就直接建立 memory↔memory 链接并标 source:'auto'。",
767
+ helpAutoLinkHub: '实体枢纽:「猫娘yuike」与「yuike名字的由来」共享 yuike,算法会复用或创建 yuike 实体,并把两条记忆连到它;人物/物品类型按保守信号推断。',
768
+ helpAutoLinkWorker: '拿不准的候选交给本次整理 worker(memu_private_link_suggest)带证据裁决;不会另起一次完整整理,也不重读转录;没有候选就不连。',
769
+ helpAutoLinkSafety: '自动关联只改图元数据:不改正文、不写向量、不删除。关系图显示「自动」标记和移除(×);实体删除仍按既有规则。',
770
+ helpLabels: '标注速查表',
771
+ helpLabelsDesc: '记忆与关系图上会出现的标记。',
772
+ helpLabelColLabel: '标注',
773
+ helpLabelColMeaning: '含义',
774
+ helpLabelSource: 'source',
775
+ helpLabelSourceMeaning: '来源:inferred 推断 / user_said 用户原话(可引用)/ told 转述 / imagined 想象。imagined 不能当事实。',
776
+ helpLabelLayer: 'layer',
777
+ helpLabelLayerMeaning: '层级:must_remember 必记 / long_term 长期 / short_term 短期指针(只给指针,不含正文)。',
778
+ helpLabelOpenLoop: 'open_loop',
779
+ helpLabelOpenLoopMeaning: '未闭环:还有承诺或悬念没交代。',
780
+ helpLabelMarginalized: 'marginalized',
781
+ helpLabelMarginalizedMeaning: '已边缘化:移出主记忆但未删除,仍可检索。',
782
+ helpLabelDate: 'date / anniversary',
783
+ helpLabelDateMeaning: '日期:date 是一次性某天;anniversary 每年重复(生日/纪念日)。',
784
+ helpLabelAttachment: '附件N',
785
+ helpLabelAttachmentMeaning: '标题卡上的附件计数,不展开 caption;正文与图片在抽屉或工具里。',
786
+ helpLabelVector: '已入向量库 · gN',
787
+ helpLabelVectorMeaning: '该条已写入第 N 代向量空间;「当前已入向量库 N 条」统计 active 代际。',
788
+ helpLabelReviewRisk: 'reviewRisk',
789
+ helpLabelReviewRiskMeaning: '全局条目的复核风险分(0–2);越高越可能进「待复核」,它不是置信度。',
790
+ helpLabelLexicon: 'lexicon kind / sensitive / hitCount · 自动',
791
+ helpLabelLexiconMeaning: '词典:kind 是词条类型;sensitive 仅用户可管;hitCount 是命中次数。关系图「自动」= 算法建立的链接。',
792
+ helpSidebar: '侧边栏地图',
793
+ helpSidebarDesc: '侧栏几个提醒与开关的位置。',
794
+ helpSidebarAlerts: '「整理提醒」跨会话汇总整理结果:失败行需要处理,点 ↗ 打开该条记忆所在的库;「清除失败记录」只删失败日志,成功记录与其他日志不动。',
795
+ helpSidebarReview: '「待复核」= pending_review:内容还没通过复核,所以不注入;侧栏统计只显示数量,审阅入口在「全局记忆」页的待复核队列(私有条目也在这里带「私有库」标签)。',
796
+ helpSidebarRunLog: '「整理记录」列最近几次整理的状态、时间与消息;「整理提醒」展开后分失败/成功两列。',
797
+ helpSidebarReviewMode: '审核模式开关在全局记忆页:「auto」只暂扣冲突、缺证据与高风险改写,「review-all」把所有内容操作送进复核。',
798
+ helpSidebarFirstRun: '首次安装卡只在没有任何记忆库时出现;建好第一个库后自动消失,没有关闭按钮,也不写本地存储。',
799
+ helpSidebarFallback: 'WebUI 若不允许「全部库」读取(宿主返回 403),记忆墙会退回第一个可访问的库并明确提示,不会整页空白;多库选项会禁用。',
800
+ helpSidebarScope: '本对话的作用域卡在侧栏「记忆」页:顶部两层显示文件夹与会话,控制卡里是私有/全局开关,下方折叠卡可设频道/角色/世界线/分支。',
801
+ helpFaq: '常见问题',
802
+ helpFaq1: '为什么没有召回?',
803
+ helpFaq1A: '按顺序检查:会话是否绑定库(memu_bind / /memu 绑定);注入模式是否 off;文件夹或会话的私有/全局开关是否关掉;条目是否还在 active 且未被上下文(频道/角色/世界线/分支/knownBy)或 domain 过滤。本地向量未激活不会让召回消失,只会回退到词法。',
804
+ helpFaq2: '私有库和全局记忆有什么区别?',
805
+ helpFaq2A: '私有库由本文件夹所有会话共享,写入即可编辑、归档、恢复;全局由 curator 在后台跨会话整理写入,按 domain/applies_to 过滤,界面里只读,只能复核、撤销合并或移除链接。',
806
+ helpFaq3: '为什么有些记忆不会被注入?',
807
+ helpFaq3A: '常见原因:pending_review(等内容复核)、archived/suppressed/expired/superseded、visibility=secret(自动注入会跳过)、上下文或 domain 不匹配。词典条目没有 pending 复核:写入即 active,归档的才不注入。',
808
+ helpFaq4: '为什么锚点只有标题?怎么拿到正文?',
809
+ helpFaq4A: '锚点与本回合召回是目录:只给标题、标签和 id,避免挤占预算,也避免模型引用没拿到的原文。正文用 memu_private_recall / memu_global_recall 按标题关键词或 id 取,工具仍返回全文;也可在记忆页抽屉里直接读。',
810
+ helpFaq5: '整理合并(absorb)之后,原来的条目去哪了?怎么撤销?',
811
+ helpFaq5A: '合并不会删除任何东西:合并稿成为新的 active 条目,被吸收的来源归档、被取代的目标标记 superseded。在全局记忆页找到合并稿,点「撤销合并」即可整体恢复原状;全局链接也可以逐条移除。',
812
+ helpFaq6: '词典为什么自动生效?怎么归档?为什么某条不注入?',
813
+ helpFaq6A: '词典写入即 active,没有待审队列;归档会立即停止注入且可恢复,彻底删除只在管理面提供、不可撤销。词条不注入时检查是否已归档、上下文/可见性是否匹配。sensitive trigger 只有用户能改。',
814
+ helpFaq7: '自动关联和实体枢纽怎么撤销?',
815
+ helpFaq7A: '关系图里带「自动」的边是算法建立的:点旁边的 × 两段确认后即可移除。移除只改图元数据,不改正文、不写向量、不删记忆。实体删除沿用既有规则:被引用时默认拒绝,确认后才连同入边一起移除。',
816
+ helpFaq8: '自动备份和导出 JSON/ZIP 有什么区别?',
817
+ helpFaq8A: '自动备份是整机快照:插件状态、全局/私有索引、词典、提示词、附件引用与 MemU 数据库,外加逐文件校验和的 manifest;切换引擎/恢复前会自动做一份。导出 JSON/ZIP 是单库导出,ZIP 额外带图片附件;两种导出都能再导入回来。',
818
+ helpFaq9: '删除的对话还会被整理吗?',
819
+ helpFaq9A: '不会。会话被删除后,空闲整理扫描会把它标记为 sessionGone 并跳过,只记一条 check 级日志,不反复重试,也不影响其他会话的整理。',
820
+ helpFaq10: '本地向量模型未激活怎么办?',
821
+ helpFaq10A: '没有 active 向量代际时会回退词法检索,仍可用但语义更弱。「当前已入向量库 N 条」指 active 代际里的条数;切换模型或修复失败后要重建,旧代际在重建期间只读,失败不会动当前代际,可再点一次续建。',
822
+ helpFeedback: '反馈',
823
+ helpOpenIssue: '提交 Issue',
824
+ helpCopyDiagnostics: '复制诊断信息',
825
+ helpDocs: '打开文档',
637
826
  }
638
827
 
639
828
  const en = {
@@ -665,6 +854,7 @@ window.__ModuleLoader__.load({
665
854
  logClear: 'Clear log',
666
855
  runLogTitle: 'Runs',
667
856
  runLogEmpty: 'No runs recorded yet.',
857
+ runLogOpen: 'All logs',
668
858
  dedupedNotice: 'Not written: the library already holds a near-identical memory [{id}]. Edit and merge into that entry instead; pass allow_duplicate only for two genuinely independent observations.',
669
859
  consolidateFeedTitle: 'Consolidation alerts',
670
860
  consolidateFailedCol: 'Failed',
@@ -679,6 +869,9 @@ window.__ModuleLoader__.load({
679
869
  consolidateClearHint: 'Deletes only failed consolidation logs; successes and other logs stay',
680
870
  consolidateCleared: 'Cleared {n} failure logs',
681
871
  allLibraries: 'All libraries',
872
+ wallAllBlocked: 'This host only allows one library per read; showing the first library',
873
+ linkCountTag: 'Links {n}',
874
+ linkRemoved: 'Link removed',
682
875
  searchMemories: 'Search titles, content or tags',
683
876
  poolReview: 'Review',
684
877
  poolInactive: 'Inactive',
@@ -686,6 +879,10 @@ window.__ModuleLoader__.load({
686
879
  wallLimit: 'Showing the latest 300 entries',
687
880
  wallScopePrivate: 'Private',
688
881
  wallScopeGlobal: 'Global',
882
+ wallFilterScope: 'Scope',
883
+ wallFilterStatus: 'Status',
884
+ wallFilterLayer: 'Layer',
885
+ wallAllLayers: 'All layers',
689
886
  multiSelect: 'Multi-select',
690
887
  wallSelected: '{n} selected',
691
888
  wallSelectAll: 'Select page',
@@ -715,7 +912,17 @@ window.__ModuleLoader__.load({
715
912
  characterId: 'Current character ID',
716
913
  worldId: 'World ID',
717
914
  branchId: 'Story branch ID',
718
- exportImages: 'Export with images',
915
+ exportZip: 'Export ZIP (with images)',
916
+ exportStarted: 'Export started: {file}',
917
+ memorySaved: 'Memory saved',
918
+ memoryArchived: 'Memory archived',
919
+ memoryRestored: 'Memory restored',
920
+ memoryDeleted: 'Memory deleted',
921
+ bulkDone: 'Bulk action finished: {n}',
922
+ curatorReview: 'Curation review mode',
923
+ descCuratorReview: 'How global curation writes: auto holds conflicts, missing evidence and risky rewrites for review; review-everything sends each content change to the review queue.',
924
+ curatorReviewAuto: 'Auto (default)',
925
+ curatorReviewAll: 'Review everything',
719
926
  archiveTooLarge: 'Attachment archive exceeds 16 MB; split it before importing',
720
927
  openSettings: 'Open settings',
721
928
  sidebarSettings: 'Keep full settings in the sidebar',
@@ -726,7 +933,7 @@ window.__ModuleLoader__.load({
726
933
  adjacency: 'Adjacency',
727
934
  graphView: 'Graph',
728
935
  viewMemories: 'Memories',
729
- viewRelations: 'Relations',
936
+ viewRelations: 'Node graph',
730
937
  close: 'Close',
731
938
  openInBrowser: 'Open in the browser',
732
939
  globalReadOnly: 'Global memories are read-only here; edit them on the global page.',
@@ -745,6 +952,7 @@ window.__ModuleLoader__.load({
745
952
  entityAliases: 'Aliases (comma separated)',
746
953
  entityKind: 'Entity kind',
747
954
  graphEmpty: 'No connections yet',
955
+ graphAutoLink: 'Auto',
748
956
  deletedNode: '[deleted]',
749
957
  graphLimited: 'Showing at most 200 nodes',
750
958
  graphFit: 'Fit graph',
@@ -826,6 +1034,13 @@ window.__ModuleLoader__.load({
826
1034
  reviews: 'Awaiting review',
827
1035
  reviewsEmpty: 'No memories are waiting for review.',
828
1036
  reviewScopePrivate: 'private library',
1037
+ // Package F2: set-absorb (absorb) review and undo.
1038
+ absorbTag: 'Set absorb · {n} entries',
1039
+ absorbSourcesTag: 'Merged memory · {n} sources',
1040
+ absorbUndo: 'Undo merge',
1041
+ absorbUndone: 'Merge undone; source memories restored',
1042
+ absorbSourcesLabel: 'Absorbed sources',
1043
+ absorbLinksLabel: 'Linked memories',
829
1044
  accuracyTag: 'details may be off',
830
1045
  accept: 'Accept',
831
1046
  reject: 'Discard',
@@ -1162,7 +1377,170 @@ window.__ModuleLoader__.load({
1162
1377
  rememberChoice: 'Remember this choice for new conversations',
1163
1378
  confirm: 'Confirm',
1164
1379
  later: 'Later',
1380
+ // —— Lexicon page ——
1381
+ pageLexicon: 'Lexicon',
1382
+ descLexicon: 'One lexicon per memory library: slang, memes, triggers and terms with meanings, archive and hit counts.',
1383
+ lexiconScope: 'Terms',
1384
+ lexiconNoLibrary: 'No memory libraries yet; a lexicon comes with each private library.',
1385
+ lexiconTotal: '{total} terms',
1386
+ lexiconTerm: 'Term',
1387
+ lexiconKind: 'Kind',
1388
+ lexiconAllKinds: 'All kinds',
1389
+ lexiconKindSlang: 'Slang',
1390
+ lexiconKindMeme: 'Meme',
1391
+ lexiconKindTrigger: 'Trigger',
1392
+ lexiconKindTerm: 'Term',
1393
+ lexiconMeaning: 'Meaning',
1394
+ lexiconUsage: 'Usage',
1395
+ lexiconAliases: 'Aliases (comma separated)',
1396
+ lexiconExamples: 'Examples (one per line)',
1397
+ lexiconSensitivity: 'Sensitivity',
1398
+ lexiconSensitivityAll: 'All sensitivities',
1399
+ lexiconNormal: 'Normal',
1400
+ lexiconSensitive: 'Sensitive',
1401
+ lexiconStatus: 'Status',
1402
+ lexiconAllStatus: 'All statuses',
1403
+ lexiconStPending: 'Pending',
1404
+ lexiconStRejected: 'Rejected',
1405
+ lexiconPending: 'Pending',
1406
+ lexiconApprove: 'Approve',
1407
+ lexiconReject: 'Reject',
1408
+ lexiconHits: 'Hits',
1409
+ lexiconStrength: 'Strength',
1410
+ lexiconSourceMemory: 'Source memory ↗',
1411
+ lexiconSearch: 'Search terms / meanings / aliases',
1412
+ lexiconNew: 'New term',
1413
+ lexiconCreate: 'Add to lexicon',
1414
+ lexiconEdit: 'Edit term',
1415
+ lexiconSaveDone: 'Term saved.',
1416
+ lexiconReviewDone: 'Review recorded.',
1417
+ lexiconAddDone: 'Term added.',
1418
+ lexiconDelete: 'Delete terms',
1419
+ lexiconDeleteHint: 'Deletion is permanent: click "Delete terms" once, then confirm.',
1420
+ lexiconDeleted: 'Deleted {n} terms.',
1421
+ lexiconPrev: 'Previous',
1422
+ lexiconNext: 'Next',
1423
+ lexiconPageOf: 'Page {page} / {pages}',
1424
+ lexiconEmpty: 'This lexicon has no terms yet.',
1425
+ lexiconNoMatch: 'No matching terms.',
1426
+ lexiconTabActive: 'In use ({n})',
1427
+ lexiconTabArchived: 'Archived ({n})',
1428
+ lexiconArchivedEmpty: 'No archived terms.',
1429
+ lexiconArchive: 'Archive',
1430
+ lexiconRestore: 'Restore',
1431
+ lexiconArchiveDone: 'Term archived — you can restore it anytime.',
1432
+ lexiconRestoreDone: 'Term restored.',
1433
+ lexiconArchivedCount: 'Archived {n} term(s).',
1434
+ lexiconRestoredCount: 'Restored {n} term(s).',
1435
+ lexiconCardUsage: 'Usage',
1436
+ lexiconCardAliases: 'Aliases',
1165
1437
  loading: 'Loading…',
1438
+ // ── Package C/D/E keys (lexicon proposals + entity visibility + help page) ──
1439
+ lexiconProposal: 'Proposal',
1440
+ lexiconPendingEmpty: 'No pending term proposals.',
1441
+ lexiconHitsDetail: 'Hit detail: {candidate} candidates · {injection} injections · {query} queries',
1442
+ orphanEntities: 'Unlinked entities ({n})',
1443
+ orphanPruneHint: 'No memory or relationship references these entities; the plugin cleans them up automatically at the next startup.',
1444
+ entityEmpty: 'No entities in this library yet.',
1445
+ firstRunTitle: 'Create a memory library first',
1446
+ firstRunDesc: 'There are no libraries yet. Memories from your conversations need one to land in.',
1447
+ firstRunGoLibraries: 'Create a library',
1448
+ firstRunGuide: 'Quick start guide',
1449
+ pageHelp: 'Help',
1450
+ descHelp: 'Quick start, scope and label reference, FAQ and feedback',
1451
+ helpQuickStart: 'Quick start',
1452
+ helpGoTo: 'Go →',
1453
+ helpStep1: 'Create a private library: create one on the Libraries page. Success looks like the project library in the list, and the folders page showing it too.',
1454
+ helpStep2: 'Bind the library in the conversation: ask the assistant to call memu_bind (or send the /memu bind command), or pick the scope in the new-session card. Success looks like the sidebar session card naming this library.',
1455
+ helpStep3: 'Pick an injection mode: in Settings → Injection mode choose smart (on demand), anchor-only (anchor only), per-turn (every turn) or off. Success looks like the status card showing the chosen mode and the next turn following it.',
1456
+ helpStep4: 'Confirm and correct on the memory page: the MemU memory page lists recalled and written entries. Open the drawer for the full text, fix mistakes, archive what you do not need; changes take effect immediately.',
1457
+ helpStep5: 'Maintain slang on the lexicon page: entries the AI writes become active automatically and join injection; you can edit, archive/restore and permanently delete, and sensitive triggers are user-only. Success looks like the term under "In use", with archived ones restorable under "Archived".',
1458
+ helpScopes: 'Private vs global',
1459
+ helpScopesDesc: 'The folder sets the ceiling, the conversation sets the switches.',
1460
+ helpScopesChain: 'Decision order: workspace → folder policy (useLibrary / useGlobal ceilings) → session binding (actual switches) → effectiveScope. The folder is a ceiling; a conversation can only work within it.',
1461
+ helpScopesPrivate: 'Private = every conversation in this folder shares one library: another chat in the same folder reads the same entries, writes are visible to all of them, and entries stay editable, archivable and restorable.',
1462
+ helpScopesGlobal: 'Global = the curator consolidates across conversations in the background and writes it, filtered by domain and applies_to; in the UI it is read-only — review, undo a merge or remove a link, never hand-edit the body.',
1463
+ helpScopesWhere: 'Where to change it: the folders page sets the ceilings; the sidebar session card sets this conversation; the global page only reviews.',
1464
+ helpScopesPitfallsTitle: 'Common pitfalls',
1465
+ helpScopesPitfall1: 'With global off in the folder, a conversation cannot turn it back on — the ceiling wins and the switch is shown disabled.',
1466
+ helpScopesPitfall2: 'A conversation with global only has no private library; private-memory tools say so instead of creating one silently.',
1467
+ helpScopesPitfall3: 'Entries carrying context (channel / character / world / branch / knownBy) stay hidden from conversations that do not match; that is filtering, not loss.',
1468
+ helpTitleCards: 'Title cards and recall',
1469
+ helpTitleCardsBody: 'The anchor and per-turn recall show title cards only: title, tags and id; bodies are not injected. The full text still lives in the memory drawer and the recall tools.',
1470
+ helpTitleCardsRecall: 'The title is a directory: recall by title keyword, or by the id shown in brackets on the card, with memu_private_recall / memu_global_recall; the tools still return the full text.',
1471
+ helpTitleCardsRule: 'Title rule: 8–40 characters naming the concrete person/object/event or conclusion ("Yuike is named after a childhood white cat"); no empty titles or "about X" labels; details belong in content.',
1472
+ helpArchiveDelete: 'Archive, delete and undo',
1473
+ helpArchiveDeleteArchive: 'Archive = soft delete: restorable at any time, links and relationships kept, no cascade; the entry moves to the "Archived" tab and stops being injected.',
1474
+ helpArchiveDeleteDelete: 'Permanent delete: the UI offers it only for archived entries, with a two-step confirmation. It is final and also removes inbound links to the entry and prunes entities left orphaned by it.',
1475
+ helpArchiveDeleteLexicon: 'The lexicon follows the same rules: archive is restorable, delete is permanent; the model has no delete or review path, and sensitive terms stay user-managed.',
1476
+ helpArchiveDeleteAbsorb: 'Absorb: the merged entry becomes active, the target is marked superseded and the sources are archived. On the global page press "Undo merge" on the merged entry to roll it all back; global links can also be removed one by one.',
1477
+ helpArchiveDeleteSessionGone: 'A deleted conversation never fails consolidation: the sweep marks it sessionGone and skips it instead of retrying forever.',
1478
+ helpVector: 'Vector store',
1479
+ helpVectorSetup: 'memoryBackend=api with vectorSource=local means MemU keeps storing and retrieving locally while a managed embedding model supplies the vectors. Pick the local source on "Connection & vectors", then download the runtime and model in the managed-vector area on the same page and switch.',
1480
+ helpVectorCount: '"Currently indexed N items" counts entries that already have vectors in the active generation; "In vector store · gN" on an entry is the generation tag it was written into.',
1481
+ helpVectorFallback: 'With no active generation (only requested, or a failed rebuild) recall falls back to lexical search: still usable, weaker semantics.',
1482
+ helpVectorRebuild: 'Rebuild when switching models or repairing a failure: the old generation stays read-only until the new one commits, and new memories then go to the new generation. A failed rebuild leaves the active generation unchanged; press again to resume.',
1483
+ helpAutoLink: 'Private auto-links and entity hubs',
1484
+ helpAutoLinkRule: "Algorithm first, zero extra model tokens: two memories in the same library, same context/track, visible and not secret, sharing a rare name/alias and not conflicting are linked memory↔memory with source:'auto'.",
1485
+ helpAutoLinkHub: 'Entity hub: "猫娘yuike" and "yuike名字的由来" share yuike, so the algorithm reuses or creates a yuike entity and links both memories to it; person/thing kind is inferred conservatively.',
1486
+ helpAutoLinkWorker: 'Uncertain candidates go to the current consolidation worker (memu_private_link_suggest), which adjudicates with evidence; it never starts another full consolidation or rereads the transcript, and no candidates means no links.',
1487
+ helpAutoLinkSafety: 'Auto-links only add graph metadata: no body changes, no vectors, no deletion. The graph shows an "Auto" badge and a remove (×); entity deletion still follows the existing rules.',
1488
+ helpLabels: 'Label cheat sheet',
1489
+ helpLabelsDesc: 'Badges you will see on memories and in the relationship graph.',
1490
+ helpLabelColLabel: 'Label',
1491
+ helpLabelColMeaning: 'Meaning',
1492
+ helpLabelSource: 'source',
1493
+ helpLabelSourceMeaning: 'Provenance: inferred / user_said (quotable) / told / imagined. imagined must never be treated as fact.',
1494
+ helpLabelLayer: 'layer',
1495
+ helpLabelLayerMeaning: 'Layer: must_remember / long_term / short_term pointer (a pointer only, no body).',
1496
+ helpLabelOpenLoop: 'open_loop',
1497
+ helpLabelOpenLoopMeaning: 'Open loop: a promise or suspense line is still unresolved.',
1498
+ helpLabelMarginalized: 'marginalized',
1499
+ helpLabelMarginalizedMeaning: 'Marginalized: pulled out of primary memory but not deleted; still searchable.',
1500
+ helpLabelDate: 'date / anniversary',
1501
+ helpLabelDateMeaning: 'Date: date is one specific day; anniversary repeats every year (birthday / memorial).',
1502
+ helpLabelAttachment: '附件N',
1503
+ helpLabelAttachmentMeaning: 'Attachment count on a title card; captions are not expanded there — body and images live in the drawer or tools.',
1504
+ helpLabelVector: 'In vector store · gN',
1505
+ helpLabelVectorMeaning: 'The entry is in generation N of the vector space; "Currently indexed N items" counts the active generation.',
1506
+ helpLabelReviewRisk: 'reviewRisk',
1507
+ helpLabelReviewRiskMeaning: 'Review risk score (0–2) for global entries; higher means more likely held for review. It is not confidence.',
1508
+ helpLabelLexicon: 'lexicon kind / sensitive / hitCount · Auto',
1509
+ helpLabelLexiconMeaning: 'Lexicon: kind is the term type; sensitive is user-managed; hitCount is how often it hit. Graph "Auto" = an algorithm-created link.',
1510
+ helpSidebar: 'Sidebar map',
1511
+ helpSidebarDesc: 'Where the reminders and switches live in the sidebar.',
1512
+ helpSidebarAlerts: 'Consolidation alerts aggregate results across conversations: failed rows need attention, ↗ opens the library holding that memory, and "Clear failures" deletes only failed logs — successes and other logs stay.',
1513
+ helpSidebarReview: '"Needs review" = pending_review: nobody has approved the content yet, so it is not injected. The sidebar only shows the count; the review queue is on the global page (private items are tagged "private library" there).',
1514
+ helpSidebarRunLog: '"Runs" lists recent consolidation status, time and message; expanding the alerts card splits failures and successes into two columns.',
1515
+ helpSidebarReviewMode: 'The review-mode switch is on the global memory page: "auto" holds only conflicts, missing evidence and risky rewrites; "review-all" sends every content operation to review.',
1516
+ helpSidebarFirstRun: 'The first-install card appears only while there are no libraries; it disappears once the first one exists. There is no dismiss button and no local storage.',
1517
+ helpSidebarFallback: 'If the host forbids all-library browsing (403), the memory wall falls back to the first accessible library and says so instead of going blank; the multi-library option is disabled.',
1518
+ helpSidebarScope: 'The conversation scope card is on the sidebar Memory page: the top shows folder and conversation, the control card holds the private/global switches, and the collapsed card below sets channel / character / world / branch.',
1519
+ helpFaq: 'FAQ',
1520
+ helpFaq1: 'Why is nothing being recalled?',
1521
+ helpFaq1A: 'Check in order: is the conversation bound to a library (memu_bind / the /memu bind command); is the injection mode off; are the folder or conversation private/global switches off; is the entry still active and not filtered by context (channel/character/world/branch/knownBy) or domain. A missing local vector generation does not empty recall — it only falls back to lexical search.',
1522
+ helpFaq2: 'How do private libraries differ from global memory?',
1523
+ helpFaq2A: 'A private library is shared by every conversation in the folder: writes are editable, archivable and restorable. Global memory is written by the curator across conversations, filtered by domain/applies_to, and read-only in the UI — review, undo a merge or remove a link, never hand-edit.',
1524
+ helpFaq3: 'Why are some memories not injected?',
1525
+ helpFaq3A: 'Common reasons: pending_review (waiting for approval), archived/suppressed/expired/superseded, visibility=secret (automatic injection skips it), or a context/domain mismatch. Lexicon terms have no pending review anymore: they are active on write and only archived terms stop being injected.',
1526
+ helpFaq4: 'Why does the anchor show only titles, and how do I get the body?',
1527
+ helpFaq4A: 'The anchor and per-turn recall are a directory: title, tags and id only, to save budget and avoid quoting text the model was not given. Fetch the body with memu_private_recall / memu_global_recall by title keyword or id; the tools still return full text, and the memory-page drawer shows it too.',
1528
+ helpFaq5: 'Where did the original entries go after an absorb, and how do I undo it?',
1529
+ helpFaq5A: 'Absorbing deletes nothing: the merged entry becomes active, the target is marked superseded and the sources are archived. Find the merged entry on the global memory page and press "Undo merge" to restore everything; global links can be removed individually.',
1530
+ helpFaq6: 'The lexicon writes automatically — how do I archive, and why is a term not injected?',
1531
+ helpFaq6A: 'Lexicon terms go active immediately: there is no pending queue. Archiving stops injection at once and is restorable; permanent deletion exists only in the admin UI and cannot be undone. If a term is not injected, check whether it is archived or its context/visibility does not match. Sensitive triggers are user-only.',
1532
+ helpFaq7: 'How do I undo an auto-link or entity hub?',
1533
+ helpFaq7A: 'Edges with the "Auto" badge in the relationship graph were created by the algorithm: press × and confirm to remove one. Removal only touches graph metadata — no body, no vector, no deletion. Entity deletion follows the existing rules: refused while referenced, and force also removes the inbound links.',
1534
+ helpFaq8: 'Automatic backups vs JSON/ZIP export?',
1535
+ helpFaq8A: 'An automatic backup is a whole-install snapshot: plugin state, global/private indexes, lexicon, prompts, attachment references and the MemU databases, plus a manifest with per-file checksums; one is taken before engine switches/restores. JSON/ZIP export is per-library, and ZIP also carries image attachments; both exports can be imported back.',
1536
+ helpFaq9: 'Are deleted conversations still consolidated?',
1537
+ helpFaq9A: 'No. Once a conversation is deleted, the idle sweep marks it sessionGone and skips it, records a check-level log instead of an error, and does not retry forever or affect other conversations.',
1538
+ helpFaq10: 'The vector model is inactive — what happens?',
1539
+ helpFaq10A: 'Recall falls back to lexical search: still usable, weaker semantically. "Currently indexed N items" counts the active generation. Switching models or repairing a failure requires a rebuild; the old generation stays read-only during it, a failed rebuild leaves the active generation unchanged, and pressing again resumes.',
1540
+ helpFeedback: 'Feedback',
1541
+ helpOpenIssue: 'Open an issue',
1542
+ helpCopyDiagnostics: 'Copy diagnostics',
1543
+ helpDocs: 'Documentation',
1166
1544
  }
1167
1545
 
1168
1546
  // One token table for both surfaces. Every colour goes through a DSH alias so
@@ -1272,6 +1650,8 @@ window.__ModuleLoader__.load({
1272
1650
  .dshm-graph-tools{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:12px 0}
1273
1651
  .dshm-graph-tools input,.dshm-graph-tools select{min-width:0;max-width:100%;padding:7px;border:1px solid var(--dsw-alias-border-l2,#505158);border-radius:4px;background:var(--dsw-alias-bg-layer-1,#242529);color:inherit;font:inherit}
1274
1652
  .dshm-graph-tools input{flex:1 1 160px}
1653
+ .dshm-root select{height:32px;max-width:100%;padding:0 30px 0 10px!important;border:1px solid var(--dsw-alias-border-l1,#dfe1e6);border-radius:9px;background-color:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.08));color:var(--dsw-alias-label-primary,inherit);font:inherit;font-size:12.5px;line-height:1.2;text-overflow:ellipsis;cursor:pointer;appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23808a99' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")!important;background-repeat:no-repeat!important;background-position:right 9px center!important;background-size:10px 6px!important}
1654
+ .dshm-root select:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#2563eb);outline-offset:2px}
1275
1655
  .dshm-graph-list{display:grid;gap:8px;margin:12px 0}
1276
1656
  .dshm-graph-edge{display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--dsw-alias-border-l1,#43444a);padding:10px 0;min-width:0}
1277
1657
  .dshm-graph-edge>div{flex:1;min-width:0;overflow-wrap:anywhere}
@@ -1299,15 +1679,17 @@ window.__ModuleLoader__.load({
1299
1679
  .dshm-graph-missing{opacity:.5}
1300
1680
  /* The sidebar's context-scope form: a collapsed card instead of four bare
1301
1681
  inputs and a browser-default button. */
1302
- .dshm-scope{margin:0 12px 10px;border:1px solid var(--dsw-alias-border-l1,#ddd);border-radius:12px;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.05));overflow:hidden}
1682
+ .dshm-scope{width:100%;margin:0 0 10px;border:1px solid var(--dsw-alias-border-l1,#ddd);border-radius:12px;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.05));overflow:hidden;flex-shrink:0}
1303
1683
  .dshm-scope>summary{display:flex;align-items:center;gap:7px;padding:9px 12px;list-style:none;cursor:pointer;font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary,rgba(127,127,127,.8));transition:color .18s ease}
1304
1684
  .dshm-scope>summary::-webkit-details-marker{display:none}
1305
1685
  .dshm-scope>summary::before{content:"";width:6px;height:6px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .2s ease;margin-right:1px}
1306
1686
  .dshm-scope[open]>summary::before{transform:rotate(45deg)}
1307
1687
  .dshm-scope>summary:hover{color:var(--dsw-alias-label-primary,inherit)}
1308
1688
  .dshm-scope>summary:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#2563eb);outline-offset:-2px}
1309
- .dshm-scope-form{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;padding:0 12px 12px}
1310
- .dshm-scope-field{display:flex;flex-direction:column;gap:4px;min-width:0;font-size:11px;color:var(--dsw-alias-label-secondary,rgba(127,127,127,.8))}
1689
+ .dshm-scope-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;padding:0 12px 12px}
1690
+ .dshm-scope-field{display:flex;flex-direction:column;gap:4px;min-width:0;font-size:11px;line-height:1.35;color:var(--dsw-alias-label-secondary,rgba(127,127,127,.8))}
1691
+ .dshm-scope-field>span{display:block;min-height:15px}
1692
+ .dshm-tab-body>*{flex-shrink:0}
1311
1693
  .dshm-scope-field input{height:32px;min-width:0;padding:0 10px;border:1px solid var(--dsw-alias-border-l1,#ddd);border-radius:9px;background:var(--dsw-alias-bg-layer-1,transparent);color:var(--dsw-alias-label-primary,inherit);font:inherit;font-size:12.5px;outline:none;transition:border-color .18s ease,box-shadow .18s ease}
1312
1694
  .dshm-scope-field input:focus{border-color:var(--dsw-alias-brand-primary,#2563eb);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary,#2563eb) 16%,transparent)}
1313
1695
  .dshm-scope-actions{grid-column:1/-1;display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:2px}
@@ -1615,6 +1997,7 @@ window.__ModuleLoader__.load({
1615
1997
  const GlyphText = glyph(['M5 6h14', 'M5 11h14', 'M5 16h9'])
1616
1998
  const GlyphLayers = glyph(['M12 3l9 5-9 5-9-5 9-5z', 'M3 13l9 5 9-5'])
1617
1999
  const GlyphChart = glyph(['M4 19V5', 'M4 19h16', 'M8 15v-4', 'M12 15V8', 'M16 15v-6'])
2000
+ const GlyphQuestion = glyph(['M9.1 9a3 3 0 0 1 5.8 1c0 2-3 2.4-3 4', 'M12 17.5h.01'])
1618
2001
 
1619
2002
  // Thirty daily bars, written by hand: the bundle purity gate refuses absolute
1620
2003
  // imports, and a chart this small does not need a library. Zero days still draw
@@ -1713,13 +2096,14 @@ window.__ModuleLoader__.load({
1713
2096
  h('div', { style: { ...S.barFill, width: `${pct}%` } }))
1714
2097
  }
1715
2098
 
1716
- function Button({ children, onClick, disabled, primary, style, title, type = 'button' }) {
2099
+ function Button({ children, onClick, disabled, primary, style, title, type = 'button', 'aria-pressed': ariaPressed }) {
1717
2100
  return h('button', {
1718
2101
  type,
1719
2102
  className: 'dshm-btn',
1720
2103
  // Without this the prop never reaches the node, and every hover hint on
1721
2104
  // the surface silently stops working.
1722
2105
  title,
2106
+ 'aria-pressed': ariaPressed,
1723
2107
  style: { ...S.btn, ...(primary ? S.primary : {}), ...(style || {}), opacity: disabled ? 0.5 : 1 },
1724
2108
  onClick,
1725
2109
  disabled,
@@ -1862,6 +2246,20 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
1862
2246
  h('div', { style: { display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 } }, footer || null)))
1863
2247
  }
1864
2248
 
2249
+ // Library selectors previously showed the whole absolute scope path, which made
2250
+ // every option a long, nearly identical line. Keep the folder leaf as context and
2251
+ // disambiguate same-looking names with a short id suffix only when needed.
2252
+ function libraryBaseLabel(lib) {
2253
+ const name = String(lib?.name || lib?.id || '')
2254
+ const leaf = String(lib?.scopeKey || '').split(/[\\/]/).filter(Boolean).pop() || ''
2255
+ return leaf && leaf.toLowerCase() !== name.toLowerCase() ? `${name} · ${leaf}` : name
2256
+ }
2257
+ function libraryOptionLabel(lib, libraries = []) {
2258
+ const base = libraryBaseLabel(lib)
2259
+ const duplicated = (Array.isArray(libraries) ? libraries : []).filter((item) => libraryBaseLabel(item) === base).length > 1
2260
+ return duplicated ? `${base} · ${String(lib?.id || '').slice(-4)}` : base
2261
+ }
2262
+
1865
2263
  function useRpc(ctx) {
1866
2264
  return React.useCallback((endpoint, payload) => rpc(ctx, endpoint, payload), [ctx])
1867
2265
  }
@@ -1875,7 +2273,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
1875
2273
  const ui = ctx.get?.('uiSession') || ctx.uiSession
1876
2274
  const cur = ui?.adapter?.current
1877
2275
  if (cur?.getSnapshot) {
1878
- return { subscribe: cur.subscribe.bind(cur), get: () => cur.getSnapshot()?.key }
2276
+ return { subscribe: typeof cur.subscribe === 'function' ? cur.subscribe.bind(cur) : function () { return function () {} }, get: function () { return cur.getSnapshot()?.key } }
1879
2277
  }
1880
2278
  return null
1881
2279
  }, [ctx])
@@ -2142,6 +2540,38 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2142
2540
  }
2143
2541
  const titles = new Map(graph.nodes.map(node => [node.id, node.missing ? t('deletedNode') : node.title]))
2144
2542
  const edges = graph.edges.filter(edge => !focus || edge.from === focus || edge.to === focus).sort((a, b) => b.confidence - a.confidence)
2543
+ // 实体行:改名表单 + 删除按钮。删除是文字按钮(§6.4 包 D,原灰色 × 读不出
2544
+ // 语义):未 armed 显示「删除」,armed 显示「确认删除」,后端拒绝过(仍被
2545
+ // 引用)再点确认升级为 force——「确认删除!」,连同引用一起删,force 明示的
2546
+ // 代价。title 与两段确认保持不变,后端 force 语义零改动。
2547
+ const entityForm = entity => h('form', { key: entity.id, className: 'dshm-graph-tools', onSubmit: event => {
2548
+ event.preventDefault()
2549
+ const data = new FormData(event.currentTarget)
2550
+ void act('renameEntity', { entityId: entity.id, name: String(data.get('name')), aliases: String(data.get('aliases')).split(',').map(value => value.trim()).filter(Boolean) })
2551
+ } },
2552
+ h('input', { name: 'name', defaultValue: entity.name, 'aria-label': t('entityName') }),
2553
+ h('input', { name: 'aliases', defaultValue: (entity.aliases || []).join(', '), placeholder: t('entityAliases'), 'aria-label': t('entityAliases') }),
2554
+ h('button', { type: 'submit', className: 'dshm-btn', style: S.btn }, t('save')),
2555
+ h('button', {
2556
+ type: 'button',
2557
+ className: `dshm-graph-unlink${armed === 'entity:' + entity.id ? ' is-armed' : ''}`,
2558
+ title: deleteRefused === entity.id ? t('entityDeleteForceHint') : t('entityDelete'),
2559
+ 'aria-label': deleteRefused === entity.id ? t('entityDeleteForceHint') : t('entityDelete'),
2560
+ onClick: async () => {
2561
+ const key = 'entity:' + entity.id
2562
+ if (armed !== key) { setArmed(key); return }
2563
+ try {
2564
+ await call('deleteEntity', { libraryId, entityId: entity.id, force: deleteRefused === entity.id })
2565
+ setArmed(''); setDeleteRefused('')
2566
+ notifyMemoryWrite('relation', libraryId)
2567
+ if (mounted.current) await refresh()
2568
+ } catch (failure) {
2569
+ const message = String(failure?.message || failure)
2570
+ if (/referenced|引用/.test(message)) setDeleteRefused(entity.id)
2571
+ if (mounted.current) setError(message)
2572
+ }
2573
+ },
2574
+ }, armed === 'entity:' + entity.id ? (deleteRefused === entity.id ? t('relationConfirm') + '!' : t('relationConfirm')) : t('entityDelete')))
2145
2575
  return h('div', null,
2146
2576
  error ? h('div', { className: 'dshm-toast', role: 'status' }, error) : null,
2147
2577
  h('div', { className: 'dshm-graph-tools' },
@@ -2158,6 +2588,9 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2158
2588
  h('div', { className: 'dshm-graph-list' }, edges.length ? edges.map(edge => h('div', { key: edge.from + edge.to + edge.kind, className: 'dshm-graph-edge' },
2159
2589
  h('div', null, h('button', { type: 'button', className: 'dshm-graph-node', onClick: () => setFocus(edge.from) }, titles.get(edge.from) || edge.from),
2160
2590
  ' → ', h('button', { type: 'button', className: 'dshm-graph-node', onClick: () => setFocus(edge.to) }, titles.get(edge.to) || edge.to),
2591
+ // 私有自动关联(§5.7):source:'auto' 的边来自算法/实体枢纽,不是用户手连。
2592
+ // 只多一个可点读的小 Tag;kind/confidence/note 与移除按钮保持原样。
2593
+ edge.source === 'auto' ? h('span', { style: { margin: '0 5px', verticalAlign: 'middle' } }, h(Tag, { tone: 'info' }, t('graphAutoLink'))) : null,
2161
2594
  h('small', null, edge.kind + ' · ' + Math.round(edge.confidence * 100) + '% · ' + edge.note)),
2162
2595
  h('button', {
2163
2596
  type: 'button',
@@ -2181,35 +2614,20 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2181
2614
  h('select', { value: entityKind, title: t('entityKind'), onChange: event => setEntityKind(event.target.value) }, ['person', 'place', 'thing', 'org'].map(value => h('option', { key: value, value }, value))),
2182
2615
  h(Button, { disabled: !entityName.trim(), onClick: () => act('createEntity', { name: entityName, kind: entityKind }) }, t('entityNew'))),
2183
2616
  h(RelationshipPanel, { call, libraryId, entities, files, t }),
2184
- entities.map(entity => h('form', { key: entity.id, className: 'dshm-graph-tools', onSubmit: event => {
2185
- event.preventDefault()
2186
- const data = new FormData(event.currentTarget)
2187
- void act('renameEntity', { entityId: entity.id, name: String(data.get('name')), aliases: String(data.get('aliases')).split(',').map(value => value.trim()).filter(Boolean) })
2188
- } },
2189
- h('input', { name: 'name', defaultValue: entity.name, 'aria-label': t('entityName') }),
2190
- h('input', { name: 'aliases', defaultValue: (entity.aliases || []).join(', '), placeholder: t('entityAliases'), 'aria-label': t('entityAliases') }),
2191
- h('button', { type: 'submit', className: 'dshm-btn', style: S.btn }, t('save')),
2192
- // 删除实体:两段确认。仍被 links/关系快照引用时后端默认拒绝(错误如实显示),
2193
- // 此时再点一次升级为 force——连同引用一起删,这是 force 明示的代价。
2194
- h('button', {
2195
- type: 'button',
2196
- className: `dshm-graph-unlink${armed === 'entity:' + entity.id ? ' is-armed' : ''}`,
2197
- title: deleteRefused === entity.id ? t('entityDeleteForceHint') : t('entityDelete'),
2198
- onClick: async () => {
2199
- const key = 'entity:' + entity.id
2200
- if (armed !== key) { setArmed(key); return }
2201
- try {
2202
- await call('deleteEntity', { libraryId, entityId: entity.id, force: deleteRefused === entity.id })
2203
- setArmed(''); setDeleteRefused('')
2204
- notifyMemoryWrite('relation', libraryId)
2205
- if (mounted.current) await refresh()
2206
- } catch (failure) {
2207
- const message = String(failure?.message || failure)
2208
- if (/referenced|引用/.test(message)) setDeleteRefused(entity.id)
2209
- if (mounted.current) setError(message)
2210
- }
2211
- },
2212
- }, armed === 'entity:' + entity.id ? (deleteRefused === entity.id ? t('relationConfirm') + '!' : t('relationConfirm')) : '×'))))
2617
+ entities.length === 0 ? h(Empty, null, t('entityEmpty')) : null,
2618
+ // 孤立分组(§6.4 包 D):listEntities 给出 linked 派生标记。被引用的实体照常
2619
+ // 渲染;孤立实体(linked=false,没有任何记忆 link/关系快照指着)收进折叠区,
2620
+ // 默认收起——它们是启动 prune 还没跑到的残余,不该和正常实体平起平坐。
2621
+ entities.filter(entity => entity.linked !== false).map(entityForm),
2622
+ (() => {
2623
+ const orphans = entities.filter(entity => entity.linked === false)
2624
+ if (!orphans.length) return null
2625
+ return h('details', { key: 'dshm-orphans', className: 'dshm-graph-orphans', style: { margin: '8px 0' } },
2626
+ h('summary', { style: { cursor: 'pointer', fontSize: 12, color: V.textDim } },
2627
+ t('orphanEntities').replace('{n}', String(orphans.length))),
2628
+ h('div', { style: { ...S.muted, margin: '6px 0' } }, t('orphanPruneHint')),
2629
+ orphans.map(entityForm))
2630
+ })())
2213
2631
  }
2214
2632
 
2215
2633
  function InboundToolsInput({ names, onChange }) {
@@ -2238,9 +2656,13 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2238
2656
  item.attachmentCount > 1 ? h('span', { className: 'dshm-wall-cover-count' }, '▧ ' + item.attachmentCount) : null)
2239
2657
  }
2240
2658
 
2241
- function MemoryWallPanel({ call, libraries, revision, stamp, onOpen, t, indexed = null, focus = null }) {
2659
+ function MemoryWallPanel({ call, libraries, revision, stamp, onOpen, t, indexed = null, focus = null, onNotice = null, initialLibraryId = '' }) {
2660
+ // 成功提示走宿主面板的 notice 通道(spec §6.2):失败仍走本面板的 error 通道,
2661
+ // 同一动作只走一条。onNotice 缺省时静默(面板也可能在无 notice 通道的宿主里用)。
2662
+ const notice = (message) => { if (typeof onNotice === 'function' && message) onNotice(message) }
2242
2663
  const [data, setData] = React.useState({ items: [], total: 0, errors: [] })
2243
- const [libraryId, setLibraryId] = React.useState('')
2664
+ const [libraryId, setLibraryId] = React.useState(initialLibraryId || '')
2665
+ const [multiBlocked, setMultiBlocked] = React.useState(false)
2244
2666
  const [view, setView] = React.useState('memories')
2245
2667
  const [graphLib, setGraphLib] = React.useState('')
2246
2668
  const [pool, setPool] = React.useState('active')
@@ -2267,16 +2689,47 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2267
2689
  setScope(focus.libraryId ? 'private' : 'global')
2268
2690
  void openDetail({ id: focus.id, libraryId: focus.libraryId || null, title: focus.title || focus.id })
2269
2691
  }, [focus])
2692
+ const firstLibraryId = libraries?.[0]?.id || ''
2270
2693
  React.useEffect(() => {
2271
2694
  let alive = true
2272
- void call('browseWall', { libraryId: libraryId || null, limit: 300 }).then(value => { if (alive) { setData(value); setError('') } })
2273
- .catch(error => { if (alive) setError(String(error.message || error)) })
2695
+ const load = async () => {
2696
+ try {
2697
+ const value = await call('browseWall', { libraryId: libraryId || null, limit: 300 })
2698
+ if (!alive) return
2699
+ setData(value)
2700
+ setError('')
2701
+ } catch (failure) {
2702
+ if (!alive) return
2703
+ const message = String(failure?.message || failure)
2704
+ // 宿主对“全部记忆库”多库读取返回 403(browseWall 无法表达为单库授权)时,
2705
+ // 退到第一个库并明确告知;单库 + 全局(browseWall 总会带全局)仍能出数据,
2706
+ // 不再整页空。用户可手动切换库;多库选项保留为禁用。
2707
+ if (!libraryId && firstLibraryId && /forbidden|403/i.test(message)) {
2708
+ try {
2709
+ const value = await call('browseWall', { libraryId: firstLibraryId, limit: 300 })
2710
+ if (!alive) return
2711
+ setData(value)
2712
+ setError('')
2713
+ setMultiBlocked(true)
2714
+ setLibraryId(firstLibraryId)
2715
+ return
2716
+ } catch (fallbackFailure) {
2717
+ if (alive) setError(String(fallbackFailure?.message || fallbackFailure))
2718
+ return
2719
+ }
2720
+ }
2721
+ setError(message)
2722
+ }
2723
+ }
2724
+ void load()
2274
2725
  return () => { alive = false }
2275
- }, [call, libraryId, revision, stamp, version])
2726
+ }, [call, libraryId, revision, stamp, version, firstLibraryId])
2276
2727
  React.useEffect(() => {
2277
- if (graphLib) return
2278
- const first = libraryId || libraries[0]?.id || ''
2728
+ if (graphLib && libraries.some((lib) => lib.id === graphLib)) return
2729
+ const selected = libraryId && libraries.some((lib) => lib.id === libraryId) ? libraryId : ''
2730
+ const first = selected || libraries[0]?.id || ''
2279
2731
  if (first) setGraphLib(first)
2732
+ else if (graphLib) setGraphLib('')
2280
2733
  }, [graphLib, libraryId, libraries])
2281
2734
  React.useEffect(() => {
2282
2735
  if (!detail) return undefined
@@ -2358,6 +2811,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2358
2811
  if (Number.isFinite(confidence)) patch.confidence = Math.max(0, Math.min(1, confidence))
2359
2812
  await call('updateMemory', { libraryId: current.item.libraryId, id: current.item.id, patch })
2360
2813
  await refreshDetail(current)
2814
+ notice(t('memorySaved'))
2361
2815
  } catch (failure) {
2362
2816
  setDetail(prev => prev ? { ...prev, busy: false, error: String(failure?.message || failure) } : prev)
2363
2817
  }
@@ -2369,6 +2823,8 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2369
2823
  try {
2370
2824
  await call('updateMemory', { libraryId: current.item.libraryId, id: current.item.id, patch: { status } })
2371
2825
  await refreshDetail(current)
2826
+ // 归档/恢复/保存是三个不同结果,同一条通知通道但各说各话。
2827
+ notice(status === 'archived' ? t('memoryArchived') : status === 'active' ? t('memoryRestored') : t('memorySaved'))
2372
2828
  } catch (failure) {
2373
2829
  setDetail(prev => prev ? { ...prev, busy: false, error: String(failure?.message || failure) } : prev)
2374
2830
  }
@@ -2382,6 +2838,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2382
2838
  setDetail(null)
2383
2839
  setDeleteArmed(false)
2384
2840
  notifyMemoryWrite('memory', current.item.libraryId)
2841
+ notice(t('memoryDeleted'))
2385
2842
  reload()
2386
2843
  } catch (failure) {
2387
2844
  setDetail(prev => prev ? { ...prev, busy: false, error: String(failure?.message || failure) } : prev)
@@ -2411,7 +2868,9 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2411
2868
  setDeleteArmedMulti(false)
2412
2869
  setSelectedIds(new Set())
2413
2870
  if (done > 0) { notifyMemoryWrite('memory', libraryId || null); reload() }
2871
+ // 部分成功不说成全部成功:有失败只走 error(带 done/total 前缀),全成功才报 notice。
2414
2872
  if (failures.length > 0) setError(`${done}/${targets.length} · ` + failures.slice(0, 3).join(';'))
2873
+ else if (done > 0) notice(t('bulkDone').replace('{n}', String(done)))
2415
2874
  }
2416
2875
 
2417
2876
  const shown = detail ? { ...detail.item, ...(detail.file || {}) } : null
@@ -2460,9 +2919,30 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2460
2919
  t('decay') + ' ' + Math.round((Number(shown.decay) || 0) * 100) + '% · ' +
2461
2920
  (shown.updatedAt ? String(shown.updatedAt).slice(0, 16).replace('T', ' ') : '')))
2462
2921
  : detail && detail.file && detail.readonly
2463
- ? h(React.Fragment, null,
2464
- h('p', { className: 'dshm-drawer-copy' }, detail.file.content || shown.preview),
2465
- h('p', { className: 'dshm-drawer-note' }, t('globalReadOnly')))
2922
+ ? (() => {
2923
+ // 全局条目只读(spec §6.7 UI):详情抽屉里只读展示 links / absorbedFrom,
2924
+ // 不提供编辑、不提供手动全局关联。links 移除按钮在全局页(F1 接入)。
2925
+ const absorbed = Array.isArray(detail.file.absorbedFrom) ? detail.file.absorbedFrom : []
2926
+ const links = Array.isArray(detail.file.links) ? detail.file.links : []
2927
+ return h(React.Fragment, null,
2928
+ h('p', { className: 'dshm-drawer-copy' }, detail.file.content || shown.preview),
2929
+ absorbed.length || links.length
2930
+ ? h('div', { className: 'dshm-drawer-note', style: { lineHeight: 1.7 } },
2931
+ absorbed.length
2932
+ ? h('div', null, h('strong', null, t('absorbSourcesTag').replace('{n}', String(absorbed.length))),
2933
+ h('div', { style: { marginTop: 2 } }, absorbed.slice(0, 8).join('、'),
2934
+ absorbed.length > 8 ? `… +${absorbed.length - 8}` : ''))
2935
+ : null,
2936
+ links.length
2937
+ ? h('div', { style: absorbed.length ? { marginTop: 6 } : null },
2938
+ h('strong', null, `${t('absorbLinksLabel')} · ${links.length}`),
2939
+ h('div', { style: { marginTop: 2 } }, links.slice(0, 8).map((link, index) =>
2940
+ h('div', { key: `${link.id || index}` }, `· ${link.kind || 'related'} → ${link.to || '?'}`)),
2941
+ links.length > 8 ? h('div', null, `… +${links.length - 8}`) : null))
2942
+ : null)
2943
+ : null,
2944
+ h('p', { className: 'dshm-drawer-note' }, t('globalReadOnly')))
2945
+ })()
2466
2946
  : detail && detail.busy
2467
2947
  ? h(Empty, null, t('loading'))
2468
2948
  : null
@@ -2536,7 +3016,8 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2536
3016
  h(Button, { style: S.ghost, onClick: () => setDetail(null) }, t('close')))))
2537
3017
  : null
2538
3018
 
2539
- const graphLibrary = graphLib || libraryId || libraries[0]?.id || ''
3019
+ const validLib = (id) => id && libraries.some((lib) => lib.id === id) ? id : ''
3020
+ const graphLibrary = validLib(graphLib) || validLib(libraryId) || libraries[0]?.id || ''
2540
3021
  const graphFiles = React.useMemo(() => items
2541
3022
  .filter(item => item.libraryId === graphLibrary)
2542
3023
  .map(item => ({ id: item.id, title: item.title })), [items, graphLibrary])
@@ -2547,65 +3028,126 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2547
3028
  h('div', { className: 'dshm-graph-tools' },
2548
3029
  h('select', {
2549
3030
  value: graphLibrary,
2550
- title: t('currentLibrary'),
3031
+ title: libraries.find((lib) => lib.id === graphLibrary)?.scopeKey || t('currentLibrary'),
2551
3032
  // One library drives both views, otherwise the graph's focus list can
2552
3033
  // be empty when the wall is filtered to a different library.
2553
3034
  onChange: (event) => { setGraphLib(event.target.value); setLibraryId(event.target.value) },
2554
3035
  },
2555
- libraries.map(lib => h('option', { key: lib.id, value: lib.id }, lib.name || lib.id)))),
3036
+ libraries.map(lib => h('option', { key: lib.id, value: lib.id }, libraryOptionLabel(lib, libraries))))),
2556
3037
  h(GraphPanel, { key: graphLibrary, call, libraryId: graphLibrary, files: graphFiles, t })))
2557
3038
 
3039
+ // Category filters used to reuse the compact nav pills (2px gap, 30px high) and
3040
+ // stacked directly on top of each other. Give every group its own segmented
3041
+ // control, a label and breathing room so the state/layer filters are distinct.
3042
+ const wallTabGroup = (items, current, onSelect, { label = '', className = '', marginTop = 0, extra = null } = {}) => h('div', {
3043
+ className,
3044
+ style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', margin: `${marginTop}px 0 0` },
3045
+ },
3046
+ label ? h('span', { style: { ...S.muted, fontSize: 11.5, minWidth: 30 } }, label) : null,
3047
+ h('div', {
3048
+ role: 'tablist',
3049
+ 'aria-label': label || undefined,
3050
+ style: {
3051
+ display: 'inline-flex', flexWrap: 'wrap', alignItems: 'stretch', gap: 4,
3052
+ padding: 4, maxWidth: '100%', boxSizing: 'border-box',
3053
+ border: `1px solid ${V.borderSoft}`, borderRadius: V.radius, background: V.surface,
3054
+ },
3055
+ }, items.map(([value, text]) => {
3056
+ const selected = current === value
3057
+ return h('button', {
3058
+ key: value, type: 'button', role: 'tab', 'aria-selected': selected,
3059
+ className: 'dshm-nav-item',
3060
+ style: {
3061
+ height: 34, minHeight: 34, padding: '0 14px', border: 0, borderRadius: V.radiusSm,
3062
+ background: selected ? V.accentSoft : 'transparent',
3063
+ color: selected ? V.accent : V.textDim,
3064
+ fontWeight: selected ? 650 : 500, fontSize: 12.5, lineHeight: 1.2,
3065
+ cursor: 'pointer', whiteSpace: 'nowrap',
3066
+ transition: 'background .18s ease, color .18s ease',
3067
+ },
3068
+ onClick: () => onSelect(value),
3069
+ }, text)
3070
+ })),
3071
+ extra)
3072
+
3073
+ const scopeItems = [['private', 'wallScopePrivate'], ['global', 'wallScopeGlobal']]
3074
+ .map(([value, key]) => [value, t(key) + ' (' + items.filter(item => value === 'global' ? item.libraryId === null : item.libraryId !== null).length + ')'])
3075
+ const poolItems = [['active', 'browseActiveTab'], ['archived', 'browseArchivedTab'], ['review', 'poolReview'], ['inactive', 'poolInactive']]
3076
+ .map(([value, key]) => [value, t(key) + ' (' + scoped.filter(item => poolOf(item) === value).length + ')'])
3077
+ const layerItems = ['all', 'must_remember', 'long_term', 'short_term', 'anchor']
3078
+ .map(value => [value, (value === 'all' ? t('wallAllLayers') : enumText(t, LAYER_LABELS, value)) + ' (' + rows.filter(item => layerMatch(item, value)).length + ')'])
3079
+ const selectedLibrary = libraries.find((lib) => lib.id === libraryId) || null
3080
+ const librarySelect = scope === 'private' && libraries.length
3081
+ ? h('select', {
3082
+ value: libraryId,
3083
+ title: selectedLibrary?.scopeKey || t('currentLibrary'),
3084
+ 'aria-label': t('currentLibrary'),
3085
+ style: { ...S.input, flex: '0 1 220px', minWidth: 150, maxWidth: 260 },
3086
+ onChange: (event) => {
3087
+ const value = event.target.value
3088
+ setLibraryId(value)
3089
+ if (value) setMultiBlocked(false)
3090
+ },
3091
+ },
3092
+ h('option', { value: '', disabled: multiBlocked }, t('allLibraries')),
3093
+ libraries.map((lib) => h('option', { key: lib.id, value: lib.id }, libraryOptionLabel(lib, libraries))))
3094
+ : null
3095
+ const hasOptionControls = scope === 'private' || multiBlocked || data?.total > items.length
3096
+ const multiToggle = scope === 'private'
3097
+ ? h(Button, {
3098
+ 'aria-pressed': multi,
3099
+ style: multi ? { ...S.ghost, color: V.accent, borderColor: V.accent } : S.ghost,
3100
+ onClick: () => { if (multi) exitMulti(); else setMulti(true) },
3101
+ }, multi ? t('wallMultiExit') : t('multiSelect'))
3102
+ : null
3103
+ const multiActions = multi && scope === 'private' ? (() => {
3104
+ const chosen = selectedRows(visible)
3105
+ const archiveN = chosen.filter(item => ['active', 'inactive'].includes(poolOf(item))).length
3106
+ const restoreN = chosen.filter(item => item.status === 'archived').length
3107
+ const deleteN = restoreN
3108
+ return h(React.Fragment, null,
3109
+ h('span', { style: S.muted }, t('wallSelected').replace('{n}', String(chosen.length))),
3110
+ h(Button, { style: S.ghost, disabled: chosen.length === visible.length, onClick: () => setSelectedIds(new Set(visible.map(keyOf))) }, t('wallSelectAll')),
3111
+ h(Button, { style: S.ghost, disabled: chosen.length === 0, onClick: () => setSelectedIds(new Set()) }, t('wallClearSelection')),
3112
+ h(Button, { style: S.ghost, disabled: wallBusy || archiveN === 0, onClick: () => { void bulkApply('archive') } }, t('archive') + ` (${archiveN})`),
3113
+ h(Button, { style: S.ghost, disabled: wallBusy || restoreN === 0, onClick: () => { void bulkApply('restore') } }, t('restore') + ` (${restoreN})`),
3114
+ deleteArmedMulti
3115
+ ? h(React.Fragment, null,
3116
+ h(Button, { style: { ...S.ghost, color: V.danger, borderColor: V.danger }, disabled: wallBusy || deleteN === 0, onClick: () => { void bulkApply('delete') } }, t('confirmDelete') + ` (${deleteN})`),
3117
+ h(Button, { style: S.ghost, disabled: wallBusy, onClick: () => setDeleteArmedMulti(false) }, t('cancelDelete')))
3118
+ : h(Button, { style: { ...S.ghost, color: V.danger }, disabled: wallBusy || deleteN === 0, title: t('browseArchivedHint'), onClick: () => setDeleteArmedMulti(true) }, t('deleteForever') + ` (${deleteN})`))
3119
+ })() : null
3120
+
2558
3121
  return h('div', { className: 'dshm-wall' },
2559
- h('div', { className: 'dshm-nav-list is-row dshm-wall-views', role: 'tablist' },
2560
- [['memories', 'viewMemories'], ['relations', 'viewRelations']].map(([value, key]) =>
2561
- h('button', {
2562
- key: value, type: 'button', role: 'tab', 'aria-selected': view === value,
2563
- className: `dshm-nav-item${view === value ? ' on' : ''}`,
2564
- onClick: () => setView(value),
2565
- }, t(key)))),
3122
+ wallTabGroup([['memories', t('viewMemories')], ['relations', t('viewRelations')]], view, setView, { className: 'dshm-wall-views' }),
2566
3123
  error || data.errors?.length ? h('p', { className: 'dshm-error', role: 'alert' }, error || t('wallReadError')) : null,
2567
3124
  view === 'relations'
2568
3125
  ? graphView
2569
3126
  : h(React.Fragment, null,
2570
3127
  // 组分类:私有 / 全局(默认私有、私有靠前)。计数按各自的块算,切换即重置多选。
2571
- h('div', { className: 'dshm-nav-list is-row', role: 'tablist' },
2572
- [['private', 'wallScopePrivate'], ['global', 'wallScopeGlobal']].map(([value, key]) =>
2573
- h('button', {
2574
- key: value, type: 'button', role: 'tab', 'aria-selected': scope === value,
2575
- className: `dshm-nav-item${scope === value ? ' on' : ''}`,
2576
- onClick: () => { setScope(value); exitMulti() },
2577
- }, t(key) + ' (' + items.filter(item => value === 'global' ? item.libraryId === null : item.libraryId !== null).length + ')'))),
2578
- data?.total > items.length ? h('p', null, t('wallLimit') + ` (${items.length}/${data.total})`) : null,
2579
- h('div', { className: 'dshm-graph-tools' },
2580
- h('input', { type: 'search', value: query, placeholder: t('searchMemories'), 'aria-label': t('searchMemories'), onChange: event => setQuery(event.target.value) }),
2581
- scope === 'private' ? h('select', { value: libraryId, title: t('currentLibrary'), onChange: event => setLibraryId(event.target.value) },
2582
- h('option', { value: '' }, t('allLibraries')), libraries.map(lib => h('option', { key: lib.id, value: lib.id }, lib.name || lib.id))) : null,
3128
+ // The library selector belongs to the scope row, not the search row: it
3129
+ // narrows the same private block the two tabs switch between.
3130
+ wallTabGroup(scopeItems, scope, (value) => { setScope(value); exitMulti() }, { label: t('wallFilterScope'), marginTop: 10, extra: librarySelect }),
3131
+ h('div', { className: 'dshm-graph-tools', style: { marginTop: 10, marginBottom: 0 } },
3132
+ h('input', {
3133
+ type: 'search',
3134
+ style: { ...S.input, width: '100%', flex: '1 1 100%' },
3135
+ value: query, placeholder: t('searchMemories'), 'aria-label': t('searchMemories'),
3136
+ onChange: event => setQuery(event.target.value),
3137
+ })),
3138
+ hasOptionControls ? h('div', {
3139
+ className: 'dshm-graph-tools',
3140
+ role: multi ? 'toolbar' : undefined,
3141
+ 'aria-label': multi ? t('multiSelect') : undefined,
3142
+ style: { marginTop: 8, marginBottom: 0 },
3143
+ },
2583
3144
  // 多选只对私有块:全局条目只读,批量操作没有意义。
2584
- scope === 'private' ? h(Button, { style: S.ghost, onClick: () => exitMulti() + setMulti(v => !v) }, t('multiSelect')) : null),
2585
- multi && scope === 'private' ? (() => {
2586
- const chosen = selectedRows(visible)
2587
- const archiveN = chosen.filter(item => ['active', 'inactive'].includes(poolOf(item))).length
2588
- const restoreN = chosen.filter(item => item.status === 'archived').length
2589
- const deleteN = restoreN
2590
- return h('div', { className: 'dshm-graph-tools', role: 'toolbar', 'aria-label': t('multiSelect') },
2591
- h('span', { style: S.muted }, t('wallSelected').replace('{n}', String(chosen.length))),
2592
- h(Button, { style: S.ghost, disabled: chosen.length === visible.length, onClick: () => setSelectedIds(new Set(visible.map(keyOf))) }, t('wallSelectAll')),
2593
- h(Button, { style: S.ghost, disabled: chosen.length === 0, onClick: () => setSelectedIds(new Set()) }, t('wallClearSelection')),
2594
- h(Button, { style: S.ghost, disabled: wallBusy || archiveN === 0, onClick: () => { void bulkApply('archive') } }, t('archive') + ` (${archiveN})`),
2595
- h(Button, { style: S.ghost, disabled: wallBusy || restoreN === 0, onClick: () => { void bulkApply('restore') } }, t('restore') + ` (${restoreN})`),
2596
- deleteArmedMulti
2597
- ? h(React.Fragment, null,
2598
- h(Button, { style: { ...S.ghost, color: V.danger, borderColor: V.danger }, disabled: wallBusy || deleteN === 0, onClick: () => { void bulkApply('delete') } }, t('confirmDelete') + ` (${deleteN})`),
2599
- h(Button, { style: S.ghost, disabled: wallBusy, onClick: () => setDeleteArmedMulti(false) }, t('cancelDelete')))
2600
- : h(Button, { style: { ...S.ghost, color: V.danger }, disabled: wallBusy || deleteN === 0, title: t('browseArchivedHint'), onClick: () => setDeleteArmedMulti(true) }, t('deleteForever') + ` (${deleteN})`),
2601
- h(Button, { style: S.ghost, onClick: exitMulti }, t('wallMultiExit')))
2602
- })() : null,
2603
- h('div', { className: 'dshm-nav-list is-row', role: 'tablist' }, [['active', 'browseActiveTab'], ['archived', 'browseArchivedTab'], ['review', 'poolReview'], ['inactive', 'poolInactive']].map(([value, key]) =>
2604
- h('button', { key: value, type: 'button', role: 'tab', 'aria-selected': pool === value, className: `dshm-nav-item${pool === value ? ' on' : ''}`, onClick: () => setPool(value) },
2605
- t(key) + ' (' + scoped.filter(item => poolOf(item) === value).length + ')'))),
2606
- h('div', { className: 'dshm-nav-list is-row', role: 'tablist' }, ['all', 'must_remember', 'long_term', 'short_term', 'anchor'].map(value =>
2607
- h('button', { key: value, type: 'button', role: 'tab', 'aria-selected': layer === value, className: `dshm-nav-item${layer === value ? ' on' : ''}`, onClick: () => setLayer(value) },
2608
- (value === 'all' ? t('allLibraries') : enumText(t, LAYER_LABELS, value)) + ' (' + rows.filter(item => layerMatch(item, value)).length + ')'))),
3145
+ multiToggle,
3146
+ multiActions,
3147
+ multiBlocked ? h('span', { style: S.muted, role: 'status' }, t('wallAllBlocked')) : null,
3148
+ data?.total > items.length ? h('span', { style: S.muted }, t('wallLimit') + ` (${items.length}/${data.total})`) : null) : null,
3149
+ wallTabGroup(poolItems, pool, setPool, { label: t('wallFilterStatus'), marginTop: 12 }),
3150
+ wallTabGroup(layerItems, layer, setLayer, { label: t('wallFilterLayer'), marginTop: 10 }),
2609
3151
  visible.length ? h('div', { className: 'dshm-wall-grid' }, visible.map(item =>
2610
3152
  h('button', {
2611
3153
  key: keyOf(item), type: 'button', className: 'dshm-wall-item' + (multi && selectedIds.has(keyOf(item)) ? ' is-selected' : ''),
@@ -2634,6 +3176,419 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2634
3176
  detailView)
2635
3177
  }
2636
3178
 
3179
+ // The stored enums stay English (they are data); only the display flips with
3180
+ // the host language.
3181
+ const LEXICON_KIND_LABELS = { slang: 'lexiconKindSlang', meme: 'lexiconKindMeme', trigger: 'lexiconKindTrigger', term: 'lexiconKindTerm' }
3182
+ // 公开生命周期两档:页签文案带计数占位符,数字来自 RPC 的 statusCounts。
3183
+ const LEXICON_TAB_LABELS = { active: 'lexiconTabActive', archived: 'lexiconTabArchived' }
3184
+ const EMPTY_LEXICON_DRAFT = { term: '', kind: 'term', meaning: '', usage: '', aliases: '', examples: '' }
3185
+
3186
+ // 词典页(§2.4):公开生命周期只有 active / archived,卡片流对齐记忆墙的
3187
+ // 交互约定(筛选/分页都在 RPC 侧完成,每页 50 条,客户端不缓存全量),但没有审核
3188
+ // ——模型写入直接生效,用户只做编辑 / 归档 / 恢复 / 删除。归档与恢复可逆、一击
3189
+ // 生效;删除是管理面独有的永久动作,两段确认。带 sourceMemoryId 的词条可跳回
3190
+ // 记忆墙对应条目(wallFocus 接力棒,见 SettingsPage)。
3191
+ function LexiconPanel({ call, libraries, t, onFocusMemory, sessionId }) {
3192
+ const [libraryId, setLibraryId] = React.useState('')
3193
+ const [data, setData] = React.useState(null)
3194
+ const [status, setStatus] = React.useState('active')
3195
+ const [kind, setKind] = React.useState('')
3196
+ const [queryInput, setQueryInput] = React.useState('')
3197
+ const [query, setQuery] = React.useState('')
3198
+ const [page, setPage] = React.useState(1)
3199
+ const [busy, setBusy] = React.useState(false)
3200
+ const [error, setError] = React.useState('')
3201
+ const [notice, setNotice] = React.useState('')
3202
+ const [editingId, setEditingId] = React.useState('')
3203
+ const [editDraft, setEditDraft] = React.useState(null)
3204
+ const [creating, setCreating] = React.useState(false)
3205
+ const [newDraft, setNewDraft] = React.useState(EMPTY_LEXICON_DRAFT)
3206
+ const [multi, setMulti] = React.useState(false)
3207
+ const [selectedIds, setSelectedIds] = React.useState(() => new Set())
3208
+ const [deleteArmed, setDeleteArmed] = React.useState('')
3209
+ const [armedBulk, setArmedBulk] = React.useState('')
3210
+
3211
+ // 宿主可能把面板与别的块并排渲染;读取状态时保持类型收窄,坏形状按空值处理,
3212
+ // 不在渲染期抛错(设置页任何一块抛错整页都会变空白)。
3213
+ const safeNewDraft = newDraft && typeof newDraft === 'object' ? newDraft : EMPTY_LEXICON_DRAFT
3214
+ const safeQueryInput = typeof queryInput === 'string' ? queryInput : ''
3215
+ const chosenIds = () => selectedIds instanceof Set ? selectedIds : new Set()
3216
+
3217
+ const activeLib = libraryId || libraries[0]?.id || ''
3218
+ const emptyView = { items: [], total: 0, page: 1, pages: 1, counts: {}, statusCounts: { active: 0, archived: 0 } }
3219
+ const load = React.useCallback(async () => {
3220
+ if (!activeLib) { setData(emptyView); return }
3221
+ setBusy(true)
3222
+ try {
3223
+ // 只取当前页、当前页签:状态页签、kind、搜索、分页全在 RPC 侧收窄,
3224
+ // 客户端不缓存任何全量词条列表。
3225
+ const res = await call('lexiconList', {
3226
+ libraryId: activeLib, sessionId: sessionId || undefined, page, pageSize: 50,
3227
+ status, kind: kind || undefined, q: query || undefined,
3228
+ })
3229
+ setData(res || emptyView)
3230
+ setError('')
3231
+ } catch (e) { setError(String(e?.message || e)) } finally { setBusy(false) }
3232
+ }, [call, activeLib, page, status, kind, query, sessionId])
3233
+ React.useEffect(() => { void load() }, [load])
3234
+ React.useEffect(() => {
3235
+ if (!error && !notice) return undefined
3236
+ const timer = setTimeout(() => { setError(''); setNotice('') }, 2600)
3237
+ return () => clearTimeout(timer)
3238
+ }, [error, notice])
3239
+ // 两段确认要能自行退场:armed 不会一直挂着;切页签/筛选/动作也会显式清掉。
3240
+ React.useEffect(() => {
3241
+ if (!deleteArmed && !armedBulk) return undefined
3242
+ const timer = setTimeout(() => { setDeleteArmed(''); setArmedBulk('') }, 2500)
3243
+ return () => clearTimeout(timer)
3244
+ }, [deleteArmed, armedBulk])
3245
+
3246
+ const resetSelection = () => { setSelectedIds(new Set()); setDeleteArmed(''); setArmedBulk('') }
3247
+ const resetTransient = () => { setEditingId(''); setEditDraft(null); setCreating(false); setMulti(false); resetSelection() }
3248
+ const changeLibrary = (value) => { setLibraryId(value); setPage(1); resetTransient() }
3249
+ const changeStatus = (value) => {
3250
+ if (value === status) return
3251
+ setStatus(value); setPage(1); setEditingId(''); setEditDraft(null); resetSelection()
3252
+ }
3253
+ const changeFilter = (setter) => (value) => { setter(value); setPage(1); setEditingId(''); setEditDraft(null); resetSelection() }
3254
+ const applySearch = () => { setQuery(safeQueryInput.trim()); setPage(1); setEditingId(''); setEditDraft(null); resetSelection() }
3255
+ const hitsTotal = (e) => {
3256
+ const counts = e.hitCounts && typeof e.hitCounts === 'object' ? e.hitCounts : {}
3257
+ return (Number(counts.candidate) || 0) + (Number(counts.injection) || 0) + (Number(counts.query) || 0)
3258
+ }
3259
+ const clipText = (value, max) => {
3260
+ const text = String(value ?? '')
3261
+ return text.length > max ? text.slice(0, max) + '…' : text
3262
+ }
3263
+ const draftOf = (e) => ({
3264
+ meaning: e.meaning || '',
3265
+ usage: e.usage || '',
3266
+ aliases: (Array.isArray(e.aliases) ? e.aliases : []).join(', '),
3267
+ examples: (Array.isArray(e.examples) ? e.examples : []).join('\n'),
3268
+ sensitivity: e.sensitivity || 'normal',
3269
+ strength: typeof e.strength === 'number' ? String(e.strength) : '',
3270
+ })
3271
+ const toggleEdit = (e) => {
3272
+ if (editingId === e.id) { setEditingId(''); setEditDraft(null); return }
3273
+ setCreating(false)
3274
+ setEditingId(e.id)
3275
+ setEditDraft(draftOf(e))
3276
+ }
3277
+ const splitList = (value, limit) => String(value || '').split(',').map((item) => item.trim()).filter(Boolean).slice(0, limit)
3278
+ const splitExamples = (value) => String(value || '').split('\n').map((item) => item.trim()).filter(Boolean).slice(0, 8)
3279
+ const strengthOf = (value) => {
3280
+ if (String(value ?? '').trim() === '') return undefined
3281
+ const n = Number(value)
3282
+ return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : undefined
3283
+ }
3284
+ const act = async (fn) => {
3285
+ setBusy(true)
3286
+ try { await fn() } catch (e) { setError(String(e?.message || e)) } finally { setBusy(false) }
3287
+ }
3288
+ const saveEdit = () => {
3289
+ const current = editingId
3290
+ const draft = editDraft
3291
+ if (!current || !draft) return
3292
+ const patch = {
3293
+ meaning: draft.meaning,
3294
+ usage: draft.usage,
3295
+ aliases: splitList(draft.aliases, 20),
3296
+ examples: splitExamples(draft.examples),
3297
+ sensitivity: draft.sensitivity,
3298
+ }
3299
+ const strength = strengthOf(draft.strength)
3300
+ if (strength !== undefined) patch.strength = strength
3301
+ return act(async () => {
3302
+ await call('lexiconUpdate', { libraryId: activeLib, sessionId: sessionId || undefined, termId: current, patch })
3303
+ setEditingId(''); setEditDraft(null); setDeleteArmed(''); setArmedBulk('')
3304
+ notifyMemoryWrite('lexicon', activeLib)
3305
+ setNotice(t('lexiconSaveDone'))
3306
+ setError('')
3307
+ await load()
3308
+ })
3309
+ }
3310
+ // 归档 / 恢复(§2.4):可逆的一击动作,不走审核、不产生 revision。
3311
+ const setLifecycle = (e, action) => {
3312
+ setDeleteArmed(''); setArmedBulk('')
3313
+ return act(async () => {
3314
+ await call(action === 'archive' ? 'lexiconArchive' : 'lexiconRestore', { libraryId: activeLib, termId: e.id })
3315
+ notifyMemoryWrite('lexicon', activeLib)
3316
+ setNotice(action === 'archive' ? t('lexiconArchiveDone') : t('lexiconRestoreDone'))
3317
+ setError('')
3318
+ await load()
3319
+ })
3320
+ }
3321
+ // 删除是永久动作,只走管理面:两段确认由卡片按钮负责,这里只发 RPC。
3322
+ const deleteTerm = (e) => {
3323
+ setDeleteArmed(''); setArmedBulk('')
3324
+ return act(async () => {
3325
+ await call('lexiconDelete', { libraryId: activeLib, termId: e.id })
3326
+ notifyMemoryWrite('lexicon', activeLib)
3327
+ setNotice(t('lexiconDeleted').split('{n}').join('1'))
3328
+ setError('')
3329
+ await load()
3330
+ })
3331
+ }
3332
+ const submitNew = () => {
3333
+ const draft = safeNewDraft
3334
+ if (!String(draft.term || '').trim() || !String(draft.meaning || '').trim()) return
3335
+ return act(async () => {
3336
+ await call('lexiconAdd', {
3337
+ libraryId: activeLib,
3338
+ sessionId: sessionId || undefined,
3339
+ term: draft.term, kind: draft.kind, meaning: draft.meaning, usage: draft.usage,
3340
+ aliases: splitList(draft.aliases, 20), examples: splitExamples(draft.examples),
3341
+ })
3342
+ setCreating(false)
3343
+ setNewDraft({ ...EMPTY_LEXICON_DRAFT, kind: draft.kind })
3344
+ notifyMemoryWrite('lexicon', activeLib)
3345
+ setNotice(t('lexiconAddDone'))
3346
+ setError('')
3347
+ // 新词条总是 active:留在归档页签会看不见刚写入的词,切回使用中。
3348
+ setStatus('active')
3349
+ setPage(1)
3350
+ await load()
3351
+ })
3352
+ }
3353
+ const exitMulti = () => { setMulti(false); resetSelection() }
3354
+ const bulkApply = (action) => {
3355
+ const wanted = action === 'archive' ? 'active' : action === 'restore' ? 'archived' : null
3356
+ const rows = (Array.isArray(data?.items) ? data.items : []).filter((e) => chosenIds().has(e.id) && (!wanted || (e.status || 'active') === wanted))
3357
+ if (!rows.length) return
3358
+ // 与记忆墙的 bulkApply 同一约定:部分成功不能说成全部成功——成功数进 notice,
3359
+ // 失败明细进 error(带 done/total 前缀),两列分开读。
3360
+ return act(async () => {
3361
+ let done = 0
3362
+ const failures = []
3363
+ for (const row of rows) {
3364
+ try {
3365
+ if (action === 'delete') await call('lexiconDelete', { libraryId: activeLib, termId: row.id })
3366
+ else await call(action === 'archive' ? 'lexiconArchive' : 'lexiconRestore', { libraryId: activeLib, termId: row.id })
3367
+ done += 1
3368
+ } catch (failure) { failures.push(`${row.term || row.id}: ${String(failure?.message || failure)}`) }
3369
+ }
3370
+ setArmedBulk(''); setDeleteArmed(''); setSelectedIds(new Set())
3371
+ if (done > 0) {
3372
+ notifyMemoryWrite('lexicon', activeLib)
3373
+ await load()
3374
+ setNotice(action === 'delete'
3375
+ ? t('lexiconDeleted').split('{n}').join(String(done))
3376
+ : action === 'archive'
3377
+ ? t('lexiconArchivedCount').split('{n}').join(String(done))
3378
+ : t('lexiconRestoredCount').split('{n}').join(String(done)))
3379
+ }
3380
+ if (failures.length > 0) setError(`${done}/${rows.length} · ` + failures.slice(0, 3).join(';'))
3381
+ })
3382
+ }
3383
+ const toggleSelect = (id) => setSelectedIds((prev) => {
3384
+ const next = prev instanceof Set ? new Set(prev) : new Set()
3385
+ if (next.has(id)) next.delete(id); else next.add(id)
3386
+ return next
3387
+ })
3388
+
3389
+ const items = Array.isArray(data?.items) ? data.items : []
3390
+ const counts = data?.counts && typeof data.counts === 'object' ? data.counts : {}
3391
+ const statusCounts = data?.statusCounts && typeof data.statusCounts === 'object' ? data.statusCounts : { active: 0, archived: 0 }
3392
+ // The old row reused the generic nav pills (2px gap, 30px high) and sat directly
3393
+ // above the count tags, so the two rows read as one cramped button cluster. Keep
3394
+ // the state switch as its own segmented control and give the counts their own
3395
+ // line; the kind counts are real filter chips, not decoration.
3396
+ const statusTabs = h('div', {
3397
+ role: 'tablist',
3398
+ 'aria-label': t('lexiconStatus'),
3399
+ style: {
3400
+ display: 'inline-flex', flexWrap: 'wrap', alignItems: 'stretch', gap: 4,
3401
+ padding: 4, margin: '2px 0 12px', maxWidth: '100%', boxSizing: 'border-box',
3402
+ border: `1px solid ${V.borderSoft}`, borderRadius: V.radius, background: V.surface,
3403
+ },
3404
+ }, Object.entries(LEXICON_TAB_LABELS).map(([value, key]) => {
3405
+ const selected = status === value
3406
+ return h('button', {
3407
+ key: value, type: 'button', role: 'tab', 'aria-selected': selected,
3408
+ className: 'dshm-nav-item',
3409
+ style: {
3410
+ height: 36, minHeight: 36, padding: '0 16px', border: 0, borderRadius: V.radiusSm,
3411
+ background: selected ? V.accentSoft : 'transparent',
3412
+ color: selected ? V.accent : V.textDim,
3413
+ fontWeight: selected ? 650 : 500, fontSize: 12.5, lineHeight: 1.2,
3414
+ cursor: 'pointer', whiteSpace: 'nowrap',
3415
+ transition: 'background .18s ease, color .18s ease, box-shadow .18s ease',
3416
+ },
3417
+ onClick: () => changeStatus(value),
3418
+ }, t(key).split('{n}').join(String(statusCounts[value] ?? 0)))
3419
+ }))
3420
+ const countsRow = h('div', { style: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, margin: '0 0 14px' } },
3421
+ h('span', { style: { ...S.muted, marginRight: 2 } }, t('lexiconTotal').split('{total}').join(String(data?.total ?? 0))),
3422
+ Object.entries(LEXICON_KIND_LABELS).map(([value, key]) => {
3423
+ const selected = kind === value
3424
+ const n = Number(counts[value]) || 0
3425
+ return h('button', {
3426
+ key: value, type: 'button',
3427
+ 'aria-pressed': selected,
3428
+ title: `${t(key)} · ${n}`,
3429
+ style: {
3430
+ display: 'inline-flex', alignItems: 'center', gap: 6,
3431
+ minHeight: 30, padding: '0 11px', borderRadius: 999,
3432
+ border: `1px solid ${selected ? V.accent : V.borderSoft}`,
3433
+ background: selected ? V.accentSoft : V.surfaceRaised,
3434
+ color: selected ? V.accent : V.textDim,
3435
+ cursor: 'pointer', fontSize: 11.5, lineHeight: 1, whiteSpace: 'nowrap',
3436
+ transition: 'background .18s ease, border-color .18s ease, color .18s ease',
3437
+ },
3438
+ onClick: () => changeFilter(setKind)(selected ? '' : value),
3439
+ }, t(key), h('b', { style: { fontWeight: 650, color: selected ? V.accent : V.text } }, String(n)))
3440
+ }))
3441
+ const filterRow = h('div', { className: 'dshm-graph-tools', style: { flexWrap: 'wrap', marginTop: 0 } },
3442
+ h('input', {
3443
+ type: 'search', style: S.input, value: safeQueryInput, placeholder: t('lexiconSearch'),
3444
+ 'aria-label': t('lexiconSearch'),
3445
+ onChange: (e) => setQueryInput(e.target.value),
3446
+ onKeyDown: (e) => { if (e.key === 'Enter') applySearch() },
3447
+ }),
3448
+ h(Button, { style: S.ghost, onClick: applySearch, disabled: busy }, t('logRefresh')),
3449
+ h(Button, { style: S.ghost, onClick: () => { setCreating((v) => !v); setEditingId(''); setEditDraft(null); setDeleteArmed(''); setArmedBulk('') } }, t('lexiconNew')),
3450
+ activeLib ? h(Button, { style: S.ghost, onClick: () => (multi ? exitMulti() : setMulti(true)), disabled: busy }, t('multiSelect')) : null)
3451
+ const newForm = creating
3452
+ ? h('div', { style: { ...S.item, display: 'block' } },
3453
+ h('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } },
3454
+ h('input', { style: { ...S.input, flex: '1 1 160px' }, value: safeNewDraft.term, placeholder: t('lexiconTerm'), 'aria-label': t('lexiconTerm'), onChange: (e) => setNewDraft({ ...safeNewDraft, term: e.target.value }) }),
3455
+ h('select', { style: S.input, value: safeNewDraft.kind, 'aria-label': t('lexiconKind'), onChange: (e) => setNewDraft({ ...safeNewDraft, kind: e.target.value }) },
3456
+ Object.entries(LEXICON_KIND_LABELS).map(([value, key]) => h('option', { key: value, value }, t(key))))),
3457
+ h('textarea', { style: { ...S.textarea, minHeight: 54, marginTop: 8 }, value: safeNewDraft.meaning, placeholder: t('lexiconMeaning'), 'aria-label': t('lexiconMeaning'), onChange: (e) => setNewDraft({ ...safeNewDraft, meaning: e.target.value }) }),
3458
+ h('input', { style: { ...S.input, marginTop: 8 }, value: safeNewDraft.usage, placeholder: t('lexiconUsage'), 'aria-label': t('lexiconUsage'), onChange: (e) => setNewDraft({ ...safeNewDraft, usage: e.target.value }) }),
3459
+ h('input', { style: { ...S.input, marginTop: 8 }, value: safeNewDraft.aliases, placeholder: t('lexiconAliases'), 'aria-label': t('lexiconAliases'), onChange: (e) => setNewDraft({ ...safeNewDraft, aliases: e.target.value }) }),
3460
+ h('textarea', { style: { ...S.textarea, minHeight: 54, marginTop: 8 }, value: safeNewDraft.examples, placeholder: t('lexiconExamples'), 'aria-label': t('lexiconExamples'), onChange: (e) => setNewDraft({ ...safeNewDraft, examples: e.target.value }) }),
3461
+ h('div', { style: { display: 'flex', gap: 8, marginTop: 8 } },
3462
+ h(Button, { primary: true, disabled: busy || !String(safeNewDraft.term || '').trim() || !String(safeNewDraft.meaning || '').trim(), onClick: () => void submitNew() }, t('lexiconCreate')),
3463
+ h(Button, { style: S.ghost, disabled: busy, onClick: () => setCreating(false) }, t('cancelDelete'))))
3464
+ : null
3465
+ const editForm = (e) => {
3466
+ const draft = editingId === e.id && editDraft && typeof editDraft === 'object' ? editDraft : null
3467
+ if (!draft) return null
3468
+ return h('div', { style: { marginTop: 8, borderTop: `1px solid ${V.borderSoft}`, paddingTop: 8 } },
3469
+ h('textarea', { style: { ...S.textarea, minHeight: 54 }, value: draft.meaning, 'aria-label': t('lexiconMeaning'), onChange: (event) => setEditDraft({ ...draft, meaning: event.target.value }) }),
3470
+ h('input', { style: { ...S.input, marginTop: 8 }, value: draft.usage, 'aria-label': t('lexiconUsage'), onChange: (event) => setEditDraft({ ...draft, usage: event.target.value }) }),
3471
+ h('input', { style: { ...S.input, marginTop: 8 }, value: draft.aliases, 'aria-label': t('lexiconAliases'), onChange: (event) => setEditDraft({ ...draft, aliases: event.target.value }) }),
3472
+ h('textarea', { style: { ...S.textarea, minHeight: 54, marginTop: 8 }, value: draft.examples, 'aria-label': t('lexiconExamples'), onChange: (event) => setEditDraft({ ...draft, examples: event.target.value }) }),
3473
+ h('div', { style: { display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap', alignItems: 'center' } },
3474
+ h('select', { style: S.input, value: draft.sensitivity, 'aria-label': t('lexiconSensitivity'), onChange: (event) => setEditDraft({ ...draft, sensitivity: event.target.value }) },
3475
+ h('option', { value: 'normal' }, t('lexiconNormal')),
3476
+ h('option', { value: 'sensitive' }, t('lexiconSensitive'))),
3477
+ h('input', { style: { ...S.input, maxWidth: 90 }, type: 'number', min: 0, max: 1, step: 0.05, value: draft.strength, 'aria-label': t('lexiconStrength'), onChange: (event) => setEditDraft({ ...draft, strength: event.target.value }) }),
3478
+ h(Button, { primary: true, disabled: busy, onClick: () => void saveEdit() }, t('save')),
3479
+ h(Button, { style: S.ghost, disabled: busy, onClick: () => { setEditingId(''); setEditDraft(null) } }, t('cancelDelete'))))
3480
+ }
3481
+ // 卡片流(§2.4):term 是标题,meaning 是正文(≤120 字),usage/aliases 是次要行;
3482
+ // 命中三计数与 strength 同记忆墙一样不抢标题的位置。
3483
+ const renderCard = (e) => {
3484
+ const editing = editingId === e.id
3485
+ const aliases = Array.isArray(e.aliases) ? e.aliases : []
3486
+ const armed = deleteArmed === e.id
3487
+ const selection = chosenIds()
3488
+ const hits = e.hitCounts && typeof e.hitCounts === 'object' ? e.hitCounts : {}
3489
+ const hitsTitle = t('lexiconHitsDetail')
3490
+ .split('{candidate}').join(String(Number(hits.candidate) || 0))
3491
+ .split('{injection}').join(String(Number(hits.injection) || 0))
3492
+ .split('{query}').join(String(Number(hits.query) || 0))
3493
+ return h('div', {
3494
+ key: e.id,
3495
+ style: { ...S.item, display: 'flex', flexDirection: 'column', gap: 7, minHeight: 150, ...(multi && selection.has(e.id) ? { outline: '2px solid ' + V.accent, outlineOffset: -2 } : {}) },
3496
+ },
3497
+ h('div', { style: { display: 'flex', gap: 7, alignItems: 'center', flexWrap: 'wrap' } },
3498
+ multi ? h('input', { type: 'checkbox', checked: selection.has(e.id), 'aria-label': e.term || e.id, onChange: () => toggleSelect(e.id), style: { margin: 0, accentColor: V.accent, flexShrink: 0 } }) : null,
3499
+ h('strong', { style: { fontSize: 15, fontWeight: 650, lineHeight: 1.3, overflowWrap: 'anywhere', flex: '1 1 120px' } }, e.term || e.id),
3500
+ h(Tag, null, enumText(t, LEXICON_KIND_LABELS, e.kind)),
3501
+ e.sensitivity === 'sensitive' ? h(Tag, { tone: 'warn' }, t('lexiconSensitive')) : null),
3502
+ h('p', { style: { margin: 0, fontSize: 12.5, lineHeight: 1.55, overflowWrap: 'anywhere', display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden' } },
3503
+ clipText(e.meaning, 120) || '—'),
3504
+ e.usage ? h('div', { style: { ...S.muted, overflowWrap: 'anywhere' } }, `${t('lexiconCardUsage')} · ${clipText(e.usage, 80)}`) : null,
3505
+ aliases.length ? h('div', { style: { ...S.muted, overflowWrap: 'anywhere' } }, `${t('lexiconCardAliases')} · ${aliases.slice(0, 4).join(', ')}`) : null,
3506
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginTop: 'auto' } },
3507
+ h('span', { style: S.muted, title: hitsTitle }, `⌁ ${hitsTotal(e)}`),
3508
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 6, flex: '1 1 90px', minWidth: 70 } },
3509
+ h(Bar, { value: e.strength ?? 0, title: `${t('lexiconStrength')} ${Math.round((Number(e.strength) || 0) * 100)}%` })),
3510
+ e.sourceMemoryId ? h(Button, {
3511
+ style: { ...S.ghost, padding: '1px 7px', flexShrink: 0 },
3512
+ title: t('lexiconSourceMemory'),
3513
+ onClick: () => onFocusMemory?.({ id: e.sourceMemoryId, libraryId: activeLib, title: e.term || e.id, at: Date.now() }),
3514
+ }, t('lexiconSourceMemory')) : null),
3515
+ h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' } },
3516
+ h(Button, { style: { ...S.ghost, padding: '2px 9px' }, disabled: busy, onClick: () => { setDeleteArmed(''); setArmedBulk(''); toggleEdit(e) } }, editing ? t('cancelDelete') : t('lexiconEdit')),
3517
+ // 归档 / 恢复是一击可逆动作;删除才要两段确认。
3518
+ h(Button, { style: { ...S.ghost, padding: '2px 9px' }, disabled: busy, onClick: () => void setLifecycle(e, status === 'archived' ? 'restore' : 'archive') },
3519
+ status === 'archived' ? t('lexiconRestore') : t('lexiconArchive')),
3520
+ armed
3521
+ ? h(React.Fragment, null,
3522
+ h(Button, { style: { ...S.ghost, color: V.danger, borderColor: V.danger, padding: '2px 9px' }, disabled: busy, onClick: () => void deleteTerm(e) }, t('confirmDelete')),
3523
+ h(Button, { style: { ...S.ghost, padding: '2px 9px' }, disabled: busy, onClick: () => setDeleteArmed('') }, t('cancelDelete')))
3524
+ : h(Button, { style: { ...S.ghost, color: V.danger, padding: '2px 9px' }, disabled: busy, title: t('lexiconDeleteHint'), onClick: () => { setArmedBulk(''); setDeleteArmed(e.id) } }, t('lexiconDelete'))),
3525
+ editing ? editForm(e) : null)
3526
+ }
3527
+ const selectedRows = items.filter((e) => chosenIds().has(e.id))
3528
+ const multiBar = multi
3529
+ ? (() => {
3530
+ const action = status === 'archived' ? 'restore' : 'archive'
3531
+ const actionLabel = action === 'archive' ? t('lexiconArchive') : t('lexiconRestore')
3532
+ const actionCount = selectedRows.filter((e) => (e.status || 'active') === (action === 'archive' ? 'active' : 'archived')).length
3533
+ const bulkButton = (value, label, count, danger) => {
3534
+ const armed = armedBulk === value
3535
+ return h(Button, {
3536
+ style: { ...S.ghost, ...(danger ? { color: V.danger } : {}), ...(armed ? { borderColor: danger ? V.danger : V.accent } : {}) },
3537
+ disabled: busy || count === 0,
3538
+ title: value === 'delete' ? t('lexiconDeleteHint') : undefined,
3539
+ onClick: () => {
3540
+ setDeleteArmed('')
3541
+ if (armedBulk !== value) { setArmedBulk(value); return }
3542
+ setArmedBulk('')
3543
+ void bulkApply(value)
3544
+ },
3545
+ }, (armed ? (value === 'delete' ? t('confirmDelete') : t('confirmAction')) : label) + ` (${count})`)
3546
+ }
3547
+ return h('div', { className: 'dshm-graph-tools', role: 'toolbar', 'aria-label': t('multiSelect') },
3548
+ h('span', { style: S.muted }, t('wallSelected').replace('{n}', String(selectedRows.length))),
3549
+ h(Button, { style: S.ghost, disabled: selectedRows.length === items.length, onClick: () => setSelectedIds(new Set(items.map((e) => e.id))) }, t('wallSelectAll')),
3550
+ h(Button, { style: S.ghost, disabled: selectedRows.length === 0, onClick: () => resetSelection() }, t('wallClearSelection')),
3551
+ // 批量归档/恢复/删除都是两段确认:第一次点只是 arm 当前动作。
3552
+ bulkButton(action, actionLabel, actionCount, false),
3553
+ bulkButton('delete', t('lexiconDelete'), selectedRows.length, true),
3554
+ armedBulk ? h(Button, { style: S.ghost, disabled: busy, onClick: () => setArmedBulk('') }, t('cancelDelete')) : null,
3555
+ h(Button, { style: S.ghost, onClick: exitMulti }, t('wallMultiExit')))
3556
+ })()
3557
+ : null
3558
+ const pager = h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 10 } },
3559
+ h(Button, { style: S.ghost, disabled: busy || (data?.page || 1) <= 1, onClick: () => setPage(Math.max(1, (data?.page || 1) - 1)) }, t('lexiconPrev')),
3560
+ h('span', { style: S.muted }, t('lexiconPageOf').split('{page}').join(String(data?.page || 1)).split('{pages}').join(String(data?.pages || 1))),
3561
+ h(Button, { style: S.ghost, disabled: busy || (data?.page || 1) >= (data?.pages || 1), onClick: () => setPage((data?.page || 1) + 1) }, t('lexiconNext')),
3562
+ busy ? h('span', { style: S.muted }, t('loading')) : null)
3563
+
3564
+ return h(React.Fragment, null,
3565
+ h(Section, {
3566
+ title: t('lexiconScope'),
3567
+ desc: t('descLexicon'),
3568
+ icon: GlyphStack,
3569
+ right: h(Button, { style: S.ghost, onClick: () => { setPage(1); resetTransient(); return load() }, disabled: busy }, t('refresh')),
3570
+ },
3571
+ h('div', { style: S.row },
3572
+ h('span', { style: S.label }, t('currentLibrary')),
3573
+ h('select', {
3574
+ style: { ...S.input, flex: 1 }, value: libraryId,
3575
+ title: libraries.find((lib) => lib.id === activeLib)?.scopeKey || t('currentLibrary'),
3576
+ onChange: (e) => changeLibrary(e.target.value),
3577
+ },
3578
+ libraries.length === 0 ? h('option', { value: '' }, t('noLibraries')) : null,
3579
+ libraries.map((lib) => h('option', { key: lib.id, value: lib.id }, libraryOptionLabel(lib, libraries))))),
3580
+ libraries.length === 0
3581
+ ? h(Empty, null, t('lexiconNoLibrary'))
3582
+ : h(React.Fragment, null, statusTabs, countsRow, filterRow, multiBar, newForm,
3583
+ error && typeof error === 'string' ? h('div', { className: 'dshm-error', role: 'alert' }, error) : null,
3584
+ notice && typeof notice === 'string' ? h('div', { style: { ...S.muted, marginTop: 6, color: V.accent } }, notice) : null,
3585
+ items.length === 0
3586
+ ? h(Empty, null, query || kind ? t('lexiconNoMatch')
3587
+ : status === 'archived' ? t('lexiconArchivedEmpty') : t('lexiconEmpty'))
3588
+ : h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(min(280px, 100%), 1fr))', gap: 10, marginTop: 10 } }, items.map(renderCard)),
3589
+ pager)))
3590
+ }
3591
+
2637
3592
  function SettingsPage({ ctx, t, embedded, standalone = false }) {
2638
3593
  const PAGE = {
2639
3594
  overview: 0,
@@ -2646,8 +3601,14 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2646
3601
  prompts: 7,
2647
3602
  stats: 8,
2648
3603
  memories: 9,
3604
+ // logs:10 之前缺失:SETTINGS_PAGES 里有 'logs' 但 PAGE 没有对应索引,
3605
+ // 任何"跳到日志页"的入口都会错位到词典。补上,help 才能落在正确的 12。
3606
+ logs: 10,
3607
+ lexicon: 11,
3608
+ help: 12,
2649
3609
  }
2650
3610
  const call = useRpc(ctx)
3611
+ const sessionId = useCurrentSession(ctx)
2651
3612
  const [state, setState] = React.useState(null)
2652
3613
  const [prompts, setPrompts] = React.useState([])
2653
3614
  const [promptsLoaded, setPromptsLoaded] = React.useState(false)
@@ -2897,9 +3858,9 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2897
3858
  const loadReviews = React.useCallback(async () => {
2898
3859
  try {
2899
3860
  const [globalQueue, privateQueue] = await Promise.all([
2900
- call('listReviews', {}),
3861
+ call('listReviews', sessionId ? { sessionId } : {}),
2901
3862
  browseLibId
2902
- ? call('listReviews', { libraryId: browseLibId }).catch(() => ({ items: [] }))
3863
+ ? call('listReviews', { libraryId: browseLibId, sessionId: sessionId || undefined }).catch(() => ({ items: [] }))
2903
3864
  : Promise.resolve({ items: [] }),
2904
3865
  ])
2905
3866
  setReviews([...(privateQueue?.items || []), ...(globalQueue?.items || [])])
@@ -3006,13 +3967,13 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3006
3967
  React.useEffect(() => {
3007
3968
  if (page !== PAGE.browser || !browseLibId) return undefined
3008
3969
  let alive = true
3009
- void call('browseLibrary', { libraryId: browseLibId }).then(result => {
3970
+ void call('browseLibrary', { libraryId: browseLibId, sessionId: sessionId || undefined }).then(result => {
3010
3971
  if (!alive) return
3011
3972
  setBrowseFiles(result.files || [])
3012
3973
  setBrowseSource(result.source || '')
3013
3974
  }).catch(error => { if (alive) setError(String(error.message || error)) })
3014
3975
  return () => { alive = false }
3015
- }, [call, page, browseLibId, state?.revision, state?.stamp])
3976
+ }, [call, page, browseLibId, sessionId, state?.revision, state?.stamp])
3016
3977
  // Statistics are per library and read the whole index for the scatter, so they
3017
3978
  // load only when that page is open. Every settings refresh (a heartbeat heard a
3018
3979
  // write) re-reads them, which is what keeps the page honest.
@@ -3285,6 +4246,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3285
4246
  return
3286
4247
  }
3287
4248
  notifyMemoryWrite('memory', browseLibId)
4249
+ setNotice(t('memorySaved'))
3288
4250
  setNewMem({ title: '', content: '', layer: 'long_term', salientDate: '' })
3289
4251
  await openBrowser(browseLibId)
3290
4252
  } catch (e) { setError(String(e?.message || e)) } finally { setBrowseBusy(false) }
@@ -3296,6 +4258,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3296
4258
  try {
3297
4259
  await call('updateMemory', { libraryId: browseLibId, id, patch })
3298
4260
  notifyMemoryWrite('memory', browseLibId)
4261
+ setNotice(t('memorySaved'))
3299
4262
  await openBrowser(browseLibId)
3300
4263
  } catch (e) { setError(String(e?.message || e)) } finally { setBrowseBusy(false) }
3301
4264
  }
@@ -3304,6 +4267,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3304
4267
  try {
3305
4268
  await call('archiveMemory', { libraryId: browseLibId, id })
3306
4269
  notifyMemoryWrite('memory', browseLibId)
4270
+ setNotice(t('memoryArchived'))
3307
4271
  await openBrowser(browseLibId)
3308
4272
  } catch (e) { setError(String(e?.message || e)) } finally { setBrowseBusy(false) }
3309
4273
  }
@@ -3312,6 +4276,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3312
4276
  try {
3313
4277
  await call('updateMemory', { libraryId: browseLibId, id, patch: { status: 'active' } })
3314
4278
  notifyMemoryWrite('memory', browseLibId)
4279
+ setNotice(t('memoryRestored'))
3315
4280
  await openBrowser(browseLibId)
3316
4281
  } catch (e) { setError(String(e?.message || e)) } finally { setBrowseBusy(false) }
3317
4282
  }
@@ -3321,6 +4286,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3321
4286
  try {
3322
4287
  await call('deleteMemory', { libraryId: browseLibId, id })
3323
4288
  notifyMemoryWrite('memory', browseLibId)
4289
+ setNotice(t('memoryDeleted'))
3324
4290
  setDeleteArmed('')
3325
4291
  await openBrowser(browseLibId)
3326
4292
  } catch (e) { setError(String(e?.message || e)) } finally { setBrowseBusy(false) }
@@ -3333,6 +4299,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3333
4299
  action,
3334
4300
  resolution: reviewDrafts[item.id]?.resolution || 'replace',
3335
4301
  reason: reviewDrafts[item.id]?.reason || '',
4302
+ sessionId: sessionId || undefined,
3336
4303
  ...(item.libraryId ? { libraryId: item.libraryId } : {}),
3337
4304
  })
3338
4305
  notifyMemoryWrite('review', item.libraryId || null)
@@ -3342,32 +4309,63 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3342
4309
  if (item.libraryId && browseLibId === item.libraryId) await openBrowser(item.libraryId)
3343
4310
  } catch (e) { setError(String(e?.message || e)) } finally { setBusy(false) }
3344
4311
  }
3345
- const exportLibraryNow = async (includeAttachments = false) => {
3346
- if (!browseLibId) return
4312
+ // 撤销集合合并(spec §4.11 undo-absorb / §6.7 UI):全局元数据动作,不带
4313
+ // libraryId;两段确认由 actionLabel/confirmAction 承担。成功后刷新全局列表。
4314
+ const undoAbsorb = async (id) => {
4315
+ if (!confirmAction('undo-absorb:' + id)) return
4316
+ setBusy(true)
4317
+ try {
4318
+ await call('resolveReview', { id, action: 'undo-absorb', sessionId: sessionId || undefined })
4319
+ setNotice(t('absorbUndone'))
4320
+ const res = await call('listGlobal', {})
4321
+ setGlobalFiles((res.files || []))
4322
+ setGlobalLoaded(true)
4323
+ await loadReviews()
4324
+ } catch (e) { setError(String(e?.message || e)) } finally { setBusy(false) }
4325
+ }
4326
+ const removeGlobalLink = async (link) => {
4327
+ const linkId = String(link?.id || '')
4328
+ if (!linkId || !confirmAction('unlink:' + linkId)) return
4329
+ setBusy(true)
4330
+ try {
4331
+ await call('resolveReview', { linkId, action: 'unlink', sessionId: sessionId || undefined })
4332
+ setNotice(t('linkRemoved'))
4333
+ const res = await call('listGlobal', {})
4334
+ setGlobalFiles(res.files || [])
4335
+ setGlobalLoaded(true)
4336
+ await loadReviews()
4337
+ } catch (e) { setError(String(e?.message || e)) } finally { setBusy(false) }
4338
+ }
4339
+ // 导出契约(spec §6.2):显式对象参数——libraryId 缺省取当前浏览库,但调用点可以
4340
+ // 传入别的 id(契约保证 payload、回退文件名、成功提示都用**传入**的 id)。
4341
+ const exportLibraryNow = async ({ libraryId = browseLibId, includeAttachments = false } = {}) => {
4342
+ if (!libraryId) return
3347
4343
  setBrowseBusy(true)
3348
4344
  try {
3349
- const data = await call('exportLibrary', { libraryId: browseLibId, includeAttachments, delivery: includeAttachments ? 'download' : undefined })
4345
+ const data = await call('exportLibrary', { libraryId, sessionId: sessionId || undefined, includeAttachments, delivery: includeAttachments ? 'download' : undefined })
3350
4346
  if (data.downloadUrl) {
3351
4347
  const link = document.createElement('a')
3352
4348
  link.href = data.downloadUrl; link.download = data.filename; link.click()
3353
- return
4349
+ } else {
4350
+ const blob = data.base64
4351
+ ? new Blob([Uint8Array.from(atob(data.base64), char => char.charCodeAt(0))], { type: 'application/zip' })
4352
+ : new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
4353
+ const url = URL.createObjectURL(blob)
4354
+ const a = document.createElement('a')
4355
+ a.href = url
4356
+ a.download = data.filename || `dsh-memu-memory-${libraryId}.json`
4357
+ a.click()
4358
+ URL.revokeObjectURL(url)
3354
4359
  }
3355
- const blob = data.base64
3356
- ? new Blob([Uint8Array.from(atob(data.base64), char => char.charCodeAt(0))], { type: 'application/zip' })
3357
- : new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
3358
- const url = URL.createObjectURL(blob)
3359
- const a = document.createElement('a')
3360
- a.href = url
3361
- a.download = data.filename || `dsh-memu-memory-${browseLibId}.json`
3362
- a.click()
3363
- URL.revokeObjectURL(url)
4360
+ setNotice(t('exportStarted').replace('{file}', data.filename || ''))
3364
4361
  } catch (e) { setError(String(e?.message || e)) } finally { setBrowseBusy(false) }
3365
4362
  }
3366
4363
 
3367
4364
  // Import reads the file in the browser and hands the text to the server: the
3368
4365
  // host already owns the state directory, so no path guessing is needed.
3369
- const importLibraryNow = () => {
3370
- if (!browseLibId) return
4366
+ // 同一契约:payload 与 ZIP 上传 URL 都用**传入**的 libraryId,成功后回到该库。
4367
+ const importLibraryNow = ({ libraryId = browseLibId } = {}) => {
4368
+ if (!libraryId) return
3371
4369
  const input = document.createElement('input')
3372
4370
  input.type = 'file'
3373
4371
  input.accept = 'application/json,.json,application/zip,.zip'
@@ -3378,10 +4376,11 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3378
4376
  setNotice('')
3379
4377
  setError('')
3380
4378
  try {
3381
- const payload = { libraryId: browseLibId }
4379
+ const payload = { libraryId, sessionId: sessionId || undefined }
3382
4380
  let res
3383
4381
  if (file.name.toLowerCase().endsWith('.zip')) {
3384
- const response = await fetch('/dsh-memu-memory/import/' + encodeURIComponent(browseLibId) + '.zip', {
4382
+ const sessionQuery = sessionId ? '?sessionId=' + encodeURIComponent(sessionId) : ''
4383
+ const response = await fetch('/dsh-memu-memory/import/' + encodeURIComponent(libraryId) + '.zip' + sessionQuery, {
3385
4384
  method: 'POST', credentials: 'same-origin', body: file, headers: { 'content-type': 'application/zip' },
3386
4385
  })
3387
4386
  const result = await response.json()
@@ -3398,8 +4397,8 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3398
4397
  const adjusted = c.downgraded || 0
3399
4398
  setNotice(`${t('importDone')}: +${res.imported || 0} / −${skipped}${adjusted ? ` · ${t('importAdjusted')}: ${adjusted}` : ''}`)
3400
4399
  if (res.engineWarning) setError(`${t('importEngineWarning')}: ${res.engineWarning}`)
3401
- notifyMemoryWrite('import', browseLibId)
3402
- await openBrowser(browseLibId)
4400
+ notifyMemoryWrite('import', libraryId)
4401
+ await openBrowser(libraryId)
3403
4402
  await refresh()
3404
4403
  } catch (e) { setError(String(e?.message || e)) } finally { setBrowseBusy(false) }
3405
4404
  }
@@ -4033,12 +5032,30 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4033
5032
  ? h(Empty, null, t('noArchived'))
4034
5033
  : h('div', { style: S.list }, (state.archivedLibraries || []).map(renderArchivedItem))))
4035
5034
 
4036
- const renderGlobalItem = (f) => h('div', { key: f.id, style: S.item },
5035
+ const renderGlobalItem = (f) => {
5036
+ // 集合记忆(spec §6.7 UI):absorbedFrom → 「集合记忆 · N 条来源」Tag +
5037
+ // 「撤销合并」两段确认按钮(resolveReview action:'undo-absorb')。links
5038
+ // 只读展示 + 「移除」(resolveReview action:'unlink'),不提供手动全局关联。
5039
+ const absorbed = Array.isArray(f.absorbedFrom) ? f.absorbedFrom : []
5040
+ const links = Array.isArray(f.links) ? f.links : []
5041
+ const titleOf = (id) => (globalFiles.find((item) => item.id === id)?.title) || id
5042
+ return h('div', { key: f.id, style: S.item },
4037
5043
  h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 8 } },
4038
5044
  h('strong', null, f.title || f.id),
4039
5045
  h('div', { style: { display: 'flex', gap: 6, flexShrink: 0 } },
4040
5046
  h(Tag, null, domainText(t, f.domain)),
4041
5047
  h(Tag, null, `conf ${f.confidence ?? '?'}`),
5048
+ links.length ? h(Tag, { tone: 'info' }, t('linkCountTag').replace('{n}', String(links.length))) : null,
5049
+ absorbed.length ? h(Tag, { tone: 'info' }, t('absorbSourcesTag').replace('{n}', String(absorbed.length))) : null,
5050
+ // 撤销合并:两段确认(点一次变为「确认此操作?」,再点才发请求)。
5051
+ absorbed.length
5052
+ ? h(Button, {
5053
+ style: S.ghost,
5054
+ title: t('absorbUndo'),
5055
+ disabled: busy,
5056
+ onClick: () => { void undoAbsorb(f.id) },
5057
+ }, actionLabel('undo-absorb:' + f.id, t('absorbUndo')))
5058
+ : null,
4042
5059
  // 查看此记忆:切到 MemU 记忆页的全局块并直接打开对应条目的抽屉。
4043
5060
  h(Button, {
4044
5061
  style: S.ghost,
@@ -4046,8 +5063,20 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4046
5063
  onClick: () => { setWallFocus({ id: f.id, libraryId: null, title: f.title || f.id, at: Date.now() }); setPage(PAGE.memories) },
4047
5064
  }, t('viewMemory')))),
4048
5065
  h('div', { style: { ...S.muted, marginTop: 4, whiteSpace: 'pre-wrap' } },
4049
- String(f.content || '').slice(0, 400))
5066
+ String(f.content || '').slice(0, 400)),
5067
+ links.length
5068
+ ? h('div', { style: { ...S.muted, marginTop: 4 } },
5069
+ links.slice(0, 8).map((link) => h('div', { key: link.id || (link.to + ':' + link.kind), style: { display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' } },
5070
+ h('span', null, `${link.kind || 'related'} · ${titleOf(link.to)}`),
5071
+ h(Button, {
5072
+ style: { ...S.ghost, padding: '1px 6px' },
5073
+ disabled: busy,
5074
+ onClick: () => { void removeGlobalLink(link) },
5075
+ }, actionLabel('unlink:' + (link.id || ''), t('relationRemove'))))),
5076
+ links.length > 8 ? h('div', null, `… +${links.length - 8}`) : null)
5077
+ : null
4050
5078
  )
5079
+ }
4051
5080
 
4052
5081
  const renderSessionChoice = (s) => h('label', {
4053
5082
  key: s.id,
@@ -4063,28 +5092,48 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4063
5092
  h('div', { style: S.muted }, `${s.cwd || '—'} · ${s.agentPreset || 'general'}`))
4064
5093
  )
4065
5094
 
4066
- const renderReviewItem = (r) => h('div', { key: r.id, style: S.item },
5095
+ const renderReviewItem = (r) => {
5096
+ // 集合吸收提案(spec §6.7 UI):pendingAbsorb → 「集合吸收 · N 条」Tag +
5097
+ // 来源标题列表(≤6,从 globalFiles 的 id→标题映射);accept 恒为 replace
5098
+ // (合并稿不能与自己的来源并存,服务端对 coexist 直接抛错),所以隐藏
5099
+ // 复核方式选择器;保留 accept/reject,不做批量接受。
5100
+ const absorbSources = r.pendingAbsorb && Array.isArray(r.pendingAbsorb.sourceIds) ? r.pendingAbsorb.sourceIds : []
5101
+ const titleOf = (id) => {
5102
+ const file = (globalFiles || []).find((item) => item.id === id)
5103
+ return (file && (file.title || file.id)) || id
5104
+ }
5105
+ return h('div', { key: r.id, style: S.item },
4067
5106
  h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center' } },
4068
5107
  h('div', { style: { minWidth: 0 } },
4069
5108
  h('strong', null, r.title || r.id),
4070
5109
  h('div', { style: S.row },
4071
5110
  h(Tag, { tone: r.libraryId ? 'info' : undefined }, r.libraryId ? t('reviewScopePrivate') : (r.domain || 'unknown')),
5111
+ r.pendingAbsorb ? h(Tag, { tone: 'info' }, t('absorbTag').replace('{n}', String(absorbSources.length + 1))) : null,
4072
5112
  r.reviewReason ? h(Tag, null, `${t('reviewReason')}: ${r.reviewReason}`) : null,
4073
5113
  typeof r.confidence === 'number' ? h(Tag, null, `conf ${r.confidence}`) : null)),
4074
5114
  h('div', { style: { display: 'flex', gap: 6, flexShrink: 0 } },
4075
5115
  h(Button, { onClick: () => resolveReview(r, 'accept'), disabled: busy, primary: true }, t('accept')),
4076
5116
  h(Button, { onClick: () => resolveReview(r, 'reject'), disabled: busy, style: S.ghost }, t('reject')))),
5117
+ absorbSources.length
5118
+ ? h('div', { style: { ...S.muted, marginTop: 4, lineHeight: 1.6 } },
5119
+ absorbSources.slice(0, 6).map((id) => h('div', { key: id }, `· ${titleOf(id)}`)),
5120
+ absorbSources.length > 6 ? h('div', null, `… +${absorbSources.length - 6}`) : null)
5121
+ : null,
4077
5122
  h('div', { style: { ...S.muted, marginTop: 6, whiteSpace: 'pre-wrap' } }, String(r.content || '').slice(0, 800)),
4078
- h('div', { className: 'dshm-graph-tools' },
4079
- h('select', { title: t('reviewResolution'), value: reviewDrafts[r.id]?.resolution || 'replace',
4080
- onChange: event => setReviewDrafts(previous => ({ ...previous, [r.id]: { ...previous[r.id], resolution: event.target.value } })) },
4081
- h('option', { value: 'replace' }, t('reviewReplace')), h('option', { value: 'coexist' }, t('reviewCoexist'))),
4082
- reviewDrafts[r.id]?.resolution === 'coexist' ? h('input', { placeholder: t('reviewConditions'), value: reviewDrafts[r.id]?.reason || '',
4083
- onChange: event => setReviewDrafts(previous => ({ ...previous, [r.id]: { ...previous[r.id], reason: event.target.value } })) }) : null),
5123
+ // 集合吸收的 accept 固定 replace:不渲染 coexist 选择器。
5124
+ !r.pendingAbsorb
5125
+ ? h('div', { className: 'dshm-graph-tools' },
5126
+ h('select', { title: t('reviewResolution'), value: reviewDrafts[r.id]?.resolution || 'replace',
5127
+ onChange: event => setReviewDrafts(previous => ({ ...previous, [r.id]: { ...previous[r.id], resolution: event.target.value } })) },
5128
+ h('option', { value: 'replace' }, t('reviewReplace')), h('option', { value: 'coexist' }, t('reviewCoexist'))),
5129
+ reviewDrafts[r.id]?.resolution === 'coexist' ? h('input', { placeholder: t('reviewConditions'), value: reviewDrafts[r.id]?.reason || '',
5130
+ onChange: event => setReviewDrafts(previous => ({ ...previous, [r.id]: { ...previous[r.id], reason: event.target.value } })) }) : null)
5131
+ : null,
4084
5132
  (r.evidence || []).length > 0
4085
5133
  ? h('div', { style: { ...S.muted, marginTop: 6, lineHeight: 1.6 } },
4086
5134
  r.evidence.map((e, i) => h('div', { key: `${r.id}-ev-${i}` }, `· ${e.sessionId}:${String(e.quote || '').slice(0, 160)}`)))
4087
5135
  : null)
5136
+ }
4088
5137
 
4089
5138
  const globalBlock = h(React.Fragment, null,
4090
5139
  h(Section, {
@@ -4114,7 +5163,17 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4114
5163
  job ? h('div', { style: { ...S.muted, marginTop: 8 } },
4115
5164
  `${job.status}${job.step ? ` · ${job.step}` : ''}`,
4116
5165
  job.error ? h('div', { style: { color: V.danger } }, job.error) : null) : null
4117
- )
5166
+ ),
5167
+ // 审核模式(spec §6.1 包 A):这是设置页的 globalBlock,不是侧栏那个只读块。
5168
+ h(Section, { title: t('curatorReview'), desc: t('descCuratorReview') },
5169
+ h(Field, { label: t('curatorReview') },
5170
+ h('select', {
5171
+ style: S.input,
5172
+ value: settings.curatorReviewMode || 'auto',
5173
+ onChange: (e) => setSetting('curatorReviewMode', e.target.value),
5174
+ },
5175
+ h('option', { value: 'auto' }, t('curatorReviewAuto')),
5176
+ h('option', { value: 'review-all' }, t('curatorReviewAll')))))
4118
5177
  )
4119
5178
 
4120
5179
  const renderPromptItem = (p) => h('div', { key: p.key, style: S.item },
@@ -4359,15 +5418,16 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4359
5418
  right: h('div', { style: { display: 'flex', gap: 8, flexShrink: 0 } },
4360
5419
  h(Button, { onClick: () => importLibraryNow(), disabled: !browseLibId || browseBusy, style: S.ghost, title: t('importHint') }, t('importJson')),
4361
5420
  h(Button, { onClick: () => exportLibraryNow(), disabled: !browseLibId, style: S.ghost }, t('export')),
4362
- h(Button, { onClick: () => exportLibraryNow(true), disabled: !browseLibId, title: t('exportImages') }, t('exportImages')),
5421
+ h(Button, { onClick: () => exportLibraryNow({ includeAttachments: true }), disabled: !browseLibId, title: t('exportZip') }, t('exportZip')),
4363
5422
  h(Button, { onClick: () => openBrowser(browseLibId), disabled: busy || !browseLibId, primary: true }, t('reload'))),
4364
5423
  },
4365
5424
  h('div', { style: S.row },
4366
5425
  h('span', { style: S.label }, t('currentLibrary')),
4367
5426
  h('select', {
4368
5427
  style: { ...S.input, flex: 1 }, value: browseLibId,
5428
+ title: (state.libraries || []).find((lib) => lib.id === browseLibId)?.scopeKey || t('currentLibrary'),
4369
5429
  onChange: (e) => setBrowseLibId(e.target.value),
4370
- }, (state.libraries || []).map((l) => h('option', { key: l.id, value: l.id }, `${l.name || l.id} (${l.scopeKey || '—'})`)))),
5430
+ }, (state.libraries || []).map((l) => h('option', { key: l.id, value: l.id }, libraryOptionLabel(l, state.libraries || []))))),
4371
5431
  browseSource === 'index'
4372
5432
  ? h('div', { style: { ...S.muted, marginTop: 6 } }, t('browserIndexOnly'))
4373
5433
  : null,
@@ -4512,8 +5572,9 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4512
5572
  h('select', {
4513
5573
  style: { ...S.input, flex: 1 },
4514
5574
  value: statsLibId,
5575
+ title: (state.libraries || []).find((lib) => lib.id === statsLibId)?.scopeKey || t('currentLibrary'),
4515
5576
  onChange: (e) => setStatsLibId(e.target.value),
4516
- }, (state.libraries || []).map((l) => h('option', { key: l.id, value: l.id }, `${l.name || l.id} (${l.scopeKey || '—'})`)))),
5577
+ }, (state.libraries || []).map((l) => h('option', { key: l.id, value: l.id }, libraryOptionLabel(l, state.libraries || []))))),
4517
5578
  !statsLibId
4518
5579
  ? h(Empty, null, t('statsNoLibrary'))
4519
5580
  : !statsData
@@ -4625,13 +5686,149 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4625
5686
  ]
4626
5687
  pageDefs.push({ label: t('pageMemories'), overline: 'MEMU', desc: t('descMemories'), icon: GlyphLayers })
4627
5688
  pageDefs.push({ label: t('pageLogs'), overline: 'ACTIVITY', desc: t('descLogs'), icon: GlyphText })
4628
- const memoriesBlock = h(MemoryWallPanel, { call, libraries: state.libraries || [], revision: state.revision, stamp: state.stamp, t,
4629
- indexed: localVector.vector?.indexed || null,
4630
- focus: wallFocus,
4631
- onOpen: item => {
4632
- if (item.libraryId) { setBrowseLibId(item.libraryId); setPage(PAGE.browser); void openBrowser(item.libraryId) }
4633
- else setPage(PAGE.global)
4634
- } })
5689
+ pageDefs.push({ label: t('pageLexicon'), overline: 'LEXICON', desc: t('descLexicon'), icon: GlyphStack })
5690
+ pageDefs.push({ label: t('pageHelp'), overline: 'GUIDE', desc: t('descHelp'), icon: GlyphQuestion })
5691
+ // 首次安装引导(§6.5 包 E):只在还没有任何库时出现——有了库,用户已经过了
5692
+ // 这一步,卡片只会碍事。不落 localStorage:关不掉是刻意的,等第一个库建好
5693
+ // 它自然消失;以后要记关闭状态必须走 settings,不许加本地存储。
5694
+ const firstRunCard = (state.libraries || []).length === 0
5695
+ ? h(Section, { title: t('firstRunTitle'), desc: t('firstRunDesc'), icon: GlyphLayers },
5696
+ h('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } },
5697
+ h(Button, { primary: true, onClick: () => setPage(PAGE.libraries) }, t('firstRunGoLibraries')),
5698
+ h(Button, { style: S.ghost, onClick: () => setPage(PAGE.help) }, t('firstRunGuide'))))
5699
+ : null
5700
+ const memoriesBlock = h(React.Fragment, null, firstRunCard,
5701
+ h(MemoryWallPanel, { call, libraries: state.libraries || [], revision: state.revision, stamp: state.stamp, t,
5702
+ indexed: localVector.vector?.indexed || null,
5703
+ focus: wallFocus,
5704
+ initialLibraryId: browseLibId,
5705
+ onNotice: setNotice,
5706
+ onOpen: item => {
5707
+ if (item.libraryId) { setBrowseLibId(item.libraryId); setPage(PAGE.browser); void openBrowser(item.libraryId) }
5708
+ else setPage(PAGE.global)
5709
+ } }))
5710
+ // 词典页:sourceMemoryId →「来源记忆 ↗」复用全局记忆页的 wallFocus 接力棒,
5711
+ // 跳到 MemU 记忆页并直接打开对应条目的抽屉。
5712
+ const lexiconBlock = h(LexiconPanel, {
5713
+ call,
5714
+ libraries: state.libraries || [],
5715
+ t,
5716
+ sessionId,
5717
+ onFocusMemory: (term) => {
5718
+ setWallFocus({ id: term.id, libraryId: term.libraryId || null, title: term.title || term.id, at: Date.now() })
5719
+ setPage(PAGE.memories)
5720
+ },
5721
+ })
5722
+
5723
+ // 帮助页 v2(包 G4):快速开始 5 步 + 七个详解区 + FAQ 10 条 + 反馈入口。
5724
+ // 所有跳转都走既有 setPage(不新增路由/RPC/localStorage),诊断复制只序列化
5725
+ // getSettingsState 已有快照(复用上方 copyText);正文全部走 i18n,段落/列表/
5726
+ // 表格只由本地小助手拼装,样式复用 Section/Tag/Button 与 S 里的既有 token。
5727
+ const helpStepTargets = [
5728
+ { key: 'helpStep1', to: PAGE.libraries },
5729
+ { key: 'helpStep2', to: null }, // 绑定发生在会话侧,页面里只能给文案指引
5730
+ { key: 'helpStep3', to: PAGE.settings },
5731
+ { key: 'helpStep4', to: PAGE.memories },
5732
+ { key: 'helpStep5', to: PAGE.lexicon },
5733
+ ]
5734
+ const copyDiagnostics = async () => {
5735
+ try {
5736
+ const snapshot = state || await call('getSettingsState', {})
5737
+ const engine = snapshot.engine || {}
5738
+ const summary = JSON.stringify({
5739
+ version: engine.currentVersion || engine.recommendedVersion || '',
5740
+ engine: { status: engine.status, installed: engine.installed, versions: engine.versions || [] },
5741
+ settings: snapshot.settings || {},
5742
+ libraries: (snapshot.libraries || []).map(lib => ({ id: lib.id, name: lib.name, scopeKey: lib.scopeKey, privateCount: lib.privateCount })),
5743
+ }, null, 2)
5744
+ const ok = await copyText(summary)
5745
+ setNotice(ok ? t('copied') : t('copyFailed'))
5746
+ } catch (failure) { setError(String(failure?.message || failure)) }
5747
+ }
5748
+ // 帮助页的三种小节积木:段落、列表、两列表格。它们只读 i18n 文本,
5749
+ // 不持有状态,也不做交互——帮助页是说明书,不是教程。
5750
+ const helpPara = key => h('p', { style: { ...S.muted, margin: '0 0 6px', fontSize: 12.5, lineHeight: 1.7 } }, t(key))
5751
+ const helpBullets = keys => h('ul', {
5752
+ style: { margin: '0 0 6px', padding: '0 0 0 18px', fontSize: 12.5, lineHeight: 1.7 },
5753
+ }, keys.map(key => h('li', { key, style: { marginBottom: 3 } }, t(key))))
5754
+ const helpCell = { borderBottom: `1px solid ${V.borderSoft}`, padding: '6px 8px', textAlign: 'left', verticalAlign: 'top' }
5755
+ const helpTable = rows => h('table', {
5756
+ 'data-help-table': 'labels',
5757
+ style: { width: '100%', borderCollapse: 'collapse', fontSize: 12, lineHeight: 1.6 },
5758
+ },
5759
+ h('thead', null, h('tr', null,
5760
+ h('th', { style: { ...helpCell, fontWeight: 600, whiteSpace: 'nowrap' } }, t('helpLabelColLabel')),
5761
+ h('th', { style: { ...helpCell, fontWeight: 600 } }, t('helpLabelColMeaning')))),
5762
+ h('tbody', null, rows.map(([label, meaning, rowKey]) => h('tr', { key: rowKey, 'data-help-row': rowKey },
5763
+ h('td', { style: { ...helpCell, overflowWrap: 'anywhere', fontFamily: 'var(--dshm-mono)' } }, label),
5764
+ h('td', { style: { ...helpCell, overflowWrap: 'anywhere' } }, meaning)))))
5765
+ const helpLabelRows = [
5766
+ [t('helpLabelSource'), t('helpLabelSourceMeaning'), 'helpLabelSource'],
5767
+ [t('helpLabelLayer'), t('helpLabelLayerMeaning'), 'helpLabelLayer'],
5768
+ [t('helpLabelOpenLoop'), t('helpLabelOpenLoopMeaning'), 'helpLabelOpenLoop'],
5769
+ [t('helpLabelMarginalized'), t('helpLabelMarginalizedMeaning'), 'helpLabelMarginalized'],
5770
+ [t('helpLabelDate'), t('helpLabelDateMeaning'), 'helpLabelDate'],
5771
+ [t('helpLabelAttachment'), t('helpLabelAttachmentMeaning'), 'helpLabelAttachment'],
5772
+ [t('helpLabelVector'), t('helpLabelVectorMeaning'), 'helpLabelVector'],
5773
+ [t('helpLabelReviewRisk'), t('helpLabelReviewRiskMeaning'), 'helpLabelReviewRisk'],
5774
+ [t('helpLabelLexicon'), t('helpLabelLexiconMeaning'), 'helpLabelLexicon'],
5775
+ ]
5776
+ const helpBlock = h(React.Fragment, null,
5777
+ h(Section, { title: t('helpQuickStart'), desc: t('descHelp'), icon: GlyphLayers },
5778
+ helpStepTargets.map((step, index) => h('div', { key: step.key, style: { ...S.row, alignItems: 'flex-start' } },
5779
+ h(Tag, { tone: 'info' }, String(index + 1)),
5780
+ h('span', { style: { flex: '1 1 320px', fontSize: 12.5, lineHeight: 1.6 } }, t(step.key)),
5781
+ step.to !== null
5782
+ ? h(Button, { style: { ...S.ghost, flexShrink: 0 }, onClick: () => setPage(step.to) }, t('helpGoTo'))
5783
+ : null))),
5784
+ h(Section, { title: t('helpScopes'), desc: t('helpScopesDesc'), icon: GlyphFolder },
5785
+ helpPara('helpScopesChain'),
5786
+ h('div', { style: S.row }, ['useLibrary', 'useGlobal', 'effectiveScope'].map(key => h(Tag, { key, tone: 'info' }, key))),
5787
+ helpPara('helpScopesPrivate'),
5788
+ helpPara('helpScopesGlobal'),
5789
+ helpPara('helpScopesWhere'),
5790
+ h('div', { style: { ...S.muted, margin: '8px 0 4px', fontWeight: 600 } }, t('helpScopesPitfallsTitle')),
5791
+ helpBullets(['helpScopesPitfall1', 'helpScopesPitfall2', 'helpScopesPitfall3'])),
5792
+ h(Section, { title: t('helpTitleCards'), icon: GlyphLayers },
5793
+ helpPara('helpTitleCardsBody'),
5794
+ helpPara('helpTitleCardsRecall'),
5795
+ helpPara('helpTitleCardsRule')),
5796
+ h(Section, { title: t('helpArchiveDelete'), icon: GlyphStack },
5797
+ helpBullets(['helpArchiveDeleteArchive', 'helpArchiveDeleteDelete', 'helpArchiveDeleteLexicon',
5798
+ 'helpArchiveDeleteAbsorb', 'helpArchiveDeleteSessionGone'])),
5799
+ h(Section, { title: t('helpVector'), icon: GlyphGauge },
5800
+ helpPara('helpVectorSetup'),
5801
+ helpPara('helpVectorCount'),
5802
+ helpPara('helpVectorFallback'),
5803
+ helpPara('helpVectorRebuild')),
5804
+ h(Section, { title: t('helpAutoLink'), icon: GlyphGlobe },
5805
+ helpPara('helpAutoLinkRule'),
5806
+ h('div', { style: S.row }, h(Tag, { tone: 'info' }, 'source=auto'), h(Tag, null, 'memu_private_link_suggest')),
5807
+ helpPara('helpAutoLinkHub'),
5808
+ helpPara('helpAutoLinkWorker'),
5809
+ helpPara('helpAutoLinkSafety')),
5810
+ h(Section, { title: t('helpLabels'), desc: t('helpLabelsDesc'), icon: GlyphText },
5811
+ helpTable(helpLabelRows)),
5812
+ h(Section, { title: t('helpSidebar'), desc: t('helpSidebarDesc'), icon: GlyphSliders },
5813
+ helpBullets(['helpSidebarAlerts', 'helpSidebarReview', 'helpSidebarRunLog',
5814
+ 'helpSidebarReviewMode', 'helpSidebarFirstRun', 'helpSidebarFallback', 'helpSidebarScope'])),
5815
+ h(Section, { title: t('helpFaq'), icon: GlyphQuestion },
5816
+ [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => h('details', { key: 'helpFaq' + n, style: { margin: '0 0 8px' } },
5817
+ h('summary', { style: { cursor: 'pointer', fontSize: 12.5, fontWeight: 600, lineHeight: 1.5 } }, t('helpFaq' + n)),
5818
+ h('div', { style: { ...S.muted, marginTop: 5, fontSize: 12, lineHeight: 1.7 } }, t('helpFaq' + n + 'A'))))),
5819
+ h(Section, { title: t('helpFeedback'), icon: GlyphGlobe },
5820
+ h('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } },
5821
+ h(Button, {
5822
+ primary: true,
5823
+ title: t('helpOpenIssue'),
5824
+ onClick: () => { try { window.open('https://github.com/Tkingxiao/dsh-memu-memory/issues/new', '_blank') } catch {} },
5825
+ }, t('helpOpenIssue')),
5826
+ h(Button, { style: S.ghost, onClick: () => { void copyDiagnostics() } }, t('helpCopyDiagnostics')),
5827
+ h(Button, {
5828
+ style: S.ghost,
5829
+ title: t('helpDocs'),
5830
+ onClick: () => { try { window.open('https://github.com/Tkingxiao/dsh-memu-memory#readme', '_blank') } catch {} },
5831
+ }, t('helpDocs')))))
4635
5832
 
4636
5833
  const LOG_KIND_LABELS = {
4637
5834
  recall: t('logKindRecall'), write: t('logKindWrite'), consolidate: t('logKindConsolidate'),
@@ -4719,7 +5916,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4719
5916
  row.detail
4720
5917
  ? h('pre', { style: { ...S.muted, margin: '6px 0 0', fontSize: 11, whiteSpace: 'pre-wrap', wordBreak: 'break-all' } }, JSON.stringify(row.detail).slice(0, 600))
4721
5918
  : null)))))
4722
- const pageBlocks = [engineBlock, connectionBlock, settingsBlock, foldersBlock, librariesBlock, browserBlock, globalBlock, promptsBlock, statsBlock, memoriesBlock, logsBlock]
5919
+ const pageBlocks = [engineBlock, connectionBlock, settingsBlock, foldersBlock, librariesBlock, browserBlock, globalBlock, promptsBlock, statsBlock, memoriesBlock, logsBlock, lexiconBlock, helpBlock]
4723
5920
 
4724
5921
  // Settings uses a left nav with a sliding indicator (the sidebar keeps its
4725
5922
  // top segments). The indicator is one absolutely-positioned element moved by
@@ -5118,10 +6315,21 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
5118
6315
  // 但不再压住页头。数据不按当前对话过滤,展开后失败/成功两列各看最新 6 条。
5119
6316
  alertBlock,
5120
6317
  h('div', { style: { marginTop: 12 } },
5121
- h('div', { style: { ...S.muted, marginBottom: 4, fontWeight: 600 } }, t('runLogTitle')),
6318
+ h('button', {
6319
+ type: 'button',
6320
+ title: t('runLogOpen'),
6321
+ onClick: () => { openWebUiPage('logs') },
6322
+ style: {
6323
+ display: 'flex', alignItems: 'center', gap: 6, width: '100%',
6324
+ marginBottom: 4, padding: 0, border: 0, background: 'transparent',
6325
+ color: 'inherit', font: 'inherit', textAlign: 'left', cursor: 'pointer',
6326
+ },
6327
+ },
6328
+ h('span', { style: { ...S.muted, fontWeight: 600 } }, t('runLogTitle')),
6329
+ h('span', { style: { marginLeft: 'auto', fontSize: 11, fontWeight: 600, color: V.accent } }, t('runLogOpen') + ' ↗')),
5122
6330
  runLog.length === 0
5123
6331
  ? h('div', { style: S.muted }, t('runLogEmpty'))
5124
- : h('div', { style: S.list }, runLog.slice(0, 8).map(renderRunRow)))),
6332
+ : h('div', { style: S.list }, runLog.slice(0, 5).map(renderRunRow)))),
5125
6333
  // 作用域放在控制卡之后:它是一组低频字段,先让用户看到记忆开关和整理入口。
5126
6334
  h(ContextScope, { binding, sessionId, call, t })
5127
6335
  )
@@ -5230,7 +6438,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
5230
6438
  }, p.label)))),
5231
6439
  error ? h('div', { style: { ...S.muted, padding: '8px 12px 0', color: V.danger } }, error) : null,
5232
6440
  notice ? h('div', { style: { ...S.muted, padding: '8px 12px 0' } }, notice) : null,
5233
- h('div', { style: S.tabBody }, pages[tabPage]?.node || null))
6441
+ h('div', { className: 'dshm-tab-body', style: S.tabBody }, pages[tabPage]?.node || null))
5234
6442
  }
5235
6443
 
5236
6444
  // One card = one pair of switches. The card the user clicks, the binding
@@ -5497,8 +6705,11 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
5497
6705
  // scope was written and the folder policy checked both switches.
5498
6706
  h('label', { style: S.row }, h('input', { type: 'radio', name: 'memu-library', checked: pick === 'folder', onChange: () => setPick('folder') }), t('folderLibrary')),
5499
6707
  h('label', { style: S.row }, h('input', { type: 'radio', name: 'memu-library', checked: pick === 'existing', onChange: () => setPick('existing') }), t('chooseLibrary')),
5500
- pick === 'existing' ? h('select', { style: { ...S.input, marginLeft: 24 }, value: libraryId, onChange: (e) => setLibraryId(e.target.value) },
5501
- libraries.map((l) => h('option', { key: l.id, value: l.id }, l.name || l.id))) : null,
6708
+ pick === 'existing' ? h('select', {
6709
+ style: { ...S.input, marginLeft: 24 }, value: libraryId,
6710
+ title: libraries.find((lib) => lib.id === libraryId)?.scopeKey || t('currentLibrary'),
6711
+ onChange: (e) => setLibraryId(e.target.value),
6712
+ }, libraries.map((l) => h('option', { key: l.id, value: l.id }, libraryOptionLabel(l, libraries)))) : null,
5502
6713
  h('label', { style: S.row }, h('input', { type: 'radio', name: 'memu-library', checked: pick === 'new', onChange: () => setPick('new') }), t('createLibrary')),
5503
6714
  pick === 'new' ? h('input', { style: { ...S.input, marginLeft: 24 }, placeholder: t('name'), value: newName, onChange: (e) => setNewName(e.target.value) }) : null)),
5504
6715
  h('label', { style: { ...S.row, marginTop: 12 } }, h('input', { type: 'checkbox', checked: dontShow, onChange: (e) => setDontShow(e.target.checked) }), t('rememberChoice')))
@@ -5516,7 +6727,11 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
5516
6727
  body: JSON.stringify({ type: 'client-request', rpcId: 'standalone-' + (++sequence), method: endpoint, payload: payload || {} }),
5517
6728
  })
5518
6729
  const envelope = await response.json()
5519
- if (!envelope.result) throw new Error('Invalid RPC response: ' + response.status)
6730
+ if (!envelope.result) {
6731
+ // 服务端错误信封(如 403 forbidden)带可读 message:透传它而不是
6732
+ // 只报 HTTP 状态码,用户才能看到「为什么」。
6733
+ throw new Error(envelope.error?.message || ('Invalid RPC response: ' + response.status))
6734
+ }
5520
6735
  return envelope.result
5521
6736
  } } },
5522
6737
  }