dsh-memu-memory 1.2.0 → 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.
- package/AGENTS.md +1 -1
- package/README.md +19 -8
- package/README.zh.md +12 -6
- package/docs/contributing.md +16 -3
- package/docs/env-and-commands.md +8 -0
- package/docs/host-api.md +3 -1
- package/docs/memory-semantics.md +21 -2
- package/docs/troubleshooting.md +42 -3
- package/lib/activity-log.js +22 -5
- package/lib/backup.js +24 -2
- package/lib/binding-diagnostics.js +3 -3
- package/lib/client.js +1610 -117
- package/lib/consolidation-worker.js +68 -17
- package/lib/curator-evidence.js +131 -0
- package/lib/curator-policy.js +81 -0
- package/lib/curator.js +1299 -83
- package/lib/cycle.js +3 -0
- package/lib/embedding-download.js +39 -4
- package/lib/engine.js +6 -3
- package/lib/global-graph.js +282 -0
- package/lib/graph.js +190 -14
- package/lib/inbound.js +19 -0
- package/lib/index.js +33 -1
- package/lib/inject.js +277 -17
- package/lib/lexicon.js +1115 -0
- package/lib/local-vector-service.js +8 -6
- package/lib/managed-runtime.js +33 -2
- package/lib/memory.js +585 -76
- package/lib/memu.js +2 -2
- package/lib/migrate.js +83 -2
- package/lib/private-links.js +587 -0
- package/lib/private.js +128 -7
- package/lib/prompts/global-curator.md +76 -19
- package/lib/prompts/private-consolidation.coding.md +4 -1
- package/lib/prompts/private-consolidation.md +3 -1
- package/lib/prompts/private-consolidation.mixed.md +3 -1
- package/lib/prompts/private-consolidation.rp.md +3 -1
- package/lib/prompts.js +1 -1
- package/lib/rpc.js +622 -13
- package/lib/state.js +4 -1
- package/lib/text-retrieval.js +661 -0
- package/lib/text-similarity.js +124 -0
- package/lib/tools.js +230 -16
- package/lib/vector-migration.js +28 -3
- package/lib/vector-profile.js +24 -0
- package/lib/webui/standalone.css +17 -0
- package/lib/webui/standalone.js +2 -0
- package/package.json +3 -2
- package/scripts/acceptance-local.mjs +8 -5
- package/scripts/curator-pair-calibrate.mjs +259 -0
- package/scripts/lexicon-calibrate.mjs +230 -0
- package/scripts/lexicon-dedupe.mjs +318 -0
- package/scripts/replay.mjs +573 -0
- package/tests/absorb-review.test.mjs +353 -0
- package/tests/appendix-b.test.mjs +6 -2
- package/tests/appendix-b2.test.mjs +2 -2
- package/tests/client-export.test.mjs +520 -0
- package/tests/client-render.test.mjs +19 -3
- package/tests/consolidation-credential.test.mjs +16 -5
- package/tests/consolidation-feed.test.mjs +96 -0
- package/tests/consolidation-fork.test.mjs +144 -10
- package/tests/consolidation-snapshot.test.mjs +82 -4
- package/tests/curator-absorb.test.mjs +443 -0
- package/tests/curator-calibrate.test.mjs +173 -0
- package/tests/curator-clusters.test.mjs +281 -0
- package/tests/curator-commit.test.mjs +324 -0
- package/tests/curator-evidence.test.mjs +102 -0
- package/tests/curator-link.test.mjs +316 -0
- package/tests/curator-pairing.test.mjs +252 -0
- package/tests/curator-probe.test.mjs +15 -6
- package/tests/curator-review-mode.test.mjs +314 -0
- package/tests/disclosure.test.mjs +2 -1
- package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
- package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
- package/tests/fixtures/lexicon-stub.mjs +284 -0
- package/tests/graph-cascade.test.mjs +223 -0
- package/tests/graph.test.mjs +113 -0
- package/tests/help-page.test.mjs +415 -0
- package/tests/hybrid-recall.test.mjs +32 -2
- package/tests/inbound.test.mjs +6 -3
- package/tests/inject-guide.test.mjs +28 -0
- package/tests/inject.test.mjs +157 -5
- package/tests/lexicon-calibrate.test.mjs +48 -0
- package/tests/lexicon-dedupe.test.mjs +171 -0
- package/tests/lexicon-frequency.test.mjs +179 -0
- package/tests/lexicon-inject.test.mjs +410 -0
- package/tests/lexicon-perf.test.mjs +436 -0
- package/tests/lexicon-ui.test.mjs +547 -0
- package/tests/lexicon.test.mjs +922 -0
- package/tests/managed-memory.test.mjs +55 -0
- package/tests/managed-runtime.test.mjs +32 -0
- package/tests/private-links.test.mjs +445 -0
- package/tests/prompts.test.mjs +35 -0
- package/tests/recall-dedup.test.mjs +446 -0
- package/tests/sidebar-beat.test.mjs +5 -1
- package/tests/tool-schema.test.mjs +1 -1
- package/tests/tools.test.mjs +112 -3
- package/tests/vector-migration.test.mjs +58 -1
- package/tests/webui-auth.test.mjs +348 -2
- package/tests/webui.test.mjs +191 -2
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,12 +135,43 @@ window.__ModuleLoader__.load({
|
|
|
135
135
|
logClear: '清空日志',
|
|
136
136
|
runLogTitle: '整理记录',
|
|
137
137
|
runLogEmpty: '还没有整理记录。',
|
|
138
|
+
runLogOpen: '全部日志',
|
|
139
|
+
dedupedNotice: '未写入:库里已有几乎相同的记忆 [{id}]。要补充内容请在原条目上编辑合并;确属两次独立观测时才需要 allow_duplicate 重写。',
|
|
140
|
+
consolidateFeedTitle: '整理提醒',
|
|
141
|
+
consolidateFailedCol: '失败',
|
|
142
|
+
consolidateOkCol: '成功',
|
|
143
|
+
consolidateFailed: '失败',
|
|
144
|
+
consolidateDone: '完成',
|
|
145
|
+
consolidateFailedCount: '失败 {n}',
|
|
146
|
+
consolidateJumpHint: '在记忆工作室中打开这条记忆所在的库',
|
|
147
|
+
consolidateExpand: '展开',
|
|
148
|
+
consolidateCollapse: '收起',
|
|
149
|
+
consolidateClear: '清除失败记录',
|
|
150
|
+
consolidateClearHint: '只删除失败的整理日志;成功记录与其它日志不动',
|
|
151
|
+
consolidateCleared: '已清除 {n} 条失败记录',
|
|
138
152
|
allLibraries: '全部记忆库',
|
|
153
|
+
wallAllBlocked: '当前宿主只允许单库读取,已显示第一个记忆库',
|
|
154
|
+
linkCountTag: '关联 {n}',
|
|
155
|
+
linkRemoved: '关联已移除',
|
|
139
156
|
searchMemories: '搜索标题、内容或标签',
|
|
140
157
|
poolReview: '待复核',
|
|
141
158
|
poolInactive: '已失效',
|
|
142
159
|
wallReadError: '部分记忆库读取失败',
|
|
143
160
|
wallLimit: '仅展示最近 300 条',
|
|
161
|
+
wallScopePrivate: '私有',
|
|
162
|
+
wallScopeGlobal: '全局',
|
|
163
|
+
wallFilterScope: '范围',
|
|
164
|
+
wallFilterStatus: '状态',
|
|
165
|
+
wallFilterLayer: '层级',
|
|
166
|
+
wallAllLayers: '全部层级',
|
|
167
|
+
multiSelect: '多选',
|
|
168
|
+
wallSelected: '已选 {n} 条',
|
|
169
|
+
wallSelectAll: '全选本页',
|
|
170
|
+
wallClearSelection: '清空选择',
|
|
171
|
+
wallMultiExit: '退出多选',
|
|
172
|
+
viewMemory: '查看此记忆',
|
|
173
|
+
entityDelete: '删除实体',
|
|
174
|
+
entityDeleteForceHint: '仍被记忆或关系引用;再点一次将连同引用一起删除',
|
|
144
175
|
openMemu: '打开 MemU WebUI',
|
|
145
176
|
saved: '已保存',
|
|
146
177
|
copied: '已复制',
|
|
@@ -162,7 +193,17 @@ window.__ModuleLoader__.load({
|
|
|
162
193
|
characterId: '当前角色 ID',
|
|
163
194
|
worldId: '世界线 ID',
|
|
164
195
|
branchId: '剧情分支 ID',
|
|
165
|
-
|
|
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: '全部待审',
|
|
166
207
|
archiveTooLarge: '附件包超过单次导入上限(16 MB),请拆分',
|
|
167
208
|
openSettings: '打开设置页',
|
|
168
209
|
sidebarSettings: '在侧边栏保留完整设置',
|
|
@@ -173,7 +214,7 @@ window.__ModuleLoader__.load({
|
|
|
173
214
|
adjacency: '邻接列表',
|
|
174
215
|
graphView: '关系图',
|
|
175
216
|
viewMemories: '记忆',
|
|
176
|
-
viewRelations: '
|
|
217
|
+
viewRelations: '节点图',
|
|
177
218
|
close: '关闭',
|
|
178
219
|
openInBrowser: '在记忆浏览中打开',
|
|
179
220
|
globalReadOnly: '全局记忆在这里只读;需要修改请去全局记忆页。',
|
|
@@ -192,6 +233,7 @@ window.__ModuleLoader__.load({
|
|
|
192
233
|
entityAliases: '别名(逗号分隔)',
|
|
193
234
|
entityKind: '实体类型',
|
|
194
235
|
graphEmpty: '还没有关联',
|
|
236
|
+
graphAutoLink: '自动',
|
|
195
237
|
deletedNode: '[已删除]',
|
|
196
238
|
graphLimited: '仅显示最多 200 个节点',
|
|
197
239
|
graphFit: '适应全图',
|
|
@@ -273,6 +315,13 @@ window.__ModuleLoader__.load({
|
|
|
273
315
|
reviews: '待复核',
|
|
274
316
|
reviewsEmpty: '没有等待复核的记忆。',
|
|
275
317
|
reviewScopePrivate: '私有库',
|
|
318
|
+
// 包 F2:集合吸收(absorb)复核与撤销。
|
|
319
|
+
absorbTag: '集合吸收 · {n} 条',
|
|
320
|
+
absorbSourcesTag: '集合记忆 · {n} 条来源',
|
|
321
|
+
absorbUndo: '撤销合并',
|
|
322
|
+
absorbUndone: '已撤销合并,来源记忆已恢复',
|
|
323
|
+
absorbSourcesLabel: '吸收来源',
|
|
324
|
+
absorbLinksLabel: '关联记忆',
|
|
276
325
|
accuracyTag: '细节未必准',
|
|
277
326
|
accept: '接受',
|
|
278
327
|
reject: '丢弃',
|
|
@@ -610,7 +659,170 @@ window.__ModuleLoader__.load({
|
|
|
610
659
|
rememberChoice: '记住我的选择,下次新对话直接用',
|
|
611
660
|
confirm: '确定',
|
|
612
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: '别名',
|
|
613
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: '打开文档',
|
|
614
826
|
}
|
|
615
827
|
|
|
616
828
|
const en = {
|
|
@@ -642,12 +854,43 @@ window.__ModuleLoader__.load({
|
|
|
642
854
|
logClear: 'Clear log',
|
|
643
855
|
runLogTitle: 'Runs',
|
|
644
856
|
runLogEmpty: 'No runs recorded yet.',
|
|
857
|
+
runLogOpen: 'All logs',
|
|
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.',
|
|
859
|
+
consolidateFeedTitle: 'Consolidation alerts',
|
|
860
|
+
consolidateFailedCol: 'Failed',
|
|
861
|
+
consolidateOkCol: 'Succeeded',
|
|
862
|
+
consolidateFailed: 'Fail',
|
|
863
|
+
consolidateDone: 'Done',
|
|
864
|
+
consolidateFailedCount: '{n} failed',
|
|
865
|
+
consolidateJumpHint: 'Open the library holding this memory in the memory studio',
|
|
866
|
+
consolidateExpand: 'Expand',
|
|
867
|
+
consolidateCollapse: 'Collapse',
|
|
868
|
+
consolidateClear: 'Clear failures',
|
|
869
|
+
consolidateClearHint: 'Deletes only failed consolidation logs; successes and other logs stay',
|
|
870
|
+
consolidateCleared: 'Cleared {n} failure logs',
|
|
645
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',
|
|
646
875
|
searchMemories: 'Search titles, content or tags',
|
|
647
876
|
poolReview: 'Review',
|
|
648
877
|
poolInactive: 'Inactive',
|
|
649
878
|
wallReadError: 'Some libraries could not be read',
|
|
650
879
|
wallLimit: 'Showing the latest 300 entries',
|
|
880
|
+
wallScopePrivate: 'Private',
|
|
881
|
+
wallScopeGlobal: 'Global',
|
|
882
|
+
wallFilterScope: 'Scope',
|
|
883
|
+
wallFilterStatus: 'Status',
|
|
884
|
+
wallFilterLayer: 'Layer',
|
|
885
|
+
wallAllLayers: 'All layers',
|
|
886
|
+
multiSelect: 'Multi-select',
|
|
887
|
+
wallSelected: '{n} selected',
|
|
888
|
+
wallSelectAll: 'Select page',
|
|
889
|
+
wallClearSelection: 'Clear selection',
|
|
890
|
+
wallMultiExit: 'Exit select',
|
|
891
|
+
viewMemory: 'View',
|
|
892
|
+
entityDelete: 'Delete entity',
|
|
893
|
+
entityDeleteForceHint: 'Still referenced by memories or relations; click again to delete with its links',
|
|
651
894
|
openMemu: 'Open MemU WebUI',
|
|
652
895
|
saved: 'Saved',
|
|
653
896
|
copied: 'Copied',
|
|
@@ -669,7 +912,17 @@ window.__ModuleLoader__.load({
|
|
|
669
912
|
characterId: 'Current character ID',
|
|
670
913
|
worldId: 'World ID',
|
|
671
914
|
branchId: 'Story branch ID',
|
|
672
|
-
|
|
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',
|
|
673
926
|
archiveTooLarge: 'Attachment archive exceeds 16 MB; split it before importing',
|
|
674
927
|
openSettings: 'Open settings',
|
|
675
928
|
sidebarSettings: 'Keep full settings in the sidebar',
|
|
@@ -680,7 +933,7 @@ window.__ModuleLoader__.load({
|
|
|
680
933
|
adjacency: 'Adjacency',
|
|
681
934
|
graphView: 'Graph',
|
|
682
935
|
viewMemories: 'Memories',
|
|
683
|
-
viewRelations: '
|
|
936
|
+
viewRelations: 'Node graph',
|
|
684
937
|
close: 'Close',
|
|
685
938
|
openInBrowser: 'Open in the browser',
|
|
686
939
|
globalReadOnly: 'Global memories are read-only here; edit them on the global page.',
|
|
@@ -699,6 +952,7 @@ window.__ModuleLoader__.load({
|
|
|
699
952
|
entityAliases: 'Aliases (comma separated)',
|
|
700
953
|
entityKind: 'Entity kind',
|
|
701
954
|
graphEmpty: 'No connections yet',
|
|
955
|
+
graphAutoLink: 'Auto',
|
|
702
956
|
deletedNode: '[deleted]',
|
|
703
957
|
graphLimited: 'Showing at most 200 nodes',
|
|
704
958
|
graphFit: 'Fit graph',
|
|
@@ -780,6 +1034,13 @@ window.__ModuleLoader__.load({
|
|
|
780
1034
|
reviews: 'Awaiting review',
|
|
781
1035
|
reviewsEmpty: 'No memories are waiting for review.',
|
|
782
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',
|
|
783
1044
|
accuracyTag: 'details may be off',
|
|
784
1045
|
accept: 'Accept',
|
|
785
1046
|
reject: 'Discard',
|
|
@@ -1116,7 +1377,170 @@ window.__ModuleLoader__.load({
|
|
|
1116
1377
|
rememberChoice: 'Remember this choice for new conversations',
|
|
1117
1378
|
confirm: 'Confirm',
|
|
1118
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',
|
|
1119
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',
|
|
1120
1544
|
}
|
|
1121
1545
|
|
|
1122
1546
|
// One token table for both surfaces. Every colour goes through a DSH alias so
|
|
@@ -1226,6 +1650,8 @@ window.__ModuleLoader__.load({
|
|
|
1226
1650
|
.dshm-graph-tools{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:12px 0}
|
|
1227
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}
|
|
1228
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}
|
|
1229
1655
|
.dshm-graph-list{display:grid;gap:8px;margin:12px 0}
|
|
1230
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}
|
|
1231
1657
|
.dshm-graph-edge>div{flex:1;min-width:0;overflow-wrap:anywhere}
|
|
@@ -1253,15 +1679,17 @@ window.__ModuleLoader__.load({
|
|
|
1253
1679
|
.dshm-graph-missing{opacity:.5}
|
|
1254
1680
|
/* The sidebar's context-scope form: a collapsed card instead of four bare
|
|
1255
1681
|
inputs and a browser-default button. */
|
|
1256
|
-
.dshm-scope{margin:0
|
|
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}
|
|
1257
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}
|
|
1258
1684
|
.dshm-scope>summary::-webkit-details-marker{display:none}
|
|
1259
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}
|
|
1260
1686
|
.dshm-scope[open]>summary::before{transform:rotate(45deg)}
|
|
1261
1687
|
.dshm-scope>summary:hover{color:var(--dsw-alias-label-primary,inherit)}
|
|
1262
1688
|
.dshm-scope>summary:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#2563eb);outline-offset:-2px}
|
|
1263
|
-
.dshm-scope-form{display:grid;grid-template-columns:repeat(
|
|
1264
|
-
.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}
|
|
1265
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}
|
|
1266
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)}
|
|
1267
1695
|
.dshm-scope-actions{grid-column:1/-1;display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:2px}
|
|
@@ -1462,8 +1890,11 @@ window.__ModuleLoader__.load({
|
|
|
1462
1890
|
* Note `window.open(..., 'noopener')` returns null even on success, which is
|
|
1463
1891
|
* why the blocked check must not pass that flag.
|
|
1464
1892
|
*/
|
|
1465
|
-
function openWebUiPage(page = null) {
|
|
1466
|
-
|
|
1893
|
+
function openWebUiPage(page = null, { libraryId = null } = {}) {
|
|
1894
|
+
// libraryId 走 query:SettingsPage 的 standalone 分支从 search 里取 browseLibId,
|
|
1895
|
+
// 跳进去就停在出错记忆所在的库;hash 只决定落到哪个页面。
|
|
1896
|
+
const query = libraryId ? '?libraryId=' + encodeURIComponent(libraryId) : ''
|
|
1897
|
+
let url = '/dsh-memu-memory/ui' + query + '#/settings/' + (page || 'memories')
|
|
1467
1898
|
try { url = new URL(url, window.location.href).toString() } catch {}
|
|
1468
1899
|
let win = null
|
|
1469
1900
|
try { win = window.open(url, '_blank') } catch { win = null }
|
|
@@ -1566,6 +1997,7 @@ window.__ModuleLoader__.load({
|
|
|
1566
1997
|
const GlyphText = glyph(['M5 6h14', 'M5 11h14', 'M5 16h9'])
|
|
1567
1998
|
const GlyphLayers = glyph(['M12 3l9 5-9 5-9-5 9-5z', 'M3 13l9 5 9-5'])
|
|
1568
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'])
|
|
1569
2001
|
|
|
1570
2002
|
// Thirty daily bars, written by hand: the bundle purity gate refuses absolute
|
|
1571
2003
|
// imports, and a chart this small does not need a library. Zero days still draw
|
|
@@ -1664,13 +2096,14 @@ window.__ModuleLoader__.load({
|
|
|
1664
2096
|
h('div', { style: { ...S.barFill, width: `${pct}%` } }))
|
|
1665
2097
|
}
|
|
1666
2098
|
|
|
1667
|
-
function Button({ children, onClick, disabled, primary, style, title, type = 'button' }) {
|
|
2099
|
+
function Button({ children, onClick, disabled, primary, style, title, type = 'button', 'aria-pressed': ariaPressed }) {
|
|
1668
2100
|
return h('button', {
|
|
1669
2101
|
type,
|
|
1670
2102
|
className: 'dshm-btn',
|
|
1671
2103
|
// Without this the prop never reaches the node, and every hover hint on
|
|
1672
2104
|
// the surface silently stops working.
|
|
1673
2105
|
title,
|
|
2106
|
+
'aria-pressed': ariaPressed,
|
|
1674
2107
|
style: { ...S.btn, ...(primary ? S.primary : {}), ...(style || {}), opacity: disabled ? 0.5 : 1 },
|
|
1675
2108
|
onClick,
|
|
1676
2109
|
disabled,
|
|
@@ -1813,6 +2246,20 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
1813
2246
|
h('div', { style: { display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 } }, footer || null)))
|
|
1814
2247
|
}
|
|
1815
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
|
+
|
|
1816
2263
|
function useRpc(ctx) {
|
|
1817
2264
|
return React.useCallback((endpoint, payload) => rpc(ctx, endpoint, payload), [ctx])
|
|
1818
2265
|
}
|
|
@@ -1826,7 +2273,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
1826
2273
|
const ui = ctx.get?.('uiSession') || ctx.uiSession
|
|
1827
2274
|
const cur = ui?.adapter?.current
|
|
1828
2275
|
if (cur?.getSnapshot) {
|
|
1829
|
-
return { subscribe: cur.subscribe.bind(cur), get: ()
|
|
2276
|
+
return { subscribe: typeof cur.subscribe === 'function' ? cur.subscribe.bind(cur) : function () { return function () {} }, get: function () { return cur.getSnapshot()?.key } }
|
|
1830
2277
|
}
|
|
1831
2278
|
return null
|
|
1832
2279
|
}, [ctx])
|
|
@@ -2050,6 +2497,8 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
2050
2497
|
const [entityName, setEntityName] = React.useState('')
|
|
2051
2498
|
const [entityKind, setEntityKind] = React.useState('person')
|
|
2052
2499
|
const [armed, setArmed] = React.useState('')
|
|
2500
|
+
// deleteEntity 被后端拒绝(仍被引用)的实体 id:此时再点确认升级为 force。
|
|
2501
|
+
const [deleteRefused, setDeleteRefused] = React.useState('')
|
|
2053
2502
|
const [error, setError] = React.useState('')
|
|
2054
2503
|
const requestId = React.useRef(0)
|
|
2055
2504
|
const mounted = React.useRef(true)
|
|
@@ -2091,6 +2540,38 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
2091
2540
|
}
|
|
2092
2541
|
const titles = new Map(graph.nodes.map(node => [node.id, node.missing ? t('deletedNode') : node.title]))
|
|
2093
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')))
|
|
2094
2575
|
return h('div', null,
|
|
2095
2576
|
error ? h('div', { className: 'dshm-toast', role: 'status' }, error) : null,
|
|
2096
2577
|
h('div', { className: 'dshm-graph-tools' },
|
|
@@ -2107,6 +2588,9 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
2107
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' },
|
|
2108
2589
|
h('div', null, h('button', { type: 'button', className: 'dshm-graph-node', onClick: () => setFocus(edge.from) }, titles.get(edge.from) || edge.from),
|
|
2109
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,
|
|
2110
2594
|
h('small', null, edge.kind + ' · ' + Math.round(edge.confidence * 100) + '% · ' + edge.note)),
|
|
2111
2595
|
h('button', {
|
|
2112
2596
|
type: 'button',
|
|
@@ -2130,14 +2614,20 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
2130
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))),
|
|
2131
2615
|
h(Button, { disabled: !entityName.trim(), onClick: () => act('createEntity', { name: entityName, kind: entityKind }) }, t('entityNew'))),
|
|
2132
2616
|
h(RelationshipPanel, { call, libraryId, entities, files, t }),
|
|
2133
|
-
entities.
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
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
|
+
})())
|
|
2141
2631
|
}
|
|
2142
2632
|
|
|
2143
2633
|
function InboundToolsInput({ names, onChange }) {
|
|
@@ -2166,9 +2656,13 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
2166
2656
|
item.attachmentCount > 1 ? h('span', { className: 'dshm-wall-cover-count' }, '▧ ' + item.attachmentCount) : null)
|
|
2167
2657
|
}
|
|
2168
2658
|
|
|
2169
|
-
function MemoryWallPanel({ call, libraries, revision, stamp, onOpen, t, indexed = 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) }
|
|
2170
2663
|
const [data, setData] = React.useState({ items: [], total: 0, errors: [] })
|
|
2171
|
-
const [libraryId, setLibraryId] = React.useState('')
|
|
2664
|
+
const [libraryId, setLibraryId] = React.useState(initialLibraryId || '')
|
|
2665
|
+
const [multiBlocked, setMultiBlocked] = React.useState(false)
|
|
2172
2666
|
const [view, setView] = React.useState('memories')
|
|
2173
2667
|
const [graphLib, setGraphLib] = React.useState('')
|
|
2174
2668
|
const [pool, setPool] = React.useState('active')
|
|
@@ -2176,21 +2670,66 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
2176
2670
|
const [query, setQuery] = React.useState('')
|
|
2177
2671
|
const [error, setError] = React.useState('')
|
|
2178
2672
|
const [version, setVersion] = React.useState(0)
|
|
2673
|
+
// 组分类:私有 / 全局两块,默认私有、私有靠前。全局条目只读,所以多选只在私有块。
|
|
2674
|
+
const [scope, setScope] = React.useState('private')
|
|
2675
|
+
const [multi, setMulti] = React.useState(false)
|
|
2676
|
+
const [selectedIds, setSelectedIds] = React.useState(() => new Set())
|
|
2677
|
+
const [wallBusy, setWallBusy] = React.useState(false)
|
|
2678
|
+
const [deleteArmedMulti, setDeleteArmedMulti] = React.useState(false)
|
|
2179
2679
|
// Opens in place instead of jumping to the browser: one card, one request, and
|
|
2180
2680
|
// the wall stays where the reader left it. `null` means the drawer is closed.
|
|
2181
2681
|
const [detail, setDetail] = React.useState(null)
|
|
2182
2682
|
const [deleteArmed, setDeleteArmed] = React.useState(false)
|
|
2183
2683
|
const reload = () => setVersion(value => value + 1)
|
|
2684
|
+
// 全局记忆页"查看此记忆"的落点:带着 id 进来,定位到对应块并直接开抽屉。
|
|
2685
|
+
React.useEffect(() => {
|
|
2686
|
+
if (!focus?.id) return
|
|
2687
|
+
setMulti(false)
|
|
2688
|
+
setSelectedIds(new Set())
|
|
2689
|
+
setScope(focus.libraryId ? 'private' : 'global')
|
|
2690
|
+
void openDetail({ id: focus.id, libraryId: focus.libraryId || null, title: focus.title || focus.id })
|
|
2691
|
+
}, [focus])
|
|
2692
|
+
const firstLibraryId = libraries?.[0]?.id || ''
|
|
2184
2693
|
React.useEffect(() => {
|
|
2185
2694
|
let alive = true
|
|
2186
|
-
|
|
2187
|
-
|
|
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()
|
|
2188
2725
|
return () => { alive = false }
|
|
2189
|
-
}, [call, libraryId, revision, stamp, version])
|
|
2726
|
+
}, [call, libraryId, revision, stamp, version, firstLibraryId])
|
|
2190
2727
|
React.useEffect(() => {
|
|
2191
|
-
if (graphLib) return
|
|
2192
|
-
const
|
|
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 || ''
|
|
2193
2731
|
if (first) setGraphLib(first)
|
|
2732
|
+
else if (graphLib) setGraphLib('')
|
|
2194
2733
|
}, [graphLib, libraryId, libraries])
|
|
2195
2734
|
React.useEffect(() => {
|
|
2196
2735
|
if (!detail) return undefined
|
|
@@ -2207,7 +2746,11 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
2207
2746
|
const poolOf = item => item.status === 'pending_review' ? 'review' : item.status === 'archived' ? 'archived'
|
|
2208
2747
|
: ['suppressed', 'expired', 'superseded'].includes(item.status) || item.expiresAt && Date.parse(item.expiresAt) <= Date.now() ? 'inactive' : 'active'
|
|
2209
2748
|
const items = Array.isArray(data?.items) ? data.items : []
|
|
2210
|
-
const
|
|
2749
|
+
const inScope = item => scope === 'global' ? item.libraryId === null : item.libraryId !== null
|
|
2750
|
+
const scoped = items.filter(inScope)
|
|
2751
|
+
const rows = scoped.filter(item => poolOf(item) === pool)
|
|
2752
|
+
const keyOf = item => (item.libraryId || 'global') + item.id
|
|
2753
|
+
const selectedRows = visible => visible.filter(item => selectedIds.has(keyOf(item)))
|
|
2211
2754
|
const layerMatch = (item, value) => value === 'all' || (value === 'anchor' ? item.layer === 'must_remember' && poolOf(item) === 'active' : item.layer === value)
|
|
2212
2755
|
const text = String(query || '').toLowerCase()
|
|
2213
2756
|
const visible = rows.filter(item => layerMatch(item, layer) && (!text || [item.title, item.preview, (item.tags || []).join(' ')].join(' ').toLowerCase().includes(text)))
|
|
@@ -2268,6 +2811,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
2268
2811
|
if (Number.isFinite(confidence)) patch.confidence = Math.max(0, Math.min(1, confidence))
|
|
2269
2812
|
await call('updateMemory', { libraryId: current.item.libraryId, id: current.item.id, patch })
|
|
2270
2813
|
await refreshDetail(current)
|
|
2814
|
+
notice(t('memorySaved'))
|
|
2271
2815
|
} catch (failure) {
|
|
2272
2816
|
setDetail(prev => prev ? { ...prev, busy: false, error: String(failure?.message || failure) } : prev)
|
|
2273
2817
|
}
|
|
@@ -2279,6 +2823,8 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
2279
2823
|
try {
|
|
2280
2824
|
await call('updateMemory', { libraryId: current.item.libraryId, id: current.item.id, patch: { status } })
|
|
2281
2825
|
await refreshDetail(current)
|
|
2826
|
+
// 归档/恢复/保存是三个不同结果,同一条通知通道但各说各话。
|
|
2827
|
+
notice(status === 'archived' ? t('memoryArchived') : status === 'active' ? t('memoryRestored') : t('memorySaved'))
|
|
2282
2828
|
} catch (failure) {
|
|
2283
2829
|
setDetail(prev => prev ? { ...prev, busy: false, error: String(failure?.message || failure) } : prev)
|
|
2284
2830
|
}
|
|
@@ -2292,12 +2838,41 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
2292
2838
|
setDetail(null)
|
|
2293
2839
|
setDeleteArmed(false)
|
|
2294
2840
|
notifyMemoryWrite('memory', current.item.libraryId)
|
|
2841
|
+
notice(t('memoryDeleted'))
|
|
2295
2842
|
reload()
|
|
2296
2843
|
} catch (failure) {
|
|
2297
2844
|
setDetail(prev => prev ? { ...prev, busy: false, error: String(failure?.message || failure) } : prev)
|
|
2298
2845
|
}
|
|
2299
2846
|
}
|
|
2300
2847
|
|
|
2848
|
+
// —— 多选批量操作(只对私有块生效;全局只读)——
|
|
2849
|
+
// 逐条走既有 RPC(updateMemory patch.status / deleteMemory),循环里失败的条目
|
|
2850
|
+
// 记下来一次性报错:部分成功不能说成全部成功,也不该让一条失败吞掉其余的成果。
|
|
2851
|
+
const exitMulti = () => { setMulti(false); setSelectedIds(new Set()); setDeleteArmedMulti(false) }
|
|
2852
|
+
const bulkApply = async (action) => {
|
|
2853
|
+
const targets = selectedRows(visible).filter(item => action === 'archive'
|
|
2854
|
+
? ['active', 'inactive'].includes(poolOf(item))
|
|
2855
|
+
: action === 'restore' || action === 'delete' ? item.status === 'archived' : false)
|
|
2856
|
+
if (targets.length === 0) return
|
|
2857
|
+
setWallBusy(true); setError('')
|
|
2858
|
+
let done = 0
|
|
2859
|
+
const failures = []
|
|
2860
|
+
for (const item of targets) {
|
|
2861
|
+
try {
|
|
2862
|
+
if (action === 'delete') await call('deleteMemory', { libraryId: item.libraryId, id: item.id })
|
|
2863
|
+
else await call('updateMemory', { libraryId: item.libraryId, id: item.id, patch: { status: action === 'archive' ? 'archived' : 'active' } })
|
|
2864
|
+
done += 1
|
|
2865
|
+
} catch (failure) { failures.push(`${item.title || item.id}: ${String(failure?.message || failure)}`) }
|
|
2866
|
+
}
|
|
2867
|
+
setWallBusy(false)
|
|
2868
|
+
setDeleteArmedMulti(false)
|
|
2869
|
+
setSelectedIds(new Set())
|
|
2870
|
+
if (done > 0) { notifyMemoryWrite('memory', libraryId || null); reload() }
|
|
2871
|
+
// 部分成功不说成全部成功:有失败只走 error(带 done/total 前缀),全成功才报 notice。
|
|
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)))
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2301
2876
|
const shown = detail ? { ...detail.item, ...(detail.file || {}) } : null
|
|
2302
2877
|
const detailForm = detail && detail.file && !detail.readonly
|
|
2303
2878
|
? h('div', { className: 'dshm-drawer-form' },
|
|
@@ -2344,9 +2919,30 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
2344
2919
|
t('decay') + ' ' + Math.round((Number(shown.decay) || 0) * 100) + '% · ' +
|
|
2345
2920
|
(shown.updatedAt ? String(shown.updatedAt).slice(0, 16).replace('T', ' ') : '')))
|
|
2346
2921
|
: detail && detail.file && detail.readonly
|
|
2347
|
-
?
|
|
2348
|
-
|
|
2349
|
-
|
|
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
|
+
})()
|
|
2350
2946
|
: detail && detail.busy
|
|
2351
2947
|
? h(Empty, null, t('loading'))
|
|
2352
2948
|
: null
|
|
@@ -2420,7 +3016,8 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
2420
3016
|
h(Button, { style: S.ghost, onClick: () => setDetail(null) }, t('close')))))
|
|
2421
3017
|
: null
|
|
2422
3018
|
|
|
2423
|
-
const
|
|
3019
|
+
const validLib = (id) => id && libraries.some((lib) => lib.id === id) ? id : ''
|
|
3020
|
+
const graphLibrary = validLib(graphLib) || validLib(libraryId) || libraries[0]?.id || ''
|
|
2424
3021
|
const graphFiles = React.useMemo(() => items
|
|
2425
3022
|
.filter(item => item.libraryId === graphLibrary)
|
|
2426
3023
|
.map(item => ({ id: item.id, title: item.title })), [items, graphLibrary])
|
|
@@ -2431,39 +3028,140 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
2431
3028
|
h('div', { className: 'dshm-graph-tools' },
|
|
2432
3029
|
h('select', {
|
|
2433
3030
|
value: graphLibrary,
|
|
2434
|
-
title: t('currentLibrary'),
|
|
3031
|
+
title: libraries.find((lib) => lib.id === graphLibrary)?.scopeKey || t('currentLibrary'),
|
|
2435
3032
|
// One library drives both views, otherwise the graph's focus list can
|
|
2436
3033
|
// be empty when the wall is filtered to a different library.
|
|
2437
3034
|
onChange: (event) => { setGraphLib(event.target.value); setLibraryId(event.target.value) },
|
|
2438
3035
|
},
|
|
2439
|
-
libraries.map(lib => h('option', { key: lib.id, value: lib.id }, lib
|
|
3036
|
+
libraries.map(lib => h('option', { key: lib.id, value: lib.id }, libraryOptionLabel(lib, libraries))))),
|
|
2440
3037
|
h(GraphPanel, { key: graphLibrary, call, libraryId: graphLibrary, files: graphFiles, t })))
|
|
2441
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
|
+
|
|
2442
3121
|
return h('div', { className: 'dshm-wall' },
|
|
2443
|
-
|
|
2444
|
-
[['memories', 'viewMemories'], ['relations', 'viewRelations']].map(([value, key]) =>
|
|
2445
|
-
h('button', {
|
|
2446
|
-
key: value, type: 'button', role: 'tab', 'aria-selected': view === value,
|
|
2447
|
-
className: `dshm-nav-item${view === value ? ' on' : ''}`,
|
|
2448
|
-
onClick: () => setView(value),
|
|
2449
|
-
}, t(key)))),
|
|
3122
|
+
wallTabGroup([['memories', t('viewMemories')], ['relations', t('viewRelations')]], view, setView, { className: 'dshm-wall-views' }),
|
|
2450
3123
|
error || data.errors?.length ? h('p', { className: 'dshm-error', role: 'alert' }, error || t('wallReadError')) : null,
|
|
2451
3124
|
view === 'relations'
|
|
2452
3125
|
? graphView
|
|
2453
3126
|
: h(React.Fragment, null,
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
3127
|
+
// 组分类:私有 / 全局(默认私有、私有靠前)。计数按各自的块算,切换即重置多选。
|
|
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
|
+
},
|
|
3144
|
+
// 多选只对私有块:全局条目只读,批量操作没有意义。
|
|
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 }),
|
|
2465
3151
|
visible.length ? h('div', { className: 'dshm-wall-grid' }, visible.map(item =>
|
|
2466
|
-
h('button', {
|
|
3152
|
+
h('button', {
|
|
3153
|
+
key: keyOf(item), type: 'button', className: 'dshm-wall-item' + (multi && selectedIds.has(keyOf(item)) ? ' is-selected' : ''),
|
|
3154
|
+
style: multi && selectedIds.has(keyOf(item)) ? { outline: '2px solid ' + V.accent, outlineOffset: -2 } : undefined,
|
|
3155
|
+
onClick: () => {
|
|
3156
|
+
// 多选模式下点卡片 = 勾选/取消勾选;正常模式打开抽屉。
|
|
3157
|
+
if (multi && scope === 'private') {
|
|
3158
|
+
setSelectedIds(prev => { const next = new Set(prev); if (next.has(keyOf(item))) next.delete(keyOf(item)); else next.add(keyOf(item)); return next })
|
|
3159
|
+
return
|
|
3160
|
+
}
|
|
3161
|
+
void openDetail(item)
|
|
3162
|
+
},
|
|
3163
|
+
},
|
|
3164
|
+
multi && scope === 'private' ? h(Tag, { tone: selectedIds.has(keyOf(item)) ? 'info' : null }, selectedIds.has(keyOf(item)) ? '✓' : '□') : null,
|
|
2467
3165
|
h(WallCover, { item }),
|
|
2468
3166
|
h('div', { className: 'dshm-wall-meta' }, h('span', null, item.libraryId ? item.libraryName : t('globalMemory')), h('span', null, enumText(t, LAYER_LABELS, item.layer))),
|
|
2469
3167
|
h('strong', null, item.title || item.id),
|
|
@@ -2478,6 +3176,419 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
2478
3176
|
detailView)
|
|
2479
3177
|
}
|
|
2480
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
|
+
|
|
2481
3592
|
function SettingsPage({ ctx, t, embedded, standalone = false }) {
|
|
2482
3593
|
const PAGE = {
|
|
2483
3594
|
overview: 0,
|
|
@@ -2490,8 +3601,14 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
2490
3601
|
prompts: 7,
|
|
2491
3602
|
stats: 8,
|
|
2492
3603
|
memories: 9,
|
|
3604
|
+
// logs:10 之前缺失:SETTINGS_PAGES 里有 'logs' 但 PAGE 没有对应索引,
|
|
3605
|
+
// 任何"跳到日志页"的入口都会错位到词典。补上,help 才能落在正确的 12。
|
|
3606
|
+
logs: 10,
|
|
3607
|
+
lexicon: 11,
|
|
3608
|
+
help: 12,
|
|
2493
3609
|
}
|
|
2494
3610
|
const call = useRpc(ctx)
|
|
3611
|
+
const sessionId = useCurrentSession(ctx)
|
|
2495
3612
|
const [state, setState] = React.useState(null)
|
|
2496
3613
|
const [prompts, setPrompts] = React.useState([])
|
|
2497
3614
|
const [promptsLoaded, setPromptsLoaded] = React.useState(false)
|
|
@@ -2505,6 +3622,8 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
2505
3622
|
const [busy, setBusy] = React.useState(false)
|
|
2506
3623
|
const [vectorOperation, setVectorOperation] = React.useState('')
|
|
2507
3624
|
const [vectorError, setVectorError] = React.useState('')
|
|
3625
|
+
// 全局记忆页"查看此记忆"的接力棒:带着 id 切到 MemU 记忆页,墙直接开对应抽屉。
|
|
3626
|
+
const [wallFocus, setWallFocus] = React.useState(null)
|
|
2508
3627
|
// Success messages need their own slot: reusing `error` would paint a good
|
|
2509
3628
|
// outcome in red and make the next real failure easy to miss.
|
|
2510
3629
|
const [notice, setNotice] = React.useState('')
|
|
@@ -2739,9 +3858,9 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
2739
3858
|
const loadReviews = React.useCallback(async () => {
|
|
2740
3859
|
try {
|
|
2741
3860
|
const [globalQueue, privateQueue] = await Promise.all([
|
|
2742
|
-
call('listReviews', {}),
|
|
3861
|
+
call('listReviews', sessionId ? { sessionId } : {}),
|
|
2743
3862
|
browseLibId
|
|
2744
|
-
? call('listReviews', { libraryId: browseLibId }).catch(() => ({ items: [] }))
|
|
3863
|
+
? call('listReviews', { libraryId: browseLibId, sessionId: sessionId || undefined }).catch(() => ({ items: [] }))
|
|
2745
3864
|
: Promise.resolve({ items: [] }),
|
|
2746
3865
|
])
|
|
2747
3866
|
setReviews([...(privateQueue?.items || []), ...(globalQueue?.items || [])])
|
|
@@ -2848,13 +3967,13 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
2848
3967
|
React.useEffect(() => {
|
|
2849
3968
|
if (page !== PAGE.browser || !browseLibId) return undefined
|
|
2850
3969
|
let alive = true
|
|
2851
|
-
void call('browseLibrary', { libraryId: browseLibId }).then(result => {
|
|
3970
|
+
void call('browseLibrary', { libraryId: browseLibId, sessionId: sessionId || undefined }).then(result => {
|
|
2852
3971
|
if (!alive) return
|
|
2853
3972
|
setBrowseFiles(result.files || [])
|
|
2854
3973
|
setBrowseSource(result.source || '')
|
|
2855
3974
|
}).catch(error => { if (alive) setError(String(error.message || error)) })
|
|
2856
3975
|
return () => { alive = false }
|
|
2857
|
-
}, [call, page, browseLibId, state?.revision, state?.stamp])
|
|
3976
|
+
}, [call, page, browseLibId, sessionId, state?.revision, state?.stamp])
|
|
2858
3977
|
// Statistics are per library and read the whole index for the scatter, so they
|
|
2859
3978
|
// load only when that page is open. Every settings refresh (a heartbeat heard a
|
|
2860
3979
|
// write) re-reads them, which is what keeps the page honest.
|
|
@@ -3119,8 +4238,15 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
3119
4238
|
payload.pinReason = 'user_explicit_note'
|
|
3120
4239
|
payload.pinSource = 'user_explicit'
|
|
3121
4240
|
}
|
|
3122
|
-
await call('addMemory', payload)
|
|
4241
|
+
const created = await call('addMemory', payload)
|
|
4242
|
+
// 事实级去重:几乎相同的重写被拒。表单留着、
|
|
4243
|
+
// 说明给足,否则用户看到的是"点了没反应"。
|
|
4244
|
+
if (created?.deduped === true) {
|
|
4245
|
+
setError(t('dedupedNotice').replace('{id}', created.existingId || '?'))
|
|
4246
|
+
return
|
|
4247
|
+
}
|
|
3123
4248
|
notifyMemoryWrite('memory', browseLibId)
|
|
4249
|
+
setNotice(t('memorySaved'))
|
|
3124
4250
|
setNewMem({ title: '', content: '', layer: 'long_term', salientDate: '' })
|
|
3125
4251
|
await openBrowser(browseLibId)
|
|
3126
4252
|
} catch (e) { setError(String(e?.message || e)) } finally { setBrowseBusy(false) }
|
|
@@ -3132,6 +4258,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
3132
4258
|
try {
|
|
3133
4259
|
await call('updateMemory', { libraryId: browseLibId, id, patch })
|
|
3134
4260
|
notifyMemoryWrite('memory', browseLibId)
|
|
4261
|
+
setNotice(t('memorySaved'))
|
|
3135
4262
|
await openBrowser(browseLibId)
|
|
3136
4263
|
} catch (e) { setError(String(e?.message || e)) } finally { setBrowseBusy(false) }
|
|
3137
4264
|
}
|
|
@@ -3140,6 +4267,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
3140
4267
|
try {
|
|
3141
4268
|
await call('archiveMemory', { libraryId: browseLibId, id })
|
|
3142
4269
|
notifyMemoryWrite('memory', browseLibId)
|
|
4270
|
+
setNotice(t('memoryArchived'))
|
|
3143
4271
|
await openBrowser(browseLibId)
|
|
3144
4272
|
} catch (e) { setError(String(e?.message || e)) } finally { setBrowseBusy(false) }
|
|
3145
4273
|
}
|
|
@@ -3148,6 +4276,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
3148
4276
|
try {
|
|
3149
4277
|
await call('updateMemory', { libraryId: browseLibId, id, patch: { status: 'active' } })
|
|
3150
4278
|
notifyMemoryWrite('memory', browseLibId)
|
|
4279
|
+
setNotice(t('memoryRestored'))
|
|
3151
4280
|
await openBrowser(browseLibId)
|
|
3152
4281
|
} catch (e) { setError(String(e?.message || e)) } finally { setBrowseBusy(false) }
|
|
3153
4282
|
}
|
|
@@ -3157,6 +4286,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
3157
4286
|
try {
|
|
3158
4287
|
await call('deleteMemory', { libraryId: browseLibId, id })
|
|
3159
4288
|
notifyMemoryWrite('memory', browseLibId)
|
|
4289
|
+
setNotice(t('memoryDeleted'))
|
|
3160
4290
|
setDeleteArmed('')
|
|
3161
4291
|
await openBrowser(browseLibId)
|
|
3162
4292
|
} catch (e) { setError(String(e?.message || e)) } finally { setBrowseBusy(false) }
|
|
@@ -3169,6 +4299,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
3169
4299
|
action,
|
|
3170
4300
|
resolution: reviewDrafts[item.id]?.resolution || 'replace',
|
|
3171
4301
|
reason: reviewDrafts[item.id]?.reason || '',
|
|
4302
|
+
sessionId: sessionId || undefined,
|
|
3172
4303
|
...(item.libraryId ? { libraryId: item.libraryId } : {}),
|
|
3173
4304
|
})
|
|
3174
4305
|
notifyMemoryWrite('review', item.libraryId || null)
|
|
@@ -3178,32 +4309,63 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
3178
4309
|
if (item.libraryId && browseLibId === item.libraryId) await openBrowser(item.libraryId)
|
|
3179
4310
|
} catch (e) { setError(String(e?.message || e)) } finally { setBusy(false) }
|
|
3180
4311
|
}
|
|
3181
|
-
|
|
3182
|
-
|
|
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
|
|
3183
4343
|
setBrowseBusy(true)
|
|
3184
4344
|
try {
|
|
3185
|
-
const data = await call('exportLibrary', { libraryId:
|
|
4345
|
+
const data = await call('exportLibrary', { libraryId, sessionId: sessionId || undefined, includeAttachments, delivery: includeAttachments ? 'download' : undefined })
|
|
3186
4346
|
if (data.downloadUrl) {
|
|
3187
4347
|
const link = document.createElement('a')
|
|
3188
4348
|
link.href = data.downloadUrl; link.download = data.filename; link.click()
|
|
3189
|
-
|
|
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)
|
|
3190
4359
|
}
|
|
3191
|
-
|
|
3192
|
-
? new Blob([Uint8Array.from(atob(data.base64), char => char.charCodeAt(0))], { type: 'application/zip' })
|
|
3193
|
-
: new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
|
3194
|
-
const url = URL.createObjectURL(blob)
|
|
3195
|
-
const a = document.createElement('a')
|
|
3196
|
-
a.href = url
|
|
3197
|
-
a.download = data.filename || `dsh-memu-memory-${browseLibId}.json`
|
|
3198
|
-
a.click()
|
|
3199
|
-
URL.revokeObjectURL(url)
|
|
4360
|
+
setNotice(t('exportStarted').replace('{file}', data.filename || ''))
|
|
3200
4361
|
} catch (e) { setError(String(e?.message || e)) } finally { setBrowseBusy(false) }
|
|
3201
4362
|
}
|
|
3202
4363
|
|
|
3203
4364
|
// Import reads the file in the browser and hands the text to the server: the
|
|
3204
4365
|
// host already owns the state directory, so no path guessing is needed.
|
|
3205
|
-
|
|
3206
|
-
|
|
4366
|
+
// 同一契约:payload 与 ZIP 上传 URL 都用**传入**的 libraryId,成功后回到该库。
|
|
4367
|
+
const importLibraryNow = ({ libraryId = browseLibId } = {}) => {
|
|
4368
|
+
if (!libraryId) return
|
|
3207
4369
|
const input = document.createElement('input')
|
|
3208
4370
|
input.type = 'file'
|
|
3209
4371
|
input.accept = 'application/json,.json,application/zip,.zip'
|
|
@@ -3214,10 +4376,11 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
3214
4376
|
setNotice('')
|
|
3215
4377
|
setError('')
|
|
3216
4378
|
try {
|
|
3217
|
-
const payload = { libraryId:
|
|
4379
|
+
const payload = { libraryId, sessionId: sessionId || undefined }
|
|
3218
4380
|
let res
|
|
3219
4381
|
if (file.name.toLowerCase().endsWith('.zip')) {
|
|
3220
|
-
const
|
|
4382
|
+
const sessionQuery = sessionId ? '?sessionId=' + encodeURIComponent(sessionId) : ''
|
|
4383
|
+
const response = await fetch('/dsh-memu-memory/import/' + encodeURIComponent(libraryId) + '.zip' + sessionQuery, {
|
|
3221
4384
|
method: 'POST', credentials: 'same-origin', body: file, headers: { 'content-type': 'application/zip' },
|
|
3222
4385
|
})
|
|
3223
4386
|
const result = await response.json()
|
|
@@ -3234,8 +4397,8 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
3234
4397
|
const adjusted = c.downgraded || 0
|
|
3235
4398
|
setNotice(`${t('importDone')}: +${res.imported || 0} / −${skipped}${adjusted ? ` · ${t('importAdjusted')}: ${adjusted}` : ''}`)
|
|
3236
4399
|
if (res.engineWarning) setError(`${t('importEngineWarning')}: ${res.engineWarning}`)
|
|
3237
|
-
notifyMemoryWrite('import',
|
|
3238
|
-
await openBrowser(
|
|
4400
|
+
notifyMemoryWrite('import', libraryId)
|
|
4401
|
+
await openBrowser(libraryId)
|
|
3239
4402
|
await refresh()
|
|
3240
4403
|
} catch (e) { setError(String(e?.message || e)) } finally { setBrowseBusy(false) }
|
|
3241
4404
|
}
|
|
@@ -3869,15 +5032,51 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
3869
5032
|
? h(Empty, null, t('noArchived'))
|
|
3870
5033
|
: h('div', { style: S.list }, (state.archivedLibraries || []).map(renderArchivedItem))))
|
|
3871
5034
|
|
|
3872
|
-
const renderGlobalItem = (f) =>
|
|
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 },
|
|
3873
5043
|
h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 8 } },
|
|
3874
5044
|
h('strong', null, f.title || f.id),
|
|
3875
|
-
h('div', { style: { display: 'flex', gap: 6 } },
|
|
5045
|
+
h('div', { style: { display: 'flex', gap: 6, flexShrink: 0 } },
|
|
3876
5046
|
h(Tag, null, domainText(t, f.domain)),
|
|
3877
|
-
h(Tag, null, `conf ${f.confidence ?? '?'}`)
|
|
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,
|
|
5059
|
+
// 查看此记忆:切到 MemU 记忆页的全局块并直接打开对应条目的抽屉。
|
|
5060
|
+
h(Button, {
|
|
5061
|
+
style: S.ghost,
|
|
5062
|
+
title: t('viewMemory'),
|
|
5063
|
+
onClick: () => { setWallFocus({ id: f.id, libraryId: null, title: f.title || f.id, at: Date.now() }); setPage(PAGE.memories) },
|
|
5064
|
+
}, t('viewMemory')))),
|
|
3878
5065
|
h('div', { style: { ...S.muted, marginTop: 4, whiteSpace: 'pre-wrap' } },
|
|
3879
|
-
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
|
|
3880
5078
|
)
|
|
5079
|
+
}
|
|
3881
5080
|
|
|
3882
5081
|
const renderSessionChoice = (s) => h('label', {
|
|
3883
5082
|
key: s.id,
|
|
@@ -3893,28 +5092,48 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
3893
5092
|
h('div', { style: S.muted }, `${s.cwd || '—'} · ${s.agentPreset || 'general'}`))
|
|
3894
5093
|
)
|
|
3895
5094
|
|
|
3896
|
-
const renderReviewItem = (r) =>
|
|
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 },
|
|
3897
5106
|
h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center' } },
|
|
3898
5107
|
h('div', { style: { minWidth: 0 } },
|
|
3899
5108
|
h('strong', null, r.title || r.id),
|
|
3900
5109
|
h('div', { style: S.row },
|
|
3901
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,
|
|
3902
5112
|
r.reviewReason ? h(Tag, null, `${t('reviewReason')}: ${r.reviewReason}`) : null,
|
|
3903
5113
|
typeof r.confidence === 'number' ? h(Tag, null, `conf ${r.confidence}`) : null)),
|
|
3904
5114
|
h('div', { style: { display: 'flex', gap: 6, flexShrink: 0 } },
|
|
3905
5115
|
h(Button, { onClick: () => resolveReview(r, 'accept'), disabled: busy, primary: true }, t('accept')),
|
|
3906
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,
|
|
3907
5122
|
h('div', { style: { ...S.muted, marginTop: 6, whiteSpace: 'pre-wrap' } }, String(r.content || '').slice(0, 800)),
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
h('
|
|
3912
|
-
|
|
3913
|
-
|
|
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,
|
|
3914
5132
|
(r.evidence || []).length > 0
|
|
3915
5133
|
? h('div', { style: { ...S.muted, marginTop: 6, lineHeight: 1.6 } },
|
|
3916
5134
|
r.evidence.map((e, i) => h('div', { key: `${r.id}-ev-${i}` }, `· ${e.sessionId}:${String(e.quote || '').slice(0, 160)}`)))
|
|
3917
5135
|
: null)
|
|
5136
|
+
}
|
|
3918
5137
|
|
|
3919
5138
|
const globalBlock = h(React.Fragment, null,
|
|
3920
5139
|
h(Section, {
|
|
@@ -3944,7 +5163,17 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
3944
5163
|
job ? h('div', { style: { ...S.muted, marginTop: 8 } },
|
|
3945
5164
|
`${job.status}${job.step ? ` · ${job.step}` : ''}`,
|
|
3946
5165
|
job.error ? h('div', { style: { color: V.danger } }, job.error) : null) : null
|
|
3947
|
-
)
|
|
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')))))
|
|
3948
5177
|
)
|
|
3949
5178
|
|
|
3950
5179
|
const renderPromptItem = (p) => h('div', { key: p.key, style: S.item },
|
|
@@ -4189,15 +5418,16 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
4189
5418
|
right: h('div', { style: { display: 'flex', gap: 8, flexShrink: 0 } },
|
|
4190
5419
|
h(Button, { onClick: () => importLibraryNow(), disabled: !browseLibId || browseBusy, style: S.ghost, title: t('importHint') }, t('importJson')),
|
|
4191
5420
|
h(Button, { onClick: () => exportLibraryNow(), disabled: !browseLibId, style: S.ghost }, t('export')),
|
|
4192
|
-
h(Button, { onClick: () => exportLibraryNow(true), disabled: !browseLibId, title: t('
|
|
5421
|
+
h(Button, { onClick: () => exportLibraryNow({ includeAttachments: true }), disabled: !browseLibId, title: t('exportZip') }, t('exportZip')),
|
|
4193
5422
|
h(Button, { onClick: () => openBrowser(browseLibId), disabled: busy || !browseLibId, primary: true }, t('reload'))),
|
|
4194
5423
|
},
|
|
4195
5424
|
h('div', { style: S.row },
|
|
4196
5425
|
h('span', { style: S.label }, t('currentLibrary')),
|
|
4197
5426
|
h('select', {
|
|
4198
5427
|
style: { ...S.input, flex: 1 }, value: browseLibId,
|
|
5428
|
+
title: (state.libraries || []).find((lib) => lib.id === browseLibId)?.scopeKey || t('currentLibrary'),
|
|
4199
5429
|
onChange: (e) => setBrowseLibId(e.target.value),
|
|
4200
|
-
}, (state.libraries || []).map((l) => h('option', { key: l.id, value: l.id },
|
|
5430
|
+
}, (state.libraries || []).map((l) => h('option', { key: l.id, value: l.id }, libraryOptionLabel(l, state.libraries || []))))),
|
|
4201
5431
|
browseSource === 'index'
|
|
4202
5432
|
? h('div', { style: { ...S.muted, marginTop: 6 } }, t('browserIndexOnly'))
|
|
4203
5433
|
: null,
|
|
@@ -4342,8 +5572,9 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
4342
5572
|
h('select', {
|
|
4343
5573
|
style: { ...S.input, flex: 1 },
|
|
4344
5574
|
value: statsLibId,
|
|
5575
|
+
title: (state.libraries || []).find((lib) => lib.id === statsLibId)?.scopeKey || t('currentLibrary'),
|
|
4345
5576
|
onChange: (e) => setStatsLibId(e.target.value),
|
|
4346
|
-
}, (state.libraries || []).map((l) => h('option', { key: l.id, value: l.id },
|
|
5577
|
+
}, (state.libraries || []).map((l) => h('option', { key: l.id, value: l.id }, libraryOptionLabel(l, state.libraries || []))))),
|
|
4347
5578
|
!statsLibId
|
|
4348
5579
|
? h(Empty, null, t('statsNoLibrary'))
|
|
4349
5580
|
: !statsData
|
|
@@ -4455,12 +5686,149 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
4455
5686
|
]
|
|
4456
5687
|
pageDefs.push({ label: t('pageMemories'), overline: 'MEMU', desc: t('descMemories'), icon: GlyphLayers })
|
|
4457
5688
|
pageDefs.push({ label: t('pageLogs'), overline: 'ACTIVITY', desc: t('descLogs'), icon: GlyphText })
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
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')))))
|
|
4464
5832
|
|
|
4465
5833
|
const LOG_KIND_LABELS = {
|
|
4466
5834
|
recall: t('logKindRecall'), write: t('logKindWrite'), consolidate: t('logKindConsolidate'),
|
|
@@ -4482,7 +5850,12 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
4482
5850
|
h(Button, {
|
|
4483
5851
|
style: S.ghost,
|
|
4484
5852
|
onClick: async () => {
|
|
4485
|
-
try {
|
|
5853
|
+
try {
|
|
5854
|
+
await call('clearActivityLog')
|
|
5855
|
+
// 跨面板即时生效:侧边栏的整理提醒不用等下一个 20s 轮询。
|
|
5856
|
+
broadcastMemoryWrite('logs-cleared')
|
|
5857
|
+
await loadLogs()
|
|
5858
|
+
} catch (error) { setLogNotice(String(error?.message || error)) }
|
|
4486
5859
|
},
|
|
4487
5860
|
}, t('logClear'))),
|
|
4488
5861
|
},
|
|
@@ -4515,8 +5888,14 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
4515
5888
|
h('div', { style: { display: 'flex', gap: 6, alignItems: 'center', minWidth: 0, flexWrap: 'wrap' } },
|
|
4516
5889
|
h(Tag, { tone: row.level === 'error' ? 'warn' : undefined }, LOG_KIND_LABELS[row.kind] || row.kind),
|
|
4517
5890
|
h('span', { style: S.muted }, new Date(row.at).toLocaleString()),
|
|
4518
|
-
|
|
4519
|
-
|
|
5891
|
+
// 整理/提炼日志挂上会话标题与文件夹名,裸 id 只做回退:日志页要回答
|
|
5892
|
+
// "这是哪个对话、哪个库",光靠 sessionId 后 10 位查不动。
|
|
5893
|
+
row.sessionTitle
|
|
5894
|
+
? h('span', { style: { fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 260 }, title: row.sessionId || '' }, row.sessionTitle)
|
|
5895
|
+
: row.sessionId ? h('span', { style: S.muted }, row.sessionId.slice(-10)) : null,
|
|
5896
|
+
row.libraryName
|
|
5897
|
+
? h(Tag, null, row.libraryName)
|
|
5898
|
+
: row.libraryId ? h('span', { style: S.muted }, row.libraryId) : null),
|
|
4520
5899
|
h('div', { style: { display: 'flex', gap: 6, flexShrink: 0 } },
|
|
4521
5900
|
row.libraryId ? h(Button, {
|
|
4522
5901
|
style: S.ghost,
|
|
@@ -4537,7 +5916,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
4537
5916
|
row.detail
|
|
4538
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))
|
|
4539
5918
|
: null)))))
|
|
4540
|
-
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]
|
|
4541
5920
|
|
|
4542
5921
|
// Settings uses a left nav with a sliding indicator (the sidebar keeps its
|
|
4543
5922
|
// top segments). The indicator is one absolutely-positioned element moved by
|
|
@@ -4598,6 +5977,65 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
4598
5977
|
return h(MemoryTab, { ctx, t, sessionId })
|
|
4599
5978
|
}
|
|
4600
5979
|
|
|
5980
|
+
// 整理提醒:跨会话(不按当前对话过滤),放在记忆设置卡之下、整理记录之上。
|
|
5981
|
+
// 默认收起只显示最新一条(有失败优先失败),点头部展开成失败/成功两列(各最新 6 条)。
|
|
5982
|
+
// 失败列带"清除失败记录":只删 consolidate 且 ok=false 的日志行,成功流水不动。
|
|
5983
|
+
function ConsolidationAlerts({ feed, t, onJump, onClear }) {
|
|
5984
|
+
const [open, setOpen] = React.useState(false)
|
|
5985
|
+
if (!feed) return null
|
|
5986
|
+
const failures = Array.isArray(feed.failures) ? feed.failures : []
|
|
5987
|
+
const successes = Array.isArray(feed.successes) ? feed.successes : []
|
|
5988
|
+
if (failures.length === 0 && successes.length === 0) return null
|
|
5989
|
+
const shortId = (id) => String(id || '').slice(-8)
|
|
5990
|
+
const label = (row) => row.sessionTitle || shortId(row.sessionId)
|
|
5991
|
+
const when = (row) => {
|
|
5992
|
+
try { return new Date(row.at).toLocaleString([], { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) } catch { return row.at }
|
|
5993
|
+
}
|
|
5994
|
+
const rowEl = (row, tone) => h('div', { key: row.id, style: { minWidth: 0 } },
|
|
5995
|
+
h('div', { style: { display: 'flex', gap: 6, alignItems: 'center', minWidth: 0 } },
|
|
5996
|
+
h(Tag, { tone: tone === 'fail' ? 'warn' : undefined }, tone === 'fail' ? t('consolidateFailed') : t('consolidateDone')),
|
|
5997
|
+
h('span', { style: { fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, flex: 1 }, title: row.message }, label(row)),
|
|
5998
|
+
h('span', { style: { ...S.muted, flexShrink: 0, fontSize: 11 } }, when(row)),
|
|
5999
|
+
tone === 'fail' ? h(Button, {
|
|
6000
|
+
style: { ...S.ghost, padding: '1px 7px', flexShrink: 0 },
|
|
6001
|
+
title: t('consolidateJumpHint'),
|
|
6002
|
+
onClick: () => onJump(row),
|
|
6003
|
+
}, '↗') : null),
|
|
6004
|
+
tone === 'fail' && row.message
|
|
6005
|
+
? h('div', { style: { ...S.muted, fontSize: 11, marginTop: 1, overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' } }, row.message)
|
|
6006
|
+
: null)
|
|
6007
|
+
const column = (title, rows, tone) => h('div', { style: { minWidth: 0, flex: 1 } },
|
|
6008
|
+
h('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 } },
|
|
6009
|
+
h('span', { style: { ...S.muted, fontWeight: 600, color: tone === 'fail' ? V.danger : undefined } }, title),
|
|
6010
|
+
tone === 'fail' && failures.length > 0 ? h(Button, {
|
|
6011
|
+
style: { ...S.ghost, padding: '1px 6px', fontSize: 11, flexShrink: 0 },
|
|
6012
|
+
title: t('consolidateClearHint'),
|
|
6013
|
+
onClick: onClear,
|
|
6014
|
+
}, t('consolidateClear')) : null),
|
|
6015
|
+
rows.length === 0
|
|
6016
|
+
? h('div', { style: S.muted }, '—')
|
|
6017
|
+
: h('div', { style: { display: 'flex', flexDirection: 'column', gap: 5 } }, rows.map(row => rowEl(row, tone))))
|
|
6018
|
+
const latest = failures.length > 0 ? failures[0] : successes[0]
|
|
6019
|
+
const latestTone = failures.length > 0 ? 'fail' : 'ok'
|
|
6020
|
+
const head = h('button', {
|
|
6021
|
+
type: 'button',
|
|
6022
|
+
'aria-expanded': open,
|
|
6023
|
+
title: open ? t('consolidateCollapse') : t('consolidateExpand'),
|
|
6024
|
+
onClick: () => setOpen(v => !v),
|
|
6025
|
+
style: { display: 'flex', gap: 8, alignItems: 'center', width: '100%', padding: 0, border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left', color: 'inherit', font: 'inherit' },
|
|
6026
|
+
},
|
|
6027
|
+
h('span', { style: { fontWeight: 600, fontSize: 12.5 } }, t('consolidateFeedTitle')),
|
|
6028
|
+
failures.length > 0 ? h(Tag, { tone: 'warn' }, t('consolidateFailedCount').replace('{n}', String(failures.length))) : null,
|
|
6029
|
+
h('span', { style: { ...S.muted, marginLeft: 'auto', fontSize: 11 } }, open ? t('consolidateCollapse') : t('consolidateExpand')))
|
|
6030
|
+
return h('div', { style: { ...S.section, padding: '10px 12px', marginTop: 12 }, role: 'status' },
|
|
6031
|
+
head,
|
|
6032
|
+
open
|
|
6033
|
+
? h('div', { style: { display: 'flex', gap: 14, marginTop: 7 } },
|
|
6034
|
+
column(t('consolidateFailedCol'), failures, 'fail'),
|
|
6035
|
+
column(t('consolidateOkCol'), successes, 'ok'))
|
|
6036
|
+
: h('div', { style: { marginTop: 7 } }, rowEl(latest, latestTone)))
|
|
6037
|
+
}
|
|
6038
|
+
|
|
4601
6039
|
function MemoryTab({ ctx, t, sessionId }) {
|
|
4602
6040
|
const call = useRpc(ctx)
|
|
4603
6041
|
const sessionsState = useSessionList(ctx)
|
|
@@ -4668,14 +6106,6 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
4668
6106
|
}, [sessionId, tabPage, sessionsLoaded, loaded, loadSidebarSessions])
|
|
4669
6107
|
|
|
4670
6108
|
React.useEffect(() => { void refresh() }, [refresh])
|
|
4671
|
-
// Instant path: another pane of this window wrote a memory (browser add /
|
|
4672
|
-
// save / archive / import, review resolution). Measured in milliseconds,
|
|
4673
|
-
// not in "reopen the tab".
|
|
4674
|
-
React.useEffect(() => {
|
|
4675
|
-
const onWrite = () => { void refresh() }
|
|
4676
|
-
memoryWriteListeners.add(onWrite)
|
|
4677
|
-
return () => { memoryWriteListeners.delete(onWrite) }
|
|
4678
|
-
}, [refresh])
|
|
4679
6109
|
// Slow path: writes we cannot hear about — the model calling
|
|
4680
6110
|
// memu_private_remember mid-turn, a curator job, a second window. The shared
|
|
4681
6111
|
// beat polls the cheap marker and refetches only when it moved; a hidden
|
|
@@ -4727,7 +6157,49 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
4727
6157
|
return () => clearInterval(timer)
|
|
4728
6158
|
}, [busy, sessionId, loadRunLog])
|
|
4729
6159
|
|
|
4730
|
-
|
|
6160
|
+
// 整理提醒(跨会话):挂载即拉一次,之后慢轮询;手动整理结束/进行中时靠 loadRunLog
|
|
6161
|
+
// 的轮询节拍顺带刷新,不在这里叠加高频轮询。不依赖 sessionId —— 没选中对话也要显示。
|
|
6162
|
+
const [feed, setFeed] = React.useState(null)
|
|
6163
|
+
const loadFeed = React.useCallback(async () => {
|
|
6164
|
+
try { setFeed(await call('getConsolidationFeed', { limit: 6 })) } catch {}
|
|
6165
|
+
}, [call])
|
|
6166
|
+
React.useEffect(() => {
|
|
6167
|
+
void loadFeed()
|
|
6168
|
+
// unref:浏览器里没有这个方法(可选调用跳过),Node 测试环境里别让轮询拖住进程退出。
|
|
6169
|
+
const timer = setInterval(() => { void loadFeed() }, 20000)
|
|
6170
|
+
try { timer.unref?.() } catch {}
|
|
6171
|
+
return () => clearInterval(timer)
|
|
6172
|
+
}, [loadFeed])
|
|
6173
|
+
React.useEffect(() => {
|
|
6174
|
+
if (!busy) return undefined
|
|
6175
|
+
const timer = setInterval(() => { void loadFeed() }, 4000)
|
|
6176
|
+
try { timer.unref?.() } catch {}
|
|
6177
|
+
return () => clearInterval(timer)
|
|
6178
|
+
}, [busy, loadFeed])
|
|
6179
|
+
// Instant path: another pane wrote a memory — or the WebUI cleared the
|
|
6180
|
+
// activity log (kind: 'logs-cleared'): the consolidation feed must empty
|
|
6181
|
+
// the same instant instead of waiting for the next 20s poll.
|
|
6182
|
+
React.useEffect(() => {
|
|
6183
|
+
const onWrite = () => { void refresh(); void loadFeed() }
|
|
6184
|
+
memoryWriteListeners.add(onWrite)
|
|
6185
|
+
return () => { memoryWriteListeners.delete(onWrite) }
|
|
6186
|
+
}, [refresh, loadFeed])
|
|
6187
|
+
const jumpToLibrary = React.useCallback((row) => {
|
|
6188
|
+
// 对应记忆界面 = 这条记忆所在的库;没带 libraryId(全局/旧事件)就落到总览。
|
|
6189
|
+
openWebUiPage(row?.libraryId ? 'browser' : 'memories', { libraryId: row?.libraryId || null })
|
|
6190
|
+
}, [])
|
|
6191
|
+
const clearFailures = React.useCallback(async () => {
|
|
6192
|
+
// 只删 consolidate 且 ok=false 的行;成功流水与其余日志不动。结果直接回填 feed。
|
|
6193
|
+
try {
|
|
6194
|
+
const result = await call('clearConsolidationFailures')
|
|
6195
|
+
if (result?.cleared) setNotice(t('consolidateCleared').replace('{n}', String(result.cleared)))
|
|
6196
|
+
if (result?.feed) setFeed(result.feed)
|
|
6197
|
+
} catch (e) { setError(String(e?.message || e)) }
|
|
6198
|
+
}, [call, t])
|
|
6199
|
+
const alertBlock = h(ConsolidationAlerts, { feed, t, onJump: jumpToLibrary, onClear: clearFailures })
|
|
6200
|
+
|
|
6201
|
+
// (保持单行提前 return:tests/hook-order.test.mjs 以这一行锚定 hook 顺序守卫。)
|
|
6202
|
+
if (!sessionId) return h('div', { style: S.tab }, h('div', { style: { ...S.tab, padding: '12px' } }, t('loading')))
|
|
4731
6203
|
const binding = state?.binding
|
|
4732
6204
|
const summary = sessionsState.byId?.[sessionId]
|
|
4733
6205
|
const scopeKey = String(binding?.scopeKey || '')
|
|
@@ -4839,11 +6311,25 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
4839
6311
|
.map((d) => h('option', { key: d, value: d }, domainText(t, d))))),
|
|
4840
6312
|
h('div', { style: { display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' } },
|
|
4841
6313
|
h(Button, { onClick: organize, disabled: busy, primary: true }, t('organize'))),
|
|
6314
|
+
// 整理提醒在记忆设置卡之下、整理记录之上:跨会话的失败提醒离整理入口最近,
|
|
6315
|
+
// 但不再压住页头。数据不按当前对话过滤,展开后失败/成功两列各看最新 6 条。
|
|
6316
|
+
alertBlock,
|
|
4842
6317
|
h('div', { style: { marginTop: 12 } },
|
|
4843
|
-
h('
|
|
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') + ' ↗')),
|
|
4844
6330
|
runLog.length === 0
|
|
4845
6331
|
? h('div', { style: S.muted }, t('runLogEmpty'))
|
|
4846
|
-
: h('div', { style: S.list }, runLog.slice(0,
|
|
6332
|
+
: h('div', { style: S.list }, runLog.slice(0, 5).map(renderRunRow)))),
|
|
4847
6333
|
// 作用域放在控制卡之后:它是一组低频字段,先让用户看到记忆开关和整理入口。
|
|
4848
6334
|
h(ContextScope, { binding, sessionId, call, t })
|
|
4849
6335
|
)
|
|
@@ -4952,7 +6438,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
4952
6438
|
}, p.label)))),
|
|
4953
6439
|
error ? h('div', { style: { ...S.muted, padding: '8px 12px 0', color: V.danger } }, error) : null,
|
|
4954
6440
|
notice ? h('div', { style: { ...S.muted, padding: '8px 12px 0' } }, notice) : null,
|
|
4955
|
-
h('div', { style: S.tabBody }, pages[tabPage]?.node || null))
|
|
6441
|
+
h('div', { className: 'dshm-tab-body', style: S.tabBody }, pages[tabPage]?.node || null))
|
|
4956
6442
|
}
|
|
4957
6443
|
|
|
4958
6444
|
// One card = one pair of switches. The card the user clicks, the binding
|
|
@@ -5219,8 +6705,11 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
5219
6705
|
// scope was written and the folder policy checked both switches.
|
|
5220
6706
|
h('label', { style: S.row }, h('input', { type: 'radio', name: 'memu-library', checked: pick === 'folder', onChange: () => setPick('folder') }), t('folderLibrary')),
|
|
5221
6707
|
h('label', { style: S.row }, h('input', { type: 'radio', name: 'memu-library', checked: pick === 'existing', onChange: () => setPick('existing') }), t('chooseLibrary')),
|
|
5222
|
-
pick === 'existing' ? h('select', {
|
|
5223
|
-
|
|
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,
|
|
5224
6713
|
h('label', { style: S.row }, h('input', { type: 'radio', name: 'memu-library', checked: pick === 'new', onChange: () => setPick('new') }), t('createLibrary')),
|
|
5225
6714
|
pick === 'new' ? h('input', { style: { ...S.input, marginLeft: 24 }, placeholder: t('name'), value: newName, onChange: (e) => setNewName(e.target.value) }) : null)),
|
|
5226
6715
|
h('label', { style: { ...S.row, marginTop: 12 } }, h('input', { type: 'checkbox', checked: dontShow, onChange: (e) => setDontShow(e.target.checked) }), t('rememberChoice')))
|
|
@@ -5238,7 +6727,11 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
|
|
|
5238
6727
|
body: JSON.stringify({ type: 'client-request', rpcId: 'standalone-' + (++sequence), method: endpoint, payload: payload || {} }),
|
|
5239
6728
|
})
|
|
5240
6729
|
const envelope = await response.json()
|
|
5241
|
-
if (!envelope.result)
|
|
6730
|
+
if (!envelope.result) {
|
|
6731
|
+
// 服务端错误信封(如 403 forbidden)带可读 message:透传它而不是
|
|
6732
|
+
// 只报 HTTP 状态码,用户才能看到「为什么」。
|
|
6733
|
+
throw new Error(envelope.error?.message || ('Invalid RPC response: ' + response.status))
|
|
6734
|
+
}
|
|
5242
6735
|
return envelope.result
|
|
5243
6736
|
} } },
|
|
5244
6737
|
}
|