@modusensus/dsh-mneme 0.7.9 → 0.7.11
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/README.en.md +368 -0
- package/README.md +298 -167
- package/{dsh-mneme/cordis.patch.yml → cordis.patch.yml} +1 -0
- package/{dsh-mneme/lib → lib}/api.js +58 -284
- package/lib/client.js +2086 -0
- package/{dsh-mneme/src → lib}/config.js +6 -100
- package/{dsh-mneme/lib → lib}/dream/decisions.js +94 -143
- package/{dsh-mneme/src → lib}/dream/sleep.js +12 -152
- package/{dsh-mneme/lib → lib}/dream.js +22 -168
- package/{dsh-mneme/src → lib}/hot-memory.js +0 -7
- package/{dsh-mneme/src → lib}/index.js +6 -34
- package/{dsh-mneme/lib → lib}/inject.js +11 -53
- package/{dsh-mneme/src → lib}/mirror.js +1 -12
- package/{dsh-mneme/lib → lib}/quality-filter.js +1 -3
- package/{dsh-mneme/lib → lib}/reranker.js +1 -4
- package/{dsh-mneme/lib → lib}/service.js +20 -410
- package/{dsh-mneme/lib → lib}/settings.js +0 -70
- package/{dsh-mneme/lib → lib}/store.js +35 -450
- package/{dsh-mneme/lib → lib}/summarize.js +10 -10
- package/lib/tools.js +274 -0
- package/package.json +41 -19
- package/{dsh-mneme/scripts → scripts}/e2e-dsh.js +3 -3
- package/{dsh-mneme/scripts → scripts}/sync-lib.js +5 -10
- package/{dsh-mneme/src → src}/api.js +58 -284
- package/{dsh-mneme/lib → src}/config.js +6 -100
- package/{dsh-mneme/src → src}/dream/decisions.js +94 -143
- package/{dsh-mneme/lib → src}/dream/sleep.js +12 -152
- package/{dsh-mneme/src → src}/dream.js +22 -168
- package/{dsh-mneme/lib → src}/hot-memory.js +0 -7
- package/{dsh-mneme/lib → src}/index.js +6 -34
- package/{dsh-mneme/src → src}/inject.js +11 -53
- package/{dsh-mneme/lib → src}/mirror.js +1 -12
- package/{dsh-mneme/src → src}/quality-filter.js +1 -3
- package/{dsh-mneme/src → src}/reranker.js +1 -4
- package/{dsh-mneme/src → src}/service.js +20 -410
- package/{dsh-mneme/src → src}/settings.js +0 -70
- package/{dsh-mneme/src → src}/store.js +35 -450
- package/{dsh-mneme/src → src}/summarize.js +10 -10
- package/src/tools.js +274 -0
- package/{dsh-mneme/test → test}/api.test.js +1 -156
- package/{dsh-mneme/test → test}/client.test.js +22 -207
- package/{dsh-mneme/test → test}/config.test.js +0 -21
- package/{dsh-mneme/test → test}/dream.test.js +1 -160
- package/{dsh-mneme/test → test}/graph-api.test.js +0 -34
- package/{dsh-mneme/test → test}/hot-memory.test.js +0 -29
- package/test/inject.test.js +120 -0
- package/{dsh-mneme/test → test}/llm-audit.test.js +4 -4
- package/{dsh-mneme/test → test}/reasoning-effort.test.js +0 -27
- package/{dsh-mneme/test → test}/recall-layer.test.js +5 -26
- package/{dsh-mneme/test → test}/reranker.test.js +0 -43
- package/{dsh-mneme/test → test}/service-search.test.js +0 -25
- package/{dsh-mneme/test → test}/service.test.js +0 -106
- package/{dsh-mneme/test → test}/settings.test.js +0 -43
- package/{dsh-mneme/test → test}/sleep.test.js +4 -171
- package/{dsh-mneme/test → test}/store.test.js +0 -76
- package/{dsh-mneme/test → test}/summarize.test.js +16 -15
- package/test/tools.test.js +265 -0
- package/.github/workflows/publish.yml +0 -58
- package/.github/workflows/test.yml +0 -32
- package/CHANGELOG.md +0 -91
- package/CONTRIBUTING.md +0 -291
- package/SECURITY.md +0 -718
- package/docs/devlog/2026-08-14-dsh-mneme-dev-log.md +0 -247
- package/docs/devlog/2026-08-15-dsh-mneme-audit-stress-dev-log.md +0 -145
- package/docs/devlog/2026-08-15-dsh-mneme-pipeline-dev-log.md +0 -56
- package/docs/devlog/2026-08-15-dsh-mneme-reflection-dev-log.md +0 -77
- package/docs/devlog/2026-08-15-dsh-mneme-review-fixes-dev-log.md +0 -64
- package/docs/devlog/2026-08-15-dsh-mneme-semantic-dev-log.md +0 -90
- package/dsh-mneme/CHANGELOG.md +0 -406
- package/dsh-mneme/LICENSE +0 -21
- package/dsh-mneme/README.md +0 -498
- package/dsh-mneme/docs/AGENT_MEMORY_RESEARCH.md +0 -183
- package/dsh-mneme/docs/ENTITIES.md +0 -245
- package/dsh-mneme/docs/LOCAL_MODEL.md +0 -141
- package/dsh-mneme/docs/MIGRATION.md +0 -127
- package/dsh-mneme/docs/SEMANTIC.md +0 -256
- package/dsh-mneme/docs/SLEEP.md +0 -163
- package/dsh-mneme/lib/client.js +0 -2050
- package/dsh-mneme/lib/dream/tag-extractor.js +0 -156
- package/dsh-mneme/lib/heat.js +0 -136
- package/dsh-mneme/lib/parser/tag.js +0 -59
- package/dsh-mneme/lib/parser/wiki-link.js +0 -38
- package/dsh-mneme/lib/search/tag-boost.js +0 -61
- package/dsh-mneme/lib/tools.js +0 -458
- package/dsh-mneme/package-lock.json +0 -1936
- package/dsh-mneme/package.json +0 -83
- package/dsh-mneme/scripts/check-sync.js +0 -42
- package/dsh-mneme/src/client.js +0 -2050
- package/dsh-mneme/src/dream/tag-extractor.js +0 -156
- package/dsh-mneme/src/heat.js +0 -136
- package/dsh-mneme/src/parser/tag.js +0 -59
- package/dsh-mneme/src/parser/wiki-link.js +0 -38
- package/dsh-mneme/src/search/tag-boost.js +0 -61
- package/dsh-mneme/src/tools.js +0 -458
- package/dsh-mneme/test/boundary-v0625.test.js +0 -82
- package/dsh-mneme/test/directory.test.js +0 -134
- package/dsh-mneme/test/heat.test.js +0 -148
- package/dsh-mneme/test/inject.test.js +0 -206
- package/dsh-mneme/test/layered-types-stats.test.js +0 -144
- package/dsh-mneme/test/lib-smoke.test.js +0 -109
- package/dsh-mneme/test/normalize-decisions.test.js +0 -120
- package/dsh-mneme/test/provenance.test.js +0 -103
- package/dsh-mneme/test/recall-runs.test.js +0 -93
- package/dsh-mneme/test/sleep-heat.test.js +0 -112
- package/dsh-mneme/test/tag-boost.test.js +0 -125
- package/dsh-mneme/test/tag.test.js +0 -426
- package/dsh-mneme/test/tools.test.js +0 -674
- package/dsh-mneme/test/updated-at-semantics.test.js +0 -113
- package/dsh-mneme/test/wiki-link.test.js +0 -332
- package//346/250/252/345/271/205.png +0 -0
- /package/{dsh-mneme/lib → lib}/commands.js +0 -0
- /package/{dsh-mneme/lib → lib}/dream/clustering.js +0 -0
- /package/{dsh-mneme/lib → lib}/embedding.js +0 -0
- /package/{dsh-mneme/lib → lib}/entities/extractor.js +0 -0
- /package/{dsh-mneme/lib → lib}/local-embedder.js +0 -0
- /package/{dsh-mneme/lib → lib}/search/adaptive.js +0 -0
- /package/{dsh-mneme/lib → lib}/search/bm25.js +0 -0
- /package/{dsh-mneme/lib → lib}/vector-index.js +0 -0
- /package/{dsh-mneme/scripts → scripts}/benchmark-embed.js +0 -0
- /package/{dsh-mneme/scripts → scripts}/benchmark-recall.js +0 -0
- /package/{dsh-mneme/scripts → scripts}/benchmark-rerank.js +0 -0
- /package/{dsh-mneme/scripts → scripts}/stress-dsh.js +0 -0
- /package/{dsh-mneme/src → src}/commands.js +0 -0
- /package/{dsh-mneme/src → src}/dream/clustering.js +0 -0
- /package/{dsh-mneme/src → src}/embedding.js +0 -0
- /package/{dsh-mneme/src → src}/entities/extractor.js +0 -0
- /package/{dsh-mneme/src → src}/local-embedder.js +0 -0
- /package/{dsh-mneme/src → src}/search/adaptive.js +0 -0
- /package/{dsh-mneme/src → src}/search/bm25.js +0 -0
- /package/{dsh-mneme/src → src}/vector-index.js +0 -0
- /package/{dsh-mneme/test → test}/audit.test.js +0 -0
- /package/{dsh-mneme/test → test}/benchmark.test.js +0 -0
- /package/{dsh-mneme/test → test}/clustering.test.js +0 -0
- /package/{dsh-mneme/test → test}/commands.test.js +0 -0
- /package/{dsh-mneme/test → test}/conflict-freeze.test.js +0 -0
- /package/{dsh-mneme/test → test}/entities.test.js +0 -0
- /package/{dsh-mneme/test → test}/epistemic.test.js +0 -0
- /package/{dsh-mneme/test → test}/fnew-0112.test.js +0 -0
- /package/{dsh-mneme/test → test}/fnew-03.test.js +0 -0
- /package/{dsh-mneme/test → test}/helpers/dream-mock.js +0 -0
- /package/{dsh-mneme/test → test}/local-embedder.test.js +0 -0
- /package/{dsh-mneme/test → test}/mirror-dirty.test.js +0 -0
- /package/{dsh-mneme/test → test}/mirror-edit-digest.test.js +0 -0
- /package/{dsh-mneme/test → test}/mirror-generation.test.js +0 -0
- /package/{dsh-mneme/test → test}/mirror.test.js +0 -0
- /package/{dsh-mneme/test → test}/peer-blockers.test.js +0 -0
- /package/{dsh-mneme/test → test}/policy-epoch.test.js +0 -0
- /package/{dsh-mneme/test → test}/quality-filter.test.js +0 -0
- /package/{dsh-mneme/test → test}/recall-evals.test.js +0 -0
- /package/{dsh-mneme/test → test}/receipt-chain.test.js +0 -0
- /package/{dsh-mneme/test → test}/reflection.test.js +0 -0
- /package/{dsh-mneme/test → test}/search-fusion.test.js +0 -0
- /package/{dsh-mneme/test → test}/semantic.test.js +0 -0
- /package/{dsh-mneme/test → test}/stress.test.js +0 -0
- /package/{dsh-mneme/test → test}/vector-index.test.js +0 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,2086 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@modusensus/dsh-mneme",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
|
|
7
|
+
let react = require("react");
|
|
8
|
+
let primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
9
|
+
let { useState, useEffect, useCallback, useRef } = react;
|
|
10
|
+
const IconArchiveOutline20 = primitives.IconArchiveOutline20;
|
|
11
|
+
|
|
12
|
+
// Portal target for the hero fallback surface. The host whitelists
|
|
13
|
+
// react-dom for its own bundles (dsh-client-ui-trajectory requires it);
|
|
14
|
+
// when the runtime rejects it for plugins the overlay renders in place —
|
|
15
|
+
// position:fixed keeps it viewport-sized either way.
|
|
16
|
+
let reactDom = null;
|
|
17
|
+
try { reactDom = require("react-dom"); } catch { reactDom = null; }
|
|
18
|
+
|
|
19
|
+
// Node-graph pictogram (three nodes joined by edges). The primitives kit
|
|
20
|
+
// ships no network/graph icon, and its share-style glyph reads as
|
|
21
|
+
// "share" — exactly the confusion this custom 16px replacement avoids.
|
|
22
|
+
const GraphNodesIcon = ({ size = 16, className }) => h("svg", {
|
|
23
|
+
width: size,
|
|
24
|
+
height: size,
|
|
25
|
+
className,
|
|
26
|
+
viewBox: "0 0 16 16",
|
|
27
|
+
fill: "none",
|
|
28
|
+
xmlns: "http://www.w3.org/2000/svg"
|
|
29
|
+
},
|
|
30
|
+
h("path", {
|
|
31
|
+
d: "M8 5.2 4.6 10.4M8 5.2l3.4 5.2M5.2 12h5.6",
|
|
32
|
+
stroke: "currentColor",
|
|
33
|
+
strokeWidth: "1.2",
|
|
34
|
+
strokeLinecap: "round",
|
|
35
|
+
strokeLinejoin: "round"
|
|
36
|
+
}),
|
|
37
|
+
h("circle", { cx: 8, cy: 3.4, r: 1.8, fill: "currentColor" }),
|
|
38
|
+
h("circle", { cx: 3.6, cy: 12, r: 1.8, fill: "currentColor" }),
|
|
39
|
+
h("circle", { cx: 12.4, cy: 12, r: 1.8, fill: "currentColor" })
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// Stroke icons, Lucide path data (ISC license) inlined as [tag, attrs]
|
|
43
|
+
// tuples — the plugin runtime cannot require third-party libraries, so
|
|
44
|
+
// the data ships with the bundle (the morphicons/lucide pairing the
|
|
45
|
+
// data package documents, minus the runtime dependency). Stroke picks
|
|
46
|
+
// up currentColor, so icons follow the host theme tokens.
|
|
47
|
+
const ICON_PATHS = {
|
|
48
|
+
database: [["ellipse", { cx: "12", cy: "5", rx: "9", ry: "3" }], ["path", { d: "M3 5V19A9 3 0 0 0 21 19V5" }], ["path", { d: "M3 12A9 3 0 0 0 21 12" }]],
|
|
49
|
+
waypoints: [["path", { d: "m10.586 5.414-5.172 5.172" }], ["path", { d: "m18.586 13.414-5.172 5.172" }], ["path", { d: "M6 12h12" }], ["circle", { cx: "12", cy: "20", r: "2" }], ["circle", { cx: "12", cy: "4", r: "2" }], ["circle", { cx: "20", cy: "12", r: "2" }], ["circle", { cx: "4", cy: "12", r: "2" }]],
|
|
50
|
+
settings: [["path", { d: "M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" }], ["circle", { cx: "12", cy: "12", r: "3" }]],
|
|
51
|
+
search: [["path", { d: "m21 21-4.34-4.34" }], ["circle", { cx: "11", cy: "11", r: "8" }]],
|
|
52
|
+
refresh: [["path", { d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" }], ["path", { d: "M21 3v5h-5" }], ["path", { d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" }], ["path", { d: "M8 16H3v5" }]],
|
|
53
|
+
chevronDown: [["path", { d: "m6 9 6 6 6-6" }]],
|
|
54
|
+
chevronRight: [["path", { d: "m9 18 6-6-6-6" }]],
|
|
55
|
+
copy: [["rect", { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2" }], ["path", { d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" }]],
|
|
56
|
+
check: [["path", { d: "M20 6 9 17l-5-5" }]],
|
|
57
|
+
inbox: [["polyline", { points: "22 12 16 12 14 15 10 15 8 12 2 12" }], ["path", { d: "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" }]],
|
|
58
|
+
activity: [["path", { d: "M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" }]]
|
|
59
|
+
};
|
|
60
|
+
const Icon = ({ name, size = 16, className }) => {
|
|
61
|
+
const parts = ICON_PATHS[name];
|
|
62
|
+
if (!parts) return null;
|
|
63
|
+
return h("svg", {
|
|
64
|
+
width: size,
|
|
65
|
+
height: size,
|
|
66
|
+
viewBox: "0 0 24 24",
|
|
67
|
+
fill: "none",
|
|
68
|
+
stroke: "currentColor",
|
|
69
|
+
strokeWidth: 2,
|
|
70
|
+
strokeLinecap: "round",
|
|
71
|
+
strokeLinejoin: "round",
|
|
72
|
+
className,
|
|
73
|
+
"aria-hidden": "true"
|
|
74
|
+
}, parts.map(([tag, attrs], i) => h(tag, { key: i, ...attrs })));
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Unified API fetcher: attaches the optional apiToken (set in the settings
|
|
78
|
+
// view, persisted in localStorage) as a Bearer header. When no token has
|
|
79
|
+
// been configured the header is omitted and the API stays open (default).
|
|
80
|
+
const API_TOKEN_KEY = "dsh-mneme-api-token";
|
|
81
|
+
function apiFetch(path, opts = {}) {
|
|
82
|
+
const token = (typeof window !== "undefined" && window.localStorage)
|
|
83
|
+
? window.localStorage.getItem(API_TOKEN_KEY) || ""
|
|
84
|
+
: "";
|
|
85
|
+
const headers = { ...(opts.headers || {}) };
|
|
86
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
87
|
+
return fetch(path, { ...opts, headers });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const inject = ["slots", "locale"];
|
|
91
|
+
|
|
92
|
+
const NS = "memory";
|
|
93
|
+
|
|
94
|
+
const dictionaries = {
|
|
95
|
+
zh: {
|
|
96
|
+
"memory.panel.empty": "暂无记忆条目",
|
|
97
|
+
"memory.panel.open": "记忆",
|
|
98
|
+
"memory.tab.all": "全部",
|
|
99
|
+
"memory.tab.preference": "偏好",
|
|
100
|
+
"memory.tab.project": "项目",
|
|
101
|
+
"memory.tab.decision": "决策",
|
|
102
|
+
"memory.tab.history": "历史",
|
|
103
|
+
"memory.settings.title": "记忆库设置",
|
|
104
|
+
"memory.settings.profile": "用户画像",
|
|
105
|
+
"memory.settings.profileHint": "写一段自我介绍(角色、背景、偏好),Agent 每轮对话都会自动带上。",
|
|
106
|
+
"memory.settings.profileSave": "保存画像",
|
|
107
|
+
"memory.settings.profileSaved": "画像已保存",
|
|
108
|
+
"memory.settings.rules": "规则",
|
|
109
|
+
"memory.settings.rulesHint": "给 Agent 立下必须遵守的规矩,随时增删,下一轮即生效。",
|
|
110
|
+
"memory.settings.ruleAdd": "添加规则",
|
|
111
|
+
"memory.settings.rulePlaceholder": "例如:回答时总是先给结论",
|
|
112
|
+
"memory.settings.commands": "自定义指令",
|
|
113
|
+
"memory.settings.commandsHint": "把常用提示词存成 / 命令,聊天时直接键入调用。",
|
|
114
|
+
"memory.settings.cmdName": "命令名",
|
|
115
|
+
"memory.settings.cmdDesc": "描述",
|
|
116
|
+
"memory.settings.cmdInstruction": "指令内容",
|
|
117
|
+
"memory.settings.cmdAdd": "添加命令",
|
|
118
|
+
"memory.settings.cmdDelete": "删除",
|
|
119
|
+
"memory.settings.empty": "暂无内容",
|
|
120
|
+
"memory.panel.semantic": "语义",
|
|
121
|
+
"memory.sidebar.aria": "打开记忆库",
|
|
122
|
+
"memory.view.label": "记忆库",
|
|
123
|
+
"memory.overlay.close": "关闭",
|
|
124
|
+
"memory.explorer.tabMemory": "记忆",
|
|
125
|
+
"memory.explorer.tabEntities": "实体",
|
|
126
|
+
"memory.explorer.tabSettings": "设置",
|
|
127
|
+
"memory.explorer.tabStatus": "状态",
|
|
128
|
+
"memory.entities.count": "{n} 个实体",
|
|
129
|
+
"memory.entities.pick": "从左侧选择一个实体,查看它的属性与关系",
|
|
130
|
+
"memory.entities.none": "暂无实体:开启实体抽取后,记忆里的人物 / 项目 / 概念会自动沉淀到这里",
|
|
131
|
+
"memory.entities.attrs": "属性",
|
|
132
|
+
"memory.entities.relations": "关系",
|
|
133
|
+
"memory.entities.mentions": "{n} 次提及",
|
|
134
|
+
"memory.entities.lastSeen": "最近出现",
|
|
135
|
+
"memory.entities.graph": "关系图谱",
|
|
136
|
+
"memory.entity.person": "人物",
|
|
137
|
+
"memory.entity.project": "项目",
|
|
138
|
+
"memory.entity.concept": "概念",
|
|
139
|
+
"memory.entity.technology": "技术",
|
|
140
|
+
"memory.entity.organization": "组织",
|
|
141
|
+
"memory.entity.other": "其他",
|
|
142
|
+
"memory.explorer.search": "搜索标题或内容…",
|
|
143
|
+
"memory.explorer.searchTitle": "语义检索",
|
|
144
|
+
"memory.explorer.types": "分类",
|
|
145
|
+
"memory.explorer.timeline": "时间树",
|
|
146
|
+
"memory.explorer.detail": "详情",
|
|
147
|
+
"memory.explorer.emptyDetail": "在时间树中选择一条记忆查看全文",
|
|
148
|
+
"memory.explorer.copy": "复制全文",
|
|
149
|
+
"memory.explorer.copied": "已复制",
|
|
150
|
+
"memory.explorer.refresh": "刷新",
|
|
151
|
+
"memory.explorer.loadMore": "加载更多",
|
|
152
|
+
"memory.explorer.count": "共 {n} 条",
|
|
153
|
+
"memory.explorer.empty": "暂无记忆条目",
|
|
154
|
+
"memory.explorer.source": "来源",
|
|
155
|
+
"memory.explorer.created": "创建",
|
|
156
|
+
"memory.explorer.updated": "更新",
|
|
157
|
+
"memory.explorer.tags": "标签",
|
|
158
|
+
"memory.explorer.importance": "重要性",
|
|
159
|
+
"memory.explorer.topK": "返回数量",
|
|
160
|
+
"memory.explorer.topKOption": "返回 {n} 条",
|
|
161
|
+
"memory.card.open": "在主区记忆库中查看全文",
|
|
162
|
+
"memory.time.now": "刚刚",
|
|
163
|
+
"memory.time.seconds": "{n}秒前",
|
|
164
|
+
"memory.time.minutes": "{n}分钟前",
|
|
165
|
+
"memory.time.hours": "{n}小时前",
|
|
166
|
+
"memory.time.days": "{n}天前",
|
|
167
|
+
"memory.graph.title": "记忆图谱",
|
|
168
|
+
"memory.graph.aria": "记忆图谱",
|
|
169
|
+
"memory.graph.placeholder": "输入实体名,查看关联网络…",
|
|
170
|
+
"memory.graph.depth": "跳数",
|
|
171
|
+
"memory.graph.empty": "图谱待积累:随对话记忆的沉淀,实体与关系会自动进入图谱",
|
|
172
|
+
"memory.graph.notFound": "未找到该实体",
|
|
173
|
+
"memory.graph.attrs": "属性",
|
|
174
|
+
"memory.graph.related": "关联记忆",
|
|
175
|
+
"memory.graph.relation": "关系",
|
|
176
|
+
"memory.graph.sourceMemory": "查看来源记忆",
|
|
177
|
+
"memory.graph.hint": "拖拽节点调整布局 · 空白处拖动平移 · 滚轮缩放",
|
|
178
|
+
"memory.graph.resetView": "重置视图",
|
|
179
|
+
"memory.graph.viewInGraph": "在实体视图中打开",
|
|
180
|
+
"memory.graph.loading": "加载中…",
|
|
181
|
+
"memory.graph.distance": "距中心 {n} 跳",
|
|
182
|
+
"memory.settings.vectorTitle": "向量搜索",
|
|
183
|
+
"memory.settings.vectorHint": "搜索不再只认字面:接入任意 OpenAI 兼容的 embedding 接口后,意思相近的记忆也能被召回。",
|
|
184
|
+
"memory.settings.vectorEnabled": "启用向量搜索",
|
|
185
|
+
"memory.settings.vectorBaseUrl": "API 地址 (Base URL)",
|
|
186
|
+
"memory.settings.vectorApiKey": "API Key",
|
|
187
|
+
"memory.settings.vectorModel": "模型名",
|
|
188
|
+
"memory.settings.vectorSave": "保存配置",
|
|
189
|
+
"memory.settings.vectorSaved": "配置已保存",
|
|
190
|
+
"memory.settings.vectorReindex": "重建索引",
|
|
191
|
+
"memory.settings.vectorReindexing": "索引中…",
|
|
192
|
+
"memory.settings.vectorReindexDone": "已索引 {n} 条",
|
|
193
|
+
"memory.settings.apiTokenTitle": "API Token(可选)",
|
|
194
|
+
"memory.settings.apiTokenHint": "给面板的写操作加一道锁:设置后,修改记忆或配置需要携带 Token,纯浏览不受影响。清空并保存可关闭。",
|
|
195
|
+
"memory.settings.apiTokenPlaceholder": "留空 = 不鉴权(默认)",
|
|
196
|
+
"memory.settings.apiTokenSave": "保存 Token",
|
|
197
|
+
"memory.settings.apiTokenSaved": "Token 已保存",
|
|
198
|
+
"memory.explorer.delete": "删除",
|
|
199
|
+
"memory.explorer.confirmDelete": "确认删除?",
|
|
200
|
+
"memory.explorer.cancel": "取消",
|
|
201
|
+
"memory.explorer.deleted": "已删除",
|
|
202
|
+
"memory.explorer.deleteFailed": "删除失败",
|
|
203
|
+
"memory.status.memories": "记忆总数",
|
|
204
|
+
"memory.status.entities": "实体",
|
|
205
|
+
"memory.status.vector": "向量索引",
|
|
206
|
+
"memory.status.vectorOn": "已启用",
|
|
207
|
+
"memory.status.vectorOff": "未启用",
|
|
208
|
+
"memory.status.llm": "LLM 消耗",
|
|
209
|
+
"memory.status.llmCalls": "近 7 天 · {n} 次调用",
|
|
210
|
+
"memory.status.error": "加载失败"
|
|
211
|
+
},
|
|
212
|
+
en: {
|
|
213
|
+
"memory.panel.empty": "No memories yet",
|
|
214
|
+
"memory.panel.open": "Memory",
|
|
215
|
+
"memory.tab.all": "All",
|
|
216
|
+
"memory.tab.preference": "Preferences",
|
|
217
|
+
"memory.tab.project": "Projects",
|
|
218
|
+
"memory.tab.decision": "Decisions",
|
|
219
|
+
"memory.tab.history": "History",
|
|
220
|
+
"memory.settings.title": "Memory Settings",
|
|
221
|
+
"memory.settings.profile": "User Profile",
|
|
222
|
+
"memory.settings.profileHint": "Describe yourself once — the agent reads it every turn.",
|
|
223
|
+
"memory.settings.profileSave": "Save Profile",
|
|
224
|
+
"memory.settings.profileSaved": "Profile saved",
|
|
225
|
+
"memory.settings.rules": "Rules",
|
|
226
|
+
"memory.settings.rulesHint": "Rules the agent must follow — add or remove anytime, effective next turn.",
|
|
227
|
+
"memory.settings.ruleAdd": "Add Rule",
|
|
228
|
+
"memory.settings.rulePlaceholder": "e.g. Always lead with a conclusion",
|
|
229
|
+
"memory.settings.commands": "Custom Commands",
|
|
230
|
+
"memory.settings.commandsHint": "Save prompts as slash commands and invoke them right from the composer.",
|
|
231
|
+
"memory.settings.cmdName": "Name",
|
|
232
|
+
"memory.settings.cmdDesc": "Description",
|
|
233
|
+
"memory.settings.cmdInstruction": "Instruction",
|
|
234
|
+
"memory.settings.cmdAdd": "Add Command",
|
|
235
|
+
"memory.settings.cmdDelete": "Delete",
|
|
236
|
+
"memory.settings.empty": "Nothing yet",
|
|
237
|
+
"memory.panel.semantic": "Semantic",
|
|
238
|
+
"memory.sidebar.aria": "Open memory panel",
|
|
239
|
+
"memory.view.label": "Memory",
|
|
240
|
+
"memory.overlay.close": "Close",
|
|
241
|
+
"memory.explorer.tabMemory": "Memories",
|
|
242
|
+
"memory.explorer.tabEntities": "Entities",
|
|
243
|
+
"memory.explorer.tabSettings": "Settings",
|
|
244
|
+
"memory.explorer.tabStatus": "Status",
|
|
245
|
+
"memory.entities.count": "{n} entities",
|
|
246
|
+
"memory.entities.pick": "Select an entity to see its attributes and relations",
|
|
247
|
+
"memory.entities.none": "No entities yet — turn on entity extraction and people / projects / concepts will accumulate here",
|
|
248
|
+
"memory.entities.attrs": "Attributes",
|
|
249
|
+
"memory.entities.relations": "Relations",
|
|
250
|
+
"memory.entities.mentions": "{n} mentions",
|
|
251
|
+
"memory.entities.lastSeen": "Last seen",
|
|
252
|
+
"memory.entities.graph": "Relation graph",
|
|
253
|
+
"memory.entity.person": "People",
|
|
254
|
+
"memory.entity.project": "Projects",
|
|
255
|
+
"memory.entity.concept": "Concepts",
|
|
256
|
+
"memory.entity.technology": "Technologies",
|
|
257
|
+
"memory.entity.organization": "Organizations",
|
|
258
|
+
"memory.entity.other": "Others",
|
|
259
|
+
"memory.explorer.search": "Search title or content…",
|
|
260
|
+
"memory.explorer.searchTitle": "Semantic Search",
|
|
261
|
+
"memory.explorer.types": "Types",
|
|
262
|
+
"memory.explorer.timeline": "Timeline",
|
|
263
|
+
"memory.explorer.detail": "Details",
|
|
264
|
+
"memory.explorer.emptyDetail": "Select a memory in the timeline to read it",
|
|
265
|
+
"memory.explorer.copy": "Copy content",
|
|
266
|
+
"memory.explorer.copied": "Copied",
|
|
267
|
+
"memory.explorer.refresh": "Refresh",
|
|
268
|
+
"memory.explorer.loadMore": "Load more",
|
|
269
|
+
"memory.explorer.count": "{n} items",
|
|
270
|
+
"memory.explorer.empty": "No memories yet",
|
|
271
|
+
"memory.explorer.source": "Source",
|
|
272
|
+
"memory.explorer.created": "Created",
|
|
273
|
+
"memory.explorer.updated": "Updated",
|
|
274
|
+
"memory.explorer.tags": "Tags",
|
|
275
|
+
"memory.explorer.importance": "Importance",
|
|
276
|
+
"memory.explorer.topK": "Results limit",
|
|
277
|
+
"memory.explorer.topKOption": "Return {n}",
|
|
278
|
+
"memory.card.open": "Open full text in the Memory tab",
|
|
279
|
+
"memory.time.now": "just now",
|
|
280
|
+
"memory.time.seconds": "{n}s ago",
|
|
281
|
+
"memory.time.minutes": "{n}m ago",
|
|
282
|
+
"memory.time.hours": "{n}h ago",
|
|
283
|
+
"memory.time.days": "{n}d ago",
|
|
284
|
+
"memory.graph.title": "Memory Graph",
|
|
285
|
+
"memory.graph.aria": "Memory graph",
|
|
286
|
+
"memory.graph.placeholder": "Type an entity name to see its network…",
|
|
287
|
+
"memory.graph.depth": "hops",
|
|
288
|
+
"memory.graph.empty": "The graph is waiting for data: entities and relations accumulate as memories are extracted",
|
|
289
|
+
"memory.graph.notFound": "Entity not found",
|
|
290
|
+
"memory.graph.attrs": "Attributes",
|
|
291
|
+
"memory.graph.related": "Related memories",
|
|
292
|
+
"memory.graph.relation": "Relation",
|
|
293
|
+
"memory.graph.sourceMemory": "View source memory",
|
|
294
|
+
"memory.graph.hint": "Drag nodes to rearrange · drag the background to pan · scroll to zoom",
|
|
295
|
+
"memory.graph.resetView": "Reset view",
|
|
296
|
+
"memory.graph.viewInGraph": "Open in entity explorer",
|
|
297
|
+
"memory.graph.loading": "Loading…",
|
|
298
|
+
"memory.graph.distance": "{n} hop(s) from root",
|
|
299
|
+
"memory.settings.vectorTitle": "Vector Search",
|
|
300
|
+
"memory.settings.vectorHint": "Match by meaning, not just wording — connect any OpenAI-compatible embeddings API.",
|
|
301
|
+
"memory.settings.vectorEnabled": "Enable vector search",
|
|
302
|
+
"memory.settings.vectorBaseUrl": "Base URL",
|
|
303
|
+
"memory.settings.vectorApiKey": "API Key",
|
|
304
|
+
"memory.settings.vectorModel": "Model",
|
|
305
|
+
"memory.settings.vectorSave": "Save Config",
|
|
306
|
+
"memory.settings.vectorSaved": "Config saved",
|
|
307
|
+
"memory.settings.vectorReindex": "Reindex",
|
|
308
|
+
"memory.settings.vectorReindexing": "Indexing…",
|
|
309
|
+
"memory.settings.vectorReindexDone": "Indexed {n} items",
|
|
310
|
+
"memory.settings.apiTokenTitle": "API Token (optional)",
|
|
311
|
+
"memory.settings.apiTokenHint": "Lock write operations behind a token — browsing stays open. Clear and save to disable.",
|
|
312
|
+
"memory.settings.apiTokenPlaceholder": "Empty = no auth (default)",
|
|
313
|
+
"memory.settings.apiTokenSave": "Save Token",
|
|
314
|
+
"memory.settings.apiTokenSaved": "Token saved",
|
|
315
|
+
"memory.explorer.delete": "Delete",
|
|
316
|
+
"memory.explorer.confirmDelete": "Confirm delete?",
|
|
317
|
+
"memory.explorer.cancel": "Cancel",
|
|
318
|
+
"memory.explorer.deleted": "Deleted",
|
|
319
|
+
"memory.explorer.deleteFailed": "Delete failed",
|
|
320
|
+
"memory.status.memories": "Total memories",
|
|
321
|
+
"memory.status.entities": "Entities",
|
|
322
|
+
"memory.status.vector": "Vector index",
|
|
323
|
+
"memory.status.vectorOn": "Enabled",
|
|
324
|
+
"memory.status.vectorOff": "Disabled",
|
|
325
|
+
"memory.status.llm": "LLM Usage",
|
|
326
|
+
"memory.status.llmCalls": "Last 7 days · {n} calls",
|
|
327
|
+
"memory.status.error": "Failed to load"
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
function typeLabel(t, type) {
|
|
332
|
+
const key = `memory.tab.${type}`;
|
|
333
|
+
const label = t(key);
|
|
334
|
+
return label && label !== key ? label : String(type);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Entity types live under their own i18n subtree (person/project/...);
|
|
338
|
+
// unknown or "other" falls back to the raw type string.
|
|
339
|
+
function entityTypeLabel(t, type) {
|
|
340
|
+
const key = `memory.entity.${type}`;
|
|
341
|
+
const label = t(key);
|
|
342
|
+
return label && label !== key ? label : String(type);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function formatDate(value) {
|
|
346
|
+
if (!value) return "—";
|
|
347
|
+
const date = new Date(value);
|
|
348
|
+
return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Relative time ("2分钟前") keeps cards scannable; anything older than a
|
|
352
|
+
// week falls back to the absolute date. The card meta shows the relative
|
|
353
|
+
// form and carries the full timestamp in a title tooltip.
|
|
354
|
+
function formatRelativeTime(value, t) {
|
|
355
|
+
if (!value) return "—";
|
|
356
|
+
const ms = new Date(value).getTime();
|
|
357
|
+
if (Number.isNaN(ms)) return "—";
|
|
358
|
+
const diff = Date.now() - ms;
|
|
359
|
+
if (diff < 60_000) return t("memory.time.now");
|
|
360
|
+
const minutes = Math.floor(diff / 60_000);
|
|
361
|
+
if (minutes < 60) return t("memory.time.minutes").replace("{n}", String(minutes));
|
|
362
|
+
const hours = Math.floor(minutes / 60);
|
|
363
|
+
if (hours < 24) return t("memory.time.hours").replace("{n}", String(hours));
|
|
364
|
+
const days = Math.floor(hours / 24);
|
|
365
|
+
if (days < 7) return t("memory.time.days").replace("{n}", String(days));
|
|
366
|
+
return new Date(ms).toLocaleDateString();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Memory-library stylesheet, injected once per page following the host's
|
|
370
|
+
// data-plugin-css convention. Every value resolves to the host's design
|
|
371
|
+
// tokens: background layers, label/border/interactive aliases and the
|
|
372
|
+
// --dsw-font-* scale, so the page reads as a first-party view beside
|
|
373
|
+
// Chat / Trajectory (flat layer-1 canvas, hairline column separators,
|
|
374
|
+
// text-turns-brand-blue active states — no boxed panels).
|
|
375
|
+
const CSS_TAG = "@modusensus/dsh-mneme/drawer.css";
|
|
376
|
+
const css = [
|
|
377
|
+
// --- sidebar foot trigger (wide row / collapsed rail icon) ---
|
|
378
|
+
".mneme-trigger{box-sizing:border-box;cursor:pointer;width:calc(100% + 4px);height:42px;color:var(--dsw-alias-label-primary);background:0 0;border:none;border-radius:12px;flex:none;align-items:center;gap:8px;margin:4px -2px;padding:0 10px 0 8px;font-family:inherit;font-size:14px;line-height:22px;display:flex;overflow:hidden}",
|
|
379
|
+
".mneme-trigger:hover{background:var(--dsw-alias-interactive-bg-hover)}",
|
|
380
|
+
".mneme-trigger.mneme-rail{border-radius:50%;justify-content:center;gap:0;width:36px;height:36px;margin:8px 0 10px;padding:0}",
|
|
381
|
+
".mneme-trigger-label{white-space:nowrap;overflow:hidden}",
|
|
382
|
+
// --- shared controls ---
|
|
383
|
+
".mneme-search{box-sizing:border-box;height:30px;padding:0 10px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base,transparent);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:13px;outline:none;transition:border-color .12s,box-shadow .12s}",
|
|
384
|
+
".mneme-search:focus{border-color:var(--dsw-alias-state-business-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-state-business-primary) 15%,transparent)}",
|
|
385
|
+
".mneme-search::placeholder{color:var(--dsw-alias-label-tertiary)}",
|
|
386
|
+
".mneme-chip{height:26px;padding:0 10px;border-radius:8px;border:1px solid transparent;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-family:inherit;font-size:12px;line-height:16px;display:inline-flex;align-items:center}",
|
|
387
|
+
".mneme-chip:hover{background:var(--dsw-alias-interactive-bg-hover)}",
|
|
388
|
+
".mneme-chip.mneme-active{color:var(--dsw-alias-state-business-primary);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent)}",
|
|
389
|
+
".mneme-select{box-sizing:border-box;height:30px;padding:0 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base,transparent);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:13px;outline:none}",
|
|
390
|
+
".mneme-footbtn{border:none;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-family:inherit;font-size:12px;line-height:16px;padding:3px 8px;border-radius:6px}",
|
|
391
|
+
".mneme-footbtn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
|
|
392
|
+
".mneme-hint{color:var(--dsw-alias-label-tertiary);padding:24px 0;text-align:center;font-size:13px}",
|
|
393
|
+
".mneme-entitychip{flex:none;height:26px;padding:0 10px;border-radius:8px;border:none;background:none;color:var(--dsw-alias-state-business-primary);cursor:pointer;font-family:inherit;font-size:12px;line-height:16px;display:inline-flex;align-items:center}",
|
|
394
|
+
".mneme-entitychip:hover{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent)}",
|
|
395
|
+
// --- main-area memory library page ---
|
|
396
|
+
".mneme-x{flex:1;min-height:0;height:100%;width:100%;box-sizing:border-box;display:flex;flex-direction:column;background:var(--dsw-alias-bg-layer-1);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2)}",
|
|
397
|
+
".mneme-xbar{flex:none;display:flex;align-items:center;gap:6px;border-bottom:1px solid var(--dsw-alias-border-l2);padding:0 16px}",
|
|
398
|
+
".mneme-filterbar{flex:none;display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 16px;border-bottom:1px solid var(--dsw-alias-border-l2)}",
|
|
399
|
+
".mneme-vtabs{display:flex;align-items:stretch;height:38px}",
|
|
400
|
+
".mneme-vtab{position:relative;border:0;background:none;cursor:pointer;padding:0 10px;color:var(--dsw-alias-label-tertiary);font-family:inherit;font-size:13px;font-weight:500;line-height:16px;display:inline-flex;align-items:center;gap:6px}",
|
|
401
|
+
".mneme-vtab:hover{color:var(--dsw-alias-label-primary)}",
|
|
402
|
+
".mneme-vtab.mneme-active{color:var(--dsw-alias-state-business-primary)}",
|
|
403
|
+
".mneme-vtab.mneme-active::after{content:\"\";position:absolute;left:8px;right:8px;bottom:-1px;height:2px;border-radius:2px;background:var(--dsw-alias-state-business-primary)}",
|
|
404
|
+
".mneme-xtools{margin-left:auto;display:flex;align-items:center;gap:8px;padding:0 0 0 12px}",
|
|
405
|
+
".mneme-xcount{flex:none;font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}",
|
|
406
|
+
// --- three-column browse layout: hairline separators, no outer box ---
|
|
407
|
+
".mneme-xmain{flex:1;min-height:0;display:flex;flex-direction:row;overflow:hidden}",
|
|
408
|
+
".mneme-xside{flex:none;width:236px;min-width:0;min-height:0;overflow-y:auto;padding:12px;border-right:1px solid var(--dsw-alias-border-l2);box-sizing:border-box;display:flex;flex-direction:column;gap:8px}",
|
|
409
|
+
".mneme-xside--filter{width:214px}",
|
|
410
|
+
".mneme-xbrowse{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;overflow:hidden}",
|
|
411
|
+
".mneme-xrow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
|
|
412
|
+
".mneme-xsearch{width:100%}",
|
|
413
|
+
".mneme-xselect{flex:1;min-width:0}",
|
|
414
|
+
".mneme-xcolhead{flex:none;font-size:12px;font-weight:500;color:var(--dsw-alias-label-tertiary);padding:2px 8px 8px}",
|
|
415
|
+
".mneme-xtype{display:flex;justify-content:flex-start;align-items:center;gap:8px;width:100%;padding:5px 8px;border:none;border-radius:8px;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-family:inherit;font-size:13px;line-height:18px;text-align:left}",
|
|
416
|
+
".mneme-xtype:hover{background:var(--dsw-alias-interactive-bg-hover)}",
|
|
417
|
+
".mneme-xtype.mneme-active{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary);font-weight:500}",
|
|
418
|
+
".mneme-xcount2{flex:none;margin-left:auto;font-size:12px;color:var(--dsw-alias-label-tertiary)}",
|
|
419
|
+
".mneme-xmonth{display:flex;align-items:center;gap:4px;width:100%;padding:6px 8px 4px;border:none;border-radius:8px;background:none;color:var(--dsw-alias-label-primary);cursor:pointer;font-family:inherit;font-size:13px;font-weight:500;line-height:18px;text-align:left}",
|
|
420
|
+
".mneme-xmonth:first-child{margin-top:0}",
|
|
421
|
+
".mneme-xmonth:hover{background:var(--dsw-alias-interactive-bg-hover)}",
|
|
422
|
+
".mneme-xcaret{flex:none;font-size:10px;color:var(--dsw-alias-label-tertiary);width:10px}",
|
|
423
|
+
".mneme-xday{display:flex;align-items:center;gap:4px;margin:6px 0 2px 22px;padding:2px 6px;border:none;border-radius:6px;background:none;font-family:inherit;font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary);cursor:pointer;text-align:left}",
|
|
424
|
+
".mneme-xday:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
|
|
425
|
+
".mneme-xitem{display:flex;gap:8px;align-items:baseline;width:calc(100% - 22px);margin-left:22px;padding:4px 8px;border:none;border-radius:8px;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-family:inherit;font-size:13px;line-height:18px;text-align:left}",
|
|
426
|
+
".mneme-xitem:hover{background:var(--dsw-alias-interactive-bg-hover)}",
|
|
427
|
+
".mneme-xitem.mneme-active{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary)}",
|
|
428
|
+
".mneme-xtime{flex:none;font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}",
|
|
429
|
+
".mneme-xname{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
430
|
+
".mneme-xempty{padding:18px 16px;text-align:center;color:var(--dsw-alias-label-tertiary);font-size:13px}",
|
|
431
|
+
// --- memory-type color dots (mirrored across filter / timeline / detail) ---
|
|
432
|
+
".mneme-xdot{flex:none;display:inline-block;width:8px;height:8px;border-radius:50%;background:currentColor}",
|
|
433
|
+
".mneme-xdot--all{background:transparent;border:1.5px solid currentColor;opacity:.55;box-sizing:border-box}",
|
|
434
|
+
".mneme-xitem .mneme-xdot{align-self:center}",
|
|
435
|
+
// --- detail column (preview pane sits BELOW the timeline: the list is
|
|
436
|
+
// the primary scan target, the detail grows only when something is
|
|
437
|
+
// selected so the empty state never eats the viewport) ---
|
|
438
|
+
".mneme-xdetail{flex:none;max-height:56%;min-height:0;overflow-y:auto;padding:12px 20px 16px;border-top:1px solid var(--dsw-alias-border-l2)}",
|
|
439
|
+
".mneme-xtree{flex:1;min-height:0;overflow-y:auto;padding:8px 10px 28px}",
|
|
440
|
+
".mneme-xdinner{max-width:720px}",
|
|
441
|
+
".mneme-xdtitle{font-size:16px;font-weight:600;line-height:24px;color:var(--dsw-alias-label-primary);margin-bottom:10px;word-break:break-word}",
|
|
442
|
+
".mneme-xdmeta{display:flex;flex-wrap:wrap;gap:4px 14px;margin-bottom:6px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}",
|
|
443
|
+
".mneme-xdsrc{display:inline-block;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:bottom}",
|
|
444
|
+
".mneme-xdcontent{margin-top:14px;font-size:14px;line-height:1.75;color:var(--dsw-alias-label-primary);white-space:pre-wrap;word-break:break-word}",
|
|
445
|
+
".mneme-xdactions{display:flex;gap:8px;margin-top:18px}",
|
|
446
|
+
// --- graph sub-view (fills the content area under the tabs) ---
|
|
447
|
+
".mneme-graph{flex:1;min-height:0;width:100%;display:flex;flex-direction:column;padding:12px 16px 16px;box-sizing:border-box}",
|
|
448
|
+
".mneme-graphbar{display:flex;gap:8px;align-items:center;flex:none;margin-bottom:10px}",
|
|
449
|
+
".mneme-graphsvg{flex:1;min-height:180px;width:100%;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-button-elevated-fill);cursor:grab;touch-action:none}",
|
|
450
|
+
".mneme-gnode{cursor:pointer}",
|
|
451
|
+
".mneme-gnode circle{stroke:var(--dsw-alias-bg-layer-2);stroke-width:2;transition:stroke-width .12s}",
|
|
452
|
+
".mneme-gnode:hover circle{stroke-width:4}",
|
|
453
|
+
".mneme-gnode.mneme-groot circle{stroke:var(--dsw-alias-state-business-primary);stroke-width:3}",
|
|
454
|
+
".mneme-glabel{fill:var(--dsw-alias-label-secondary);font-size:11px;text-anchor:middle;pointer-events:none;user-select:none}",
|
|
455
|
+
".mneme-gedge{stroke:var(--dsw-alias-label-dimmed);stroke-width:1.5;cursor:pointer}",
|
|
456
|
+
".mneme-gedge:hover{stroke:var(--dsw-alias-state-business-primary)}",
|
|
457
|
+
".mneme-gedge-dashed{stroke-dasharray:5 4;opacity:.7}",
|
|
458
|
+
".mneme-graphhint{flex:none;color:var(--dsw-alias-label-tertiary);font-size:13px;text-align:center;padding:6px 0 2px}",
|
|
459
|
+
".mneme-graphside{flex:none;max-height:220px;overflow-y:auto;border-top:1px solid var(--dsw-alias-border-l1);margin-top:10px;padding-top:10px}",
|
|
460
|
+
".mneme-gs-title{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary);margin-bottom:4px;display:flex;justify-content:space-between;align-items:baseline;gap:8px}",
|
|
461
|
+
".mneme-gs-meta{font-size:13px;color:var(--dsw-alias-label-tertiary);margin-bottom:6px}",
|
|
462
|
+
".mneme-gs-attr{display:flex;gap:6px;font-size:13px;padding:2px 0}",
|
|
463
|
+
".mneme-gs-attrkey{flex:none;color:var(--dsw-alias-label-tertiary);font-size:13px}",
|
|
464
|
+
".mneme-gs-attrval{color:var(--dsw-alias-label-secondary);word-break:break-word;font-size:13px}",
|
|
465
|
+
".mneme-gs-link{display:block;width:100%;text-align:left;border:none;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-family:inherit;font-size:13px;padding:3px 6px;border-radius:6px;word-break:break-word}",
|
|
466
|
+
".mneme-gs-link:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
|
|
467
|
+
// --- entity explorer: rail + layered detail (entities → attrs → relations → graph) ---
|
|
468
|
+
".mneme-ent{flex:1;min-height:0;display:flex;overflow:hidden}",
|
|
469
|
+
".mneme-entrail{flex:none;width:224px;min-height:0;overflow-y:auto;padding:12px;border-right:1px solid var(--dsw-alias-border-l2);box-sizing:border-box;display:flex;flex-direction:column;gap:4px}",
|
|
470
|
+
".mneme-enttype{display:flex;align-items:center;gap:6px;width:100%;padding:8px 8px 2px;border:none;background:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-family:inherit;font-size:11px;font-weight:600;line-height:16px;text-align:left;letter-spacing:.02em}",
|
|
471
|
+
".mneme-entitem{display:flex;align-items:center;gap:8px;width:100%;padding:5px 8px;border:none;border-radius:8px;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-family:inherit;font-size:13px;line-height:18px;text-align:left}",
|
|
472
|
+
".mneme-entitem:hover{background:var(--dsw-alias-interactive-bg-hover)}",
|
|
473
|
+
".mneme-entitem.mneme-active{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary)}",
|
|
474
|
+
".mneme-entname{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
475
|
+
".mneme-entmentions{flex:none;font-size:11px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}",
|
|
476
|
+
".mneme-entdetail{flex:1;min-width:0;min-height:0;overflow-y:auto;padding:20px 24px 32px;box-sizing:border-box}",
|
|
477
|
+
".mneme-entinner{max-width:720px}",
|
|
478
|
+
".mneme-enttitle{display:flex;align-items:center;gap:10px;font-size:17px;font-weight:600;line-height:24px;color:var(--dsw-alias-label-primary);word-break:break-word}",
|
|
479
|
+
".mneme-enttitle .mneme-xdot{width:10px;height:10px}",
|
|
480
|
+
".mneme-entmeta{display:flex;flex-wrap:wrap;gap:4px 14px;margin-top:6px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}",
|
|
481
|
+
".mneme-layer{margin-top:24px}",
|
|
482
|
+
".mneme-layerhead{display:flex;align-items:baseline;gap:8px;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);margin-bottom:10px}",
|
|
483
|
+
".mneme-layercount{font-size:12px;font-weight:400;color:var(--dsw-alias-label-tertiary)}",
|
|
484
|
+
".mneme-attrgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px}",
|
|
485
|
+
".mneme-attrcard{min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:8px 12px}",
|
|
486
|
+
".mneme-attrkey{font-size:11px;color:var(--dsw-alias-label-tertiary);margin-bottom:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
487
|
+
".mneme-attrval{font-size:13px;line-height:19px;color:var(--dsw-alias-label-primary);word-break:break-word}",
|
|
488
|
+
".mneme-relrow{display:flex;align-items:center;gap:10px;width:100%;padding:7px 10px;border:none;border-radius:10px;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-family:inherit;font-size:13px;line-height:20px;text-align:left}",
|
|
489
|
+
".mneme-relrow:hover{background:var(--dsw-alias-interactive-bg-hover)}",
|
|
490
|
+
".mneme-reltype{flex:none;font-size:11px;line-height:16px;color:var(--dsw-alias-state-business-primary);border:1px solid color-mix(in srgb,var(--dsw-alias-state-business-primary) 35%,transparent);border-radius:6px;padding:0 6px}",
|
|
491
|
+
".mneme-relname{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
492
|
+
// --- settings: quiet stacked sections, one concern per section ---
|
|
493
|
+
".mneme-set{flex:1;min-height:0;overflow-y:auto;padding:8px 24px 48px;box-sizing:border-box}",
|
|
494
|
+
".mneme-set-inner{max-width:680px}",
|
|
495
|
+
".mneme-set-sec{padding:20px 0 24px;border-bottom:1px solid var(--dsw-alias-border-l2)}",
|
|
496
|
+
".mneme-set-sec:last-child{border-bottom:none}",
|
|
497
|
+
".mneme-set-title{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary);margin-bottom:4px}",
|
|
498
|
+
".mneme-set-desc{font-size:13px;line-height:19px;color:var(--dsw-alias-label-tertiary);margin-bottom:14px}",
|
|
499
|
+
".mneme-set-input{box-sizing:border-box;width:100%;height:34px;padding:0 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base,transparent);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:13px;outline:none;margin-bottom:10px;transition:border-color .12s,box-shadow .12s}",
|
|
500
|
+
".mneme-set-input:focus{border-color:var(--dsw-alias-state-business-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-state-business-primary) 15%,transparent)}",
|
|
501
|
+
".mneme-set-input::placeholder{color:var(--dsw-alias-label-tertiary)}",
|
|
502
|
+
".mneme-set-input--area{height:auto;min-height:76px;padding:8px 12px;resize:vertical;line-height:1.6}",
|
|
503
|
+
".mneme-btn{flex:none;height:30px;padding:0 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:none;color:var(--dsw-alias-label-primary);cursor:pointer;font-family:inherit;font-size:12px;line-height:16px;display:inline-flex;align-items:center;gap:6px}",
|
|
504
|
+
".mneme-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}",
|
|
505
|
+
".mneme-btn:disabled{opacity:.5;cursor:default}",
|
|
506
|
+
".mneme-saved{font-size:12px;color:var(--dsw-alias-state-success,#2a7)}",
|
|
507
|
+
// rule rows: numbered chips + hover-revealed delete
|
|
508
|
+
".mneme-set-row{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;margin-bottom:8px;background:var(--dsw-alias-bg-base,transparent)}",
|
|
509
|
+
".mneme-set-idx{flex:none;width:20px;height:20px;border-radius:50%;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:18px;display:inline-flex;align-items:center;justify-content:center;font-variant-numeric:tabular-nums}",
|
|
510
|
+
".mneme-set-ruletext{flex:1;min-width:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);word-break:break-word}",
|
|
511
|
+
".mneme-set-del{flex:none;opacity:0;transition:opacity .12s;border:none;background:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:14px;line-height:20px;padding:0 4px;border-radius:6px}",
|
|
512
|
+
".mneme-set-row:hover .mneme-set-del,.mneme-set-row:focus-within .mneme-set-del{opacity:1}",
|
|
513
|
+
".mneme-set-del:hover{color:var(--dsw-alias-state-error,#c33);background:var(--dsw-alias-interactive-bg-hover)}",
|
|
514
|
+
".mneme-set-cmd{flex:1;min-width:0}",
|
|
515
|
+
".mneme-set-cmdname{font-size:13px;font-weight:600;color:var(--dsw-alias-state-business-primary)}",
|
|
516
|
+
".mneme-set-cmddesc{font-size:12px;line-height:17px;color:var(--dsw-alias-label-tertiary);margin-top:1px;word-break:break-word}",
|
|
517
|
+
// --- settings sub-view ---
|
|
518
|
+
".mneme-set{flex:1;min-height:0;overflow-y:auto;padding:8px 24px 48px;box-sizing:border-box}",
|
|
519
|
+
// --- hero fallback: full-viewport memory library when no tab ring exists ---
|
|
520
|
+
// The host hides the whole conversation tab ring while a session is
|
|
521
|
+
// blank (hero screen), so the sidebar entry cannot activate the tab
|
|
522
|
+
// there. This surface reuses the exact MemoryExplorer UI at full size —
|
|
523
|
+
// not a side drawer — so the library stays reachable from any state.
|
|
524
|
+
".mneme-overlay{position:fixed;inset:0;z-index:1000;display:flex;flex-direction:column;background:var(--dsw-alias-bg-layer-1);animation:mneme-fadein .12s ease-out}",
|
|
525
|
+
".mneme-overlaybar{flex:none;display:flex;align-items:center;justify-content:space-between;height:44px;padding:0 12px 0 16px;border-bottom:1px solid var(--dsw-alias-border-l2)}",
|
|
526
|
+
".mneme-overlaytitle{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}",
|
|
527
|
+
".mneme-overlaybody{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden}",
|
|
528
|
+
"@keyframes mneme-fadein{from{opacity:0}to{opacity:1}}",
|
|
529
|
+
// --- explorer refresh: paged month tree, sticky headers, inline icons ---
|
|
530
|
+
".mneme-vtabico{flex:none;opacity:.85}",
|
|
531
|
+
".mneme-xsearchwrap{position:relative;flex:none}",
|
|
532
|
+
".mneme-xsearchico{position:absolute;left:9px;top:50%;transform:translateY(-50%);color:var(--dsw-alias-label-tertiary);pointer-events:none}",
|
|
533
|
+
".mneme-xsearchwrap .mneme-xsearch{padding-left:27px}",
|
|
534
|
+
".mneme-footbtn{display:inline-flex;align-items:center;gap:4px}",
|
|
535
|
+
".mneme-xmonth{position:sticky;top:0;z-index:2;background:var(--dsw-alias-bg-layer-1);display:flex;align-items:center;gap:6px}",
|
|
536
|
+
".mneme-xmonthcount{margin-left:auto;color:var(--dsw-alias-label-tertiary);font-weight:400;font-variant-numeric:tabular-nums}",
|
|
537
|
+
".mneme-xcaret{display:inline-flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-tertiary)}",
|
|
538
|
+
".mneme-xmore{display:flex;justify-content:center;align-items:center;padding:10px 0 24px;min-height:20px}",
|
|
539
|
+
".mneme-xemptyico{display:block;margin:0 auto 6px;opacity:.7}",
|
|
540
|
+
// --- status sub-view: responsive stat-card grid (auto-fill, ~220px min) ---
|
|
541
|
+
".mneme-status{flex:1;min-height:0;overflow-y:auto;padding:16px 20px 32px;box-sizing:border-box}",
|
|
542
|
+
".mneme-statusgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px;max-width:960px}",
|
|
543
|
+
".mneme-statuscard{min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:14px}",
|
|
544
|
+
".mneme-statusnum{font-size:24px;font-weight:600;line-height:32px;color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}",
|
|
545
|
+
".mneme-statuscap{margin-top:4px;font-size:13px;line-height:19px;color:var(--dsw-alias-label-tertiary);word-break:break-word}",
|
|
546
|
+
// --- destructive actions: red outline = delete, solid red = confirm ---
|
|
547
|
+
".mneme-btndanger{color:var(--dsw-alias-state-error,#c33);border-color:var(--dsw-alias-state-error,#c33)}",
|
|
548
|
+
".mneme-btndanger:hover{background:color-mix(in srgb,var(--dsw-alias-state-error,#c33) 12%,transparent)}",
|
|
549
|
+
".mneme-btndangerconfirm{background:var(--dsw-alias-state-error,#c33);border-color:var(--dsw-alias-state-error,#c33);color:#fff}",
|
|
550
|
+
".mneme-btndangerconfirm:hover{filter:brightness(.9)}"
|
|
551
|
+
].join("\n");
|
|
552
|
+
if (typeof document !== "undefined" && document.querySelector(`style[data-plugin-css="${CSS_TAG}"]`) === null) {
|
|
553
|
+
const tag = document.createElement("style");
|
|
554
|
+
tag.dataset.plugin = "@modusensus/dsh-mneme";
|
|
555
|
+
tag.dataset.pluginCss = CSS_TAG;
|
|
556
|
+
tag.textContent = css;
|
|
557
|
+
document.head.appendChild(tag);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const h = react.createElement;
|
|
561
|
+
|
|
562
|
+
// --- graph view constants ---
|
|
563
|
+
// Entity type → node fill. The host has no palette token for categorical
|
|
564
|
+
// data, so these are fixed hues tuned for both light and dark themes
|
|
565
|
+
// (medium saturation, similar luminance).
|
|
566
|
+
const TYPE_COLORS = {
|
|
567
|
+
person: "#3b82f6",
|
|
568
|
+
project: "#22c55e",
|
|
569
|
+
concept: "#a855f7",
|
|
570
|
+
technology: "#f59e0b",
|
|
571
|
+
organization: "#06b6d4"
|
|
572
|
+
};
|
|
573
|
+
function typeColor(type) {
|
|
574
|
+
return TYPE_COLORS[type] || "#94a3b8";
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Memory-type → dot fill, shared by the type filter, timeline rows and the
|
|
578
|
+
// detail meta so one hue always means one memory type across the panel.
|
|
579
|
+
// Same palette rules as TYPE_COLORS: fixed hues, medium saturation,
|
|
580
|
+
// similar luminance, legible on light and dark themes.
|
|
581
|
+
const MEMORY_TYPE_COLORS = {
|
|
582
|
+
preference: "#f59e0b",
|
|
583
|
+
project: "#22c55e",
|
|
584
|
+
decision: "#3b82f6",
|
|
585
|
+
summary: "#a855f7",
|
|
586
|
+
history: "#94a3b8"
|
|
587
|
+
};
|
|
588
|
+
function memoryTypeColor(type) {
|
|
589
|
+
return MEMORY_TYPE_COLORS[type] || "#94a3b8";
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Compact date for the detail meta ("2026/8/16"); the full timestamp
|
|
593
|
+
// stays in the tooltip.
|
|
594
|
+
function formatDateShort(value) {
|
|
595
|
+
if (!value) return "—";
|
|
596
|
+
const date = new Date(value);
|
|
597
|
+
return Number.isNaN(date.getTime()) ? "—" : date.toLocaleDateString();
|
|
598
|
+
}
|
|
599
|
+
// Graph canvas space: the svg's fixed viewBox. The simulation runs in
|
|
600
|
+
// these user units — never in CSS pixels — so node positions always land
|
|
601
|
+
// inside the drawn area; the browser scales the viewBox to whatever the
|
|
602
|
+
// element's CSS size is (small window, hidden tab, maximized #72).
|
|
603
|
+
const VIEW_W = 380, VIEW_H = 300;
|
|
604
|
+
|
|
605
|
+
function nodeRadius(n) {
|
|
606
|
+
// mention_count → area-ish growth, clamped so hubs stay legible.
|
|
607
|
+
return 7 + Math.min(20, Math.max(1, n.mention_count ?? 1)) * 0.55;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Deterministic golden-angle spiral: no two nodes start overlapping, and
|
|
611
|
+
// re-running the layout for the same data is stable (no random seeding).
|
|
612
|
+
function initialPositions(nodes, width, height) {
|
|
613
|
+
const cx = width / 2;
|
|
614
|
+
const cy = height / 2;
|
|
615
|
+
return nodes.map((n, i) => {
|
|
616
|
+
const r = i === 0 ? 0 : 34 + 13 * Math.sqrt(i);
|
|
617
|
+
const a = i * 2.39996;
|
|
618
|
+
return { ...n, x: cx + r * Math.cos(a), y: cy + r * Math.sin(a), vx: 0, vy: 0, pinned: false };
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// --- Entity explorer: the entity-gene layers made visible ---
|
|
623
|
+
// Rail = the entity directory (grouped by type, mention-ranked inside a
|
|
624
|
+
// type). Detail = the layered structure from docs/ENTITIES.md:
|
|
625
|
+
// ① 属性 current snapshot of entity_attrs (saveAttr invalidates old rows)
|
|
626
|
+
// ② 关系 entity_relations touching the entity (from the 1-hop ego walk)
|
|
627
|
+
// ③ 关联记忆 memories recalled through the entity: prefix search
|
|
628
|
+
// ④ 关系图谱 the same ego graph the old graph-only tab drew
|
|
629
|
+
function EntityPanel({ t, focusEntity, onJumpMemory }) {
|
|
630
|
+
const [entities, setEntities] = useState(null); // null = still loading
|
|
631
|
+
const [railQuery, setRailQuery] = useState("");
|
|
632
|
+
const [entityName, setEntityName] = useState(focusEntity || "");
|
|
633
|
+
const [depth, setDepth] = useState(1);
|
|
634
|
+
const [data, setData] = useState(null);
|
|
635
|
+
const [status, setStatus] = useState("idle"); // idle | loading | ready | notfound | error
|
|
636
|
+
const [selected, setSelected] = useState(null); // { kind: "node"|"edge", node?|edge? }
|
|
637
|
+
const [attrs, setAttrs] = useState([]);
|
|
638
|
+
const [related, setRelated] = useState([]);
|
|
639
|
+
const svgRef = useRef(null);
|
|
640
|
+
const posRef = useRef([]); // live simulation positions, not React state
|
|
641
|
+
const dragRef = useRef(null); // { id, moved }
|
|
642
|
+
const viewRef = useRef({ x: 0, y: 0, k: 1 }); // pan/zoom viewport, viewBox units
|
|
643
|
+
const [frame, setFrame] = useState(0); // re-render tick driven by the simulation
|
|
644
|
+
|
|
645
|
+
useEffect(() => {
|
|
646
|
+
let cancelled = false;
|
|
647
|
+
apiFetch("/api/dsh-mneme/entities?limit=500")
|
|
648
|
+
.then((r) => (r.ok ? r.json() : { entities: [] }))
|
|
649
|
+
.then((j) => { if (!cancelled) setEntities(Array.isArray(j.entities) ? j.entities : []); })
|
|
650
|
+
.catch(() => { if (!cancelled) setEntities([]); });
|
|
651
|
+
return () => { cancelled = true; };
|
|
652
|
+
}, []);
|
|
653
|
+
|
|
654
|
+
useEffect(() => {
|
|
655
|
+
if (focusEntity && focusEntity !== entityName) {
|
|
656
|
+
setEntityName(focusEntity);
|
|
657
|
+
}
|
|
658
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
659
|
+
}, [focusEntity]);
|
|
660
|
+
|
|
661
|
+
// Auto-select the most recently seen entity so the layers are never a
|
|
662
|
+
// dead end on first open (the rail still allows picking another one).
|
|
663
|
+
useEffect(() => {
|
|
664
|
+
if (!entities || entityName || entities.length === 0) return;
|
|
665
|
+
setEntityName(entities[0].name);
|
|
666
|
+
}, [entities, entityName]);
|
|
667
|
+
|
|
668
|
+
const load = useCallback(async (name, d) => {
|
|
669
|
+
if (!name) { setData(null); setStatus("idle"); return; }
|
|
670
|
+
setStatus("loading");
|
|
671
|
+
setSelected(null);
|
|
672
|
+
try {
|
|
673
|
+
const res = await apiFetch(`/api/dsh-mneme/semantic/graph/ego?entity=${encodeURIComponent(name)}&depth=${d}`);
|
|
674
|
+
if (res.status === 404) { setData(null); setStatus("notfound"); return; }
|
|
675
|
+
if (!res.ok) { setData(null); setStatus("error"); return; }
|
|
676
|
+
const json = await res.json();
|
|
677
|
+
setData(json);
|
|
678
|
+
setStatus("ready");
|
|
679
|
+
} catch {
|
|
680
|
+
setData(null);
|
|
681
|
+
setStatus("error");
|
|
682
|
+
}
|
|
683
|
+
}, []);
|
|
684
|
+
|
|
685
|
+
useEffect(() => { load(entityName, depth); }, [load, entityName, depth]);
|
|
686
|
+
|
|
687
|
+
// Layer data for the selected entity: current attrs + related memories.
|
|
688
|
+
useEffect(() => {
|
|
689
|
+
setAttrs([]);
|
|
690
|
+
setRelated([]);
|
|
691
|
+
if (!entityName) return;
|
|
692
|
+
let cancelled = false;
|
|
693
|
+
apiFetch(`/api/dsh-mneme/semantic/graph/entity-attrs?entity=${encodeURIComponent(entityName)}`)
|
|
694
|
+
.then((r) => (r.ok ? r.json() : { attrs: [] }))
|
|
695
|
+
.then((j) => { if (!cancelled) setAttrs(Array.isArray(j.attrs) ? j.attrs : []); })
|
|
696
|
+
.catch(() => {});
|
|
697
|
+
apiFetch(`/api/dsh-mneme/search?q=${encodeURIComponent("entity:" + entityName)}&limit=10`)
|
|
698
|
+
.then((r) => (r.ok ? r.json() : { items: [] }))
|
|
699
|
+
.then((j) => { if (!cancelled) setRelated(Array.isArray(j.items) ? j.items : []); })
|
|
700
|
+
.catch(() => {});
|
|
701
|
+
return () => { cancelled = true; };
|
|
702
|
+
}, [entityName]);
|
|
703
|
+
|
|
704
|
+
// Simulation: run in rAF against posRef, tick React only every few frames.
|
|
705
|
+
// Re-seeds when data changes; dragging writes straight into posRef.
|
|
706
|
+
useEffect(() => {
|
|
707
|
+
if (!data || data.nodes.length === 0) return;
|
|
708
|
+
// Bounds in viewBox units: measuring clientWidth here put the gravity
|
|
709
|
+
// center and clamp range outside the 380-wide viewBox once the window
|
|
710
|
+
// (or its hidden-then-shown container) got wide, and every node was
|
|
711
|
+
// clipped off-canvas — the maximized-invisible bug (#72).
|
|
712
|
+
posRef.current = initialPositions(data.nodes, VIEW_W, VIEW_H);
|
|
713
|
+
const byId = new Map(posRef.current.map((n) => [n.id, n]));
|
|
714
|
+
const edges = data.edges;
|
|
715
|
+
let raf = 0;
|
|
716
|
+
let n = 0;
|
|
717
|
+
const tick = () => {
|
|
718
|
+
const nodes = posRef.current;
|
|
719
|
+
// pairwise repulsion, capped so far nodes don't explode
|
|
720
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
721
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
722
|
+
const a = nodes[i], b = nodes[j];
|
|
723
|
+
let dx = b.x - a.x, dy = b.y - a.y;
|
|
724
|
+
let d2 = dx * dx + dy * dy;
|
|
725
|
+
if (d2 < 1) { dx = (Math.random() - 0.5) || 1; dy = (Math.random() - 0.5) || 1; d2 = dx * dx + dy * dy; }
|
|
726
|
+
const d = Math.sqrt(d2);
|
|
727
|
+
const f = Math.min(2200 / d2, 6);
|
|
728
|
+
const fx = (dx / d) * f, fy = (dy / d) * f;
|
|
729
|
+
a.vx -= fx; a.vy -= fy; b.vx += fx; b.vy += fy;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
// edge springs pull toward the target length
|
|
733
|
+
for (const e of edges) {
|
|
734
|
+
const a = byId.get(e.from), b = byId.get(e.to);
|
|
735
|
+
if (!a || !b) continue;
|
|
736
|
+
const dx = b.x - a.x, dy = b.y - a.y;
|
|
737
|
+
const d = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
738
|
+
const f = (d - 90) * 0.02;
|
|
739
|
+
const fx = (dx / d) * f, fy = (dy / d) * f;
|
|
740
|
+
a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy;
|
|
741
|
+
}
|
|
742
|
+
let energy = 0;
|
|
743
|
+
for (const node of nodes) {
|
|
744
|
+
// gentle gravity toward center keeps the cloud from drifting off-canvas
|
|
745
|
+
node.vx += (VIEW_W / 2 - node.x) * 0.002;
|
|
746
|
+
node.vy += (VIEW_H / 2 - node.y) * 0.002;
|
|
747
|
+
if (node.pinned || node === dragRef.current?.node) { node.vx = 0; node.vy = 0; continue; }
|
|
748
|
+
node.vx *= 0.85; node.vy *= 0.85;
|
|
749
|
+
node.x = Math.max(nodeRadius(node) + 4, Math.min(VIEW_W - nodeRadius(node) - 4, node.x + node.vx));
|
|
750
|
+
node.y = Math.max(nodeRadius(node) + 14, Math.min(VIEW_H - nodeRadius(node) - 14, node.y + node.vy));
|
|
751
|
+
energy += Math.abs(node.vx) + Math.abs(node.vy);
|
|
752
|
+
}
|
|
753
|
+
n++;
|
|
754
|
+
if (n % 3 === 0) setFrame((f) => f + 1);
|
|
755
|
+
if (n < 300 && energy > 0.4) raf = requestAnimationFrame(tick);
|
|
756
|
+
};
|
|
757
|
+
raf = requestAnimationFrame(tick);
|
|
758
|
+
return () => cancelAnimationFrame(raf);
|
|
759
|
+
}, [data]);
|
|
760
|
+
|
|
761
|
+
// drag handling on the svg surface
|
|
762
|
+
const onNodeMouseDown = (e, node) => {
|
|
763
|
+
e.preventDefault();
|
|
764
|
+
e.stopPropagation(); // node drag must not start a background pan
|
|
765
|
+
dragRef.current = { node, moved: false };
|
|
766
|
+
const startX = e.clientX, startY = e.clientY;
|
|
767
|
+
const origX = node.x, origY = node.y;
|
|
768
|
+
const svg = svgRef.current;
|
|
769
|
+
const rect = svg.getBoundingClientRect();
|
|
770
|
+
const scale = VIEW_W / Math.max(1, rect.width) / viewRef.current.k; // viewBox units per css px, zoom-aware
|
|
771
|
+
const onMove = (ev) => {
|
|
772
|
+
dragRef.current.moved = true;
|
|
773
|
+
node.x = origX + (ev.clientX - startX) * scale;
|
|
774
|
+
node.y = origY + (ev.clientY - startY) * scale;
|
|
775
|
+
setFrame((f) => f + 1);
|
|
776
|
+
};
|
|
777
|
+
const onUp = () => {
|
|
778
|
+
window.removeEventListener("mousemove", onMove);
|
|
779
|
+
window.removeEventListener("mouseup", onUp);
|
|
780
|
+
setTimeout(() => { dragRef.current = null; }, 0);
|
|
781
|
+
};
|
|
782
|
+
window.addEventListener("mousemove", onMove);
|
|
783
|
+
window.addEventListener("mouseup", onUp);
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
const onNodeClick = (node) => {
|
|
787
|
+
if (dragRef.current?.moved) return; // it was a drag, not a click
|
|
788
|
+
setSelected({ kind: "node", node });
|
|
789
|
+
};
|
|
790
|
+
const onEdgeClick = (edge) => setSelected({ kind: "edge", edge });
|
|
791
|
+
|
|
792
|
+
// --- canvas pan & zoom ---
|
|
793
|
+
// The viewport (translate + scale) lives in viewRef so the simulation
|
|
794
|
+
// and the handlers share it without re-mounting; every mutation bumps
|
|
795
|
+
// `frame` to re-render. Panning starts on the bare svg surface (node
|
|
796
|
+
// mousedowns stopPropagation above); zooming is a native non-passive
|
|
797
|
+
// wheel listener because React attaches wheel handlers passively and
|
|
798
|
+
// could not preventDefault the page scroll.
|
|
799
|
+
const svgToView = (clientX, clientY) => {
|
|
800
|
+
const svg = svgRef.current;
|
|
801
|
+
const rect = svg.getBoundingClientRect();
|
|
802
|
+
const sx = VIEW_W / Math.max(1, rect.width);
|
|
803
|
+
const sy = VIEW_H / Math.max(1, rect.height);
|
|
804
|
+
return { x: (clientX - rect.left) * sx, y: (clientY - rect.top) * sy };
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
const onSurfaceMouseDown = (e) => {
|
|
808
|
+
if (e.button !== 0) return;
|
|
809
|
+
e.preventDefault();
|
|
810
|
+
const start = { x: e.clientX, y: e.clientY };
|
|
811
|
+
const orig = { ...viewRef.current };
|
|
812
|
+
const onMove = (ev) => {
|
|
813
|
+
const rect = svgRef.current?.getBoundingClientRect();
|
|
814
|
+
if (!rect) return;
|
|
815
|
+
const sx = VIEW_W / Math.max(1, rect.width);
|
|
816
|
+
const sy = VIEW_H / Math.max(1, rect.height);
|
|
817
|
+
const v = viewRef.current;
|
|
818
|
+
v.x = orig.x + (ev.clientX - start.x) * sx;
|
|
819
|
+
v.y = orig.y + (ev.clientY - start.y) * sy;
|
|
820
|
+
setFrame((f) => f + 1);
|
|
821
|
+
};
|
|
822
|
+
const onUp = () => {
|
|
823
|
+
window.removeEventListener("mousemove", onMove);
|
|
824
|
+
window.removeEventListener("mouseup", onUp);
|
|
825
|
+
};
|
|
826
|
+
window.addEventListener("mousemove", onMove);
|
|
827
|
+
window.addEventListener("mouseup", onUp);
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
useEffect(() => {
|
|
831
|
+
const svg = svgRef.current;
|
|
832
|
+
if (!svg) return undefined;
|
|
833
|
+
const onWheel = (e) => {
|
|
834
|
+
e.preventDefault();
|
|
835
|
+
const v = viewRef.current;
|
|
836
|
+
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
|
837
|
+
const k = Math.min(3, Math.max(0.5, v.k * factor));
|
|
838
|
+
const s = svgToView(e.clientX, e.clientY);
|
|
839
|
+
// keep the point under the cursor pinned while the scale changes
|
|
840
|
+
const px = (s.x - v.x) / v.k;
|
|
841
|
+
const py = (s.y - v.y) / v.k;
|
|
842
|
+
v.k = k;
|
|
843
|
+
v.x = s.x - px * k;
|
|
844
|
+
v.y = s.y - py * k;
|
|
845
|
+
setFrame((f) => f + 1);
|
|
846
|
+
};
|
|
847
|
+
svg.addEventListener("wheel", onWheel, { passive: false });
|
|
848
|
+
return () => svg.removeEventListener("wheel", onWheel);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
const resetView = () => {
|
|
852
|
+
viewRef.current = { x: 0, y: 0, k: 1 };
|
|
853
|
+
setFrame((f) => f + 1);
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
const nodes = posRef.current;
|
|
857
|
+
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
|
858
|
+
|
|
859
|
+
// --- rail: entity directory grouped by type ---
|
|
860
|
+
const q = railQuery.trim().toLowerCase();
|
|
861
|
+
const visibleEntities = (entities || []).filter((e) => !q || (e.name || "").toLowerCase().includes(q));
|
|
862
|
+
const ENTITY_TYPE_ORDER = ["person", "project", "concept", "technology", "organization"];
|
|
863
|
+
const groups = [];
|
|
864
|
+
const byType = new Map();
|
|
865
|
+
for (const e of visibleEntities) {
|
|
866
|
+
const key = ENTITY_TYPE_ORDER.includes(e.type) ? e.type : "other";
|
|
867
|
+
if (!byType.has(key)) { const g = { key, items: [] }; byType.set(key, g); groups.push(g); }
|
|
868
|
+
byType.get(key).items.push(e);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const entity = entities?.find((e) => e.name === entityName) || null;
|
|
872
|
+
const rootEdges = (data?.edges || []).filter((e) => e.from === data?.root?.id || e.to === data?.root?.id);
|
|
873
|
+
const graphStatus = status === "ready" && data && data.nodes.length <= 1 ? "empty" : status;
|
|
874
|
+
|
|
875
|
+
const rail = h("div", { className: "mneme-entrail" },
|
|
876
|
+
h("div", { className: "mneme-xcolhead" }, t("memory.view.label")),
|
|
877
|
+
h("input", {
|
|
878
|
+
className: "mneme-search mneme-xsearch",
|
|
879
|
+
placeholder: t("memory.graph.placeholder"),
|
|
880
|
+
value: railQuery,
|
|
881
|
+
onChange: (e) => setRailQuery(e.target.value)
|
|
882
|
+
}),
|
|
883
|
+
entities === null && h("div", { className: "mneme-xempty" }, "…"),
|
|
884
|
+
entities !== null && entities.length === 0 && h("div", { className: "mneme-xempty" }, t("memory.entities.none")),
|
|
885
|
+
entities !== null && entities.length > 0 && h("div", { className: "mneme-xcount" },
|
|
886
|
+
t("memory.entities.count").replace("{n}", String(entities.length))),
|
|
887
|
+
groups.map((g) => h("div", { key: g.key },
|
|
888
|
+
h("div", { className: "mneme-enttype" },
|
|
889
|
+
h("span", { className: "mneme-xdot", style: { color: typeColor(g.key === "other" ? null : g.key) }, "aria-hidden": "true" }),
|
|
890
|
+
entityTypeLabel(t, g.key)
|
|
891
|
+
),
|
|
892
|
+
g.items.map((e) => h("button", {
|
|
893
|
+
key: e.id,
|
|
894
|
+
className: e.name === entityName ? "mneme-entitem mneme-active" : "mneme-entitem",
|
|
895
|
+
onClick: () => setEntityName(e.name)
|
|
896
|
+
},
|
|
897
|
+
h("span", { className: "mneme-entname" }, e.name),
|
|
898
|
+
h("span", { className: "mneme-entmentions" }, `×${e.mention_count ?? 1}`)
|
|
899
|
+
))
|
|
900
|
+
))
|
|
901
|
+
);
|
|
902
|
+
|
|
903
|
+
// side card for a clicked (non-root) node or edge inside the graph layer
|
|
904
|
+
const side = selected?.kind === "node"
|
|
905
|
+
? h("div", { className: "mneme-graphside" },
|
|
906
|
+
h("div", { className: "mneme-gs-title" },
|
|
907
|
+
h("span", null, selected.node.name),
|
|
908
|
+
h("span", { className: "mneme-gs-meta" },
|
|
909
|
+
`${entityTypeLabel(t, selected.node.type || "concept")} · ★${selected.node.mention_count ?? 1}`)
|
|
910
|
+
),
|
|
911
|
+
selected.node.name !== entityName && h("button", {
|
|
912
|
+
className: "mneme-footbtn",
|
|
913
|
+
onClick: () => setEntityName(selected.node.name)
|
|
914
|
+
}, t("memory.graph.viewInGraph")),
|
|
915
|
+
attrs.length > 0 && h("div", null,
|
|
916
|
+
h("div", { className: "mneme-gs-meta" }, t("memory.graph.attrs")),
|
|
917
|
+
attrs.map((a, i) => h("div", { key: i, className: "mneme-gs-attr" },
|
|
918
|
+
h("span", { className: "mneme-gs-attrkey" }, `${a.key}:`),
|
|
919
|
+
h("span", { className: "mneme-gs-attrval" }, a.value)
|
|
920
|
+
))
|
|
921
|
+
)
|
|
922
|
+
)
|
|
923
|
+
: selected?.kind === "edge"
|
|
924
|
+
? h("div", { className: "mneme-graphside" },
|
|
925
|
+
h("div", { className: "mneme-gs-title" },
|
|
926
|
+
h("span", null,
|
|
927
|
+
`${nodeById.get(selected.edge.from)?.name ?? "?"} → ${selected.edge.relation_type} → ${nodeById.get(selected.edge.to)?.name ?? "?"}`)
|
|
928
|
+
),
|
|
929
|
+
h("div", { className: "mneme-gs-meta" },
|
|
930
|
+
`${t("memory.graph.relation")} · ${formatRelativeTime(selected.edge.created_at, t)}`),
|
|
931
|
+
selected.edge.memory_id && h("button", {
|
|
932
|
+
className: "mneme-footbtn",
|
|
933
|
+
onClick: () => onJumpMemory && onJumpMemory({ id: selected.edge.memory_id })
|
|
934
|
+
}, t("memory.graph.sourceMemory"))
|
|
935
|
+
)
|
|
936
|
+
: null;
|
|
937
|
+
|
|
938
|
+
const detail = h("div", { className: "mneme-entdetail" },
|
|
939
|
+
h("div", { className: "mneme-entinner" },
|
|
940
|
+
status === "idle" && h("div", { className: "mneme-hint" }, t("memory.entities.pick")),
|
|
941
|
+
status === "loading" && h("div", { className: "mneme-hint" }, t("memory.graph.loading")),
|
|
942
|
+
status === "notfound" && h("div", { className: "mneme-hint" }, t("memory.graph.notFound")),
|
|
943
|
+
status === "error" && h("div", { className: "mneme-hint" }, t("memory.panel.empty")),
|
|
944
|
+
status === "ready" && entity && h(react.Fragment, null,
|
|
945
|
+
h("div", { className: "mneme-enttitle" },
|
|
946
|
+
h("span", { className: "mneme-xdot", style: { color: typeColor(entity.type) }, "aria-hidden": "true" }),
|
|
947
|
+
entity.name
|
|
948
|
+
),
|
|
949
|
+
h("div", { className: "mneme-entmeta" },
|
|
950
|
+
h("span", null, entityTypeLabel(t, entity.type || "other")),
|
|
951
|
+
h("span", null, t("memory.entities.mentions").replace("{n}", String(entity.mention_count ?? 1))),
|
|
952
|
+
h("span", { title: formatDate(entity.last_seen) },
|
|
953
|
+
`${t("memory.entities.lastSeen")}: ${formatRelativeTime(entity.last_seen, t)}`)
|
|
954
|
+
),
|
|
955
|
+
// ① 属性 — current snapshot (valid_until IS NULL)
|
|
956
|
+
h("div", { className: "mneme-layer" },
|
|
957
|
+
h("div", { className: "mneme-layerhead" },
|
|
958
|
+
t("memory.entities.attrs"),
|
|
959
|
+
h("span", { className: "mneme-layercount" }, String(attrs.length))
|
|
960
|
+
),
|
|
961
|
+
attrs.length === 0
|
|
962
|
+
? h("div", { className: "mneme-xempty" }, t("memory.settings.empty"))
|
|
963
|
+
: h("div", { className: "mneme-attrgrid" },
|
|
964
|
+
attrs.map((a, i) => h("div", { key: i, className: "mneme-attrcard", title: a.value },
|
|
965
|
+
h("div", { className: "mneme-attrkey" }, a.key),
|
|
966
|
+
h("div", { className: "mneme-attrval" }, a.value)
|
|
967
|
+
))
|
|
968
|
+
)
|
|
969
|
+
),
|
|
970
|
+
// ② 关系 — both directions from the 1-hop ego walk
|
|
971
|
+
h("div", { className: "mneme-layer" },
|
|
972
|
+
h("div", { className: "mneme-layerhead" },
|
|
973
|
+
t("memory.entities.relations"),
|
|
974
|
+
h("span", { className: "mneme-layercount" }, String(rootEdges.length))
|
|
975
|
+
),
|
|
976
|
+
rootEdges.length === 0
|
|
977
|
+
? h("div", { className: "mneme-xempty" }, t("memory.settings.empty"))
|
|
978
|
+
: rootEdges.map((e) => {
|
|
979
|
+
const out = e.from === data.root.id;
|
|
980
|
+
const other = nodeById.get(out ? e.to : e.from);
|
|
981
|
+
const otherName = other?.name ?? (out ? e.to : e.from);
|
|
982
|
+
return h("button", {
|
|
983
|
+
key: e.id,
|
|
984
|
+
className: "mneme-relrow",
|
|
985
|
+
title: e.memory_id ? t("memory.graph.sourceMemory") : undefined,
|
|
986
|
+
onClick: () => { if (e.memory_id && onJumpMemory) onJumpMemory({ id: e.memory_id }); }
|
|
987
|
+
},
|
|
988
|
+
h("span", { className: "mneme-relname", style: { textAlign: "right", flex: 1 } }, entity.name),
|
|
989
|
+
h("span", { className: "mneme-reltype" }, e.relation_type),
|
|
990
|
+
h("span", { className: "mneme-relname", style: { flex: 1 } }, otherName)
|
|
991
|
+
);
|
|
992
|
+
})
|
|
993
|
+
),
|
|
994
|
+
// ③ 关联记忆 — entity: prefix recall
|
|
995
|
+
related.length > 0 && h("div", { className: "mneme-layer" },
|
|
996
|
+
h("div", { className: "mneme-layerhead" },
|
|
997
|
+
t("memory.graph.related"),
|
|
998
|
+
h("span", { className: "mneme-layercount" }, String(related.length))
|
|
999
|
+
),
|
|
1000
|
+
related.map((m) => h("button", {
|
|
1001
|
+
key: m.id,
|
|
1002
|
+
type: "button",
|
|
1003
|
+
className: "mneme-gs-link",
|
|
1004
|
+
title: t("memory.card.open"),
|
|
1005
|
+
onClick: () => onJumpMemory && onJumpMemory(m)
|
|
1006
|
+
}, m.title || m.content?.slice(0, 60)))
|
|
1007
|
+
),
|
|
1008
|
+
// ④ 关系图谱 — the ego graph
|
|
1009
|
+
h("div", { className: "mneme-layer" },
|
|
1010
|
+
h("div", { className: "mneme-layerhead" },
|
|
1011
|
+
t("memory.entities.graph"),
|
|
1012
|
+
h("span", { className: "mneme-layercount" },
|
|
1013
|
+
h("button", {
|
|
1014
|
+
className: depth === 2 ? "mneme-chip mneme-active" : "mneme-chip",
|
|
1015
|
+
title: t("memory.graph.depth"),
|
|
1016
|
+
onClick: () => setDepth(depth === 1 ? 2 : 1)
|
|
1017
|
+
}, `${depth} ${t("memory.graph.depth")}`)),
|
|
1018
|
+
h("span", { className: "mneme-layercount" },
|
|
1019
|
+
h("button", {
|
|
1020
|
+
className: "mneme-chip",
|
|
1021
|
+
title: t("memory.graph.resetView"),
|
|
1022
|
+
onClick: resetView
|
|
1023
|
+
}, t("memory.graph.resetView")))
|
|
1024
|
+
),
|
|
1025
|
+
graphStatus === "empty"
|
|
1026
|
+
? h("div", { className: "mneme-hint" }, t("memory.graph.empty"))
|
|
1027
|
+
: h(react.Fragment, null,
|
|
1028
|
+
h("svg", {
|
|
1029
|
+
ref: svgRef,
|
|
1030
|
+
className: "mneme-graphsvg",
|
|
1031
|
+
viewBox: `0 0 ${VIEW_W} ${VIEW_H}`,
|
|
1032
|
+
style: { height: 320 },
|
|
1033
|
+
onMouseDown: onSurfaceMouseDown
|
|
1034
|
+
},
|
|
1035
|
+
h("g", {
|
|
1036
|
+
transform: `translate(${viewRef.current.x},${viewRef.current.y}) scale(${viewRef.current.k})`
|
|
1037
|
+
},
|
|
1038
|
+
data.edges.map((e) => {
|
|
1039
|
+
const a = nodeById.get(e.from), b = nodeById.get(e.to);
|
|
1040
|
+
if (!a || !b) return null;
|
|
1041
|
+
return h("line", {
|
|
1042
|
+
key: e.id,
|
|
1043
|
+
x1: a.x, y1: a.y, x2: b.x, y2: b.y,
|
|
1044
|
+
className: e.memory_id ? "mneme-gedge" : "mneme-gedge mneme-gedge-dashed",
|
|
1045
|
+
onClick: () => onEdgeClick(e)
|
|
1046
|
+
});
|
|
1047
|
+
}),
|
|
1048
|
+
nodes.map((n) => h("g", {
|
|
1049
|
+
key: n.id,
|
|
1050
|
+
className: n.id === data.root.id ? "mneme-gnode mneme-groot" : "mneme-gnode",
|
|
1051
|
+
transform: `translate(${n.x},${n.y})`,
|
|
1052
|
+
onMouseDown: (e) => onNodeMouseDown(e, n),
|
|
1053
|
+
onClick: () => onNodeClick(n),
|
|
1054
|
+
"data-node": n.name
|
|
1055
|
+
},
|
|
1056
|
+
h("circle", { r: nodeRadius(n), fill: typeColor(n.type) }),
|
|
1057
|
+
h("text", { className: "mneme-glabel", y: nodeRadius(n) + 13 }, n.name)
|
|
1058
|
+
))
|
|
1059
|
+
)
|
|
1060
|
+
),
|
|
1061
|
+
h("div", { className: "mneme-graphhint" }, t("memory.graph.hint"))
|
|
1062
|
+
),
|
|
1063
|
+
side
|
|
1064
|
+
)
|
|
1065
|
+
)
|
|
1066
|
+
)
|
|
1067
|
+
);
|
|
1068
|
+
|
|
1069
|
+
return h("div", { className: "mneme-ent" }, rail, detail);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
function SettingsContent({ t }) {
|
|
1074
|
+
const [profile, setProfile] = react.useState("");
|
|
1075
|
+
const [rules, setRules] = react.useState([]);
|
|
1076
|
+
const [commands, setCommands] = react.useState([]);
|
|
1077
|
+
const [newRule, setNewRule] = react.useState("");
|
|
1078
|
+
const [newCmd, setNewCmd] = react.useState({ name: "", description: "", instruction: "" });
|
|
1079
|
+
const [saved, setSaved] = react.useState(false);
|
|
1080
|
+
const [cmdError, setCmdError] = react.useState("");
|
|
1081
|
+
const [vector, setVector] = react.useState({ enabled: false, baseUrl: "", apiKey: "", model: "" });
|
|
1082
|
+
const [vectorSaved, setVectorSaved] = react.useState(false);
|
|
1083
|
+
const [reindexing, setReindexing] = react.useState(false);
|
|
1084
|
+
const [reindexMsg, setReindexMsg] = react.useState("");
|
|
1085
|
+
const [apiToken, setApiToken] = react.useState(() =>
|
|
1086
|
+
(typeof window !== "undefined" && window.localStorage) ? window.localStorage.getItem("dsh-mneme-api-token") || "" : ""
|
|
1087
|
+
);
|
|
1088
|
+
const [apiTokenSaved, setApiTokenSaved] = react.useState(false);
|
|
1089
|
+
|
|
1090
|
+
const load = react.useCallback(async () => {
|
|
1091
|
+
try {
|
|
1092
|
+
const [p, r, c, v] = await Promise.all([
|
|
1093
|
+
apiFetch("/api/dsh-mneme/profile").then((res) => res.json()),
|
|
1094
|
+
apiFetch("/api/dsh-mneme/rules").then((res) => res.json()),
|
|
1095
|
+
apiFetch("/api/dsh-mneme/commands").then((res) => res.json()),
|
|
1096
|
+
apiFetch("/api/dsh-mneme/vector-config").then((res) => res.json())
|
|
1097
|
+
]);
|
|
1098
|
+
setProfile(p.profile || "");
|
|
1099
|
+
setRules(Array.isArray(r.rules) ? r.rules : []);
|
|
1100
|
+
setCommands(Array.isArray(c.commands) ? c.commands : []);
|
|
1101
|
+
setVector(v.config || { enabled: false, baseUrl: "", apiKey: "", model: "" });
|
|
1102
|
+
} catch { /* ignore */ }
|
|
1103
|
+
}, []);
|
|
1104
|
+
|
|
1105
|
+
react.useEffect(() => { load(); }, [load]);
|
|
1106
|
+
|
|
1107
|
+
async function saveProfile() {
|
|
1108
|
+
try {
|
|
1109
|
+
await apiFetch("/api/dsh-mneme/profile", {
|
|
1110
|
+
method: "PUT",
|
|
1111
|
+
headers: { "Content-Type": "application/json" },
|
|
1112
|
+
body: JSON.stringify({ profile })
|
|
1113
|
+
});
|
|
1114
|
+
setSaved(true);
|
|
1115
|
+
setTimeout(() => setSaved(false), 1500);
|
|
1116
|
+
} catch { /* ignore */ }
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
async function putRules(next) {
|
|
1120
|
+
await apiFetch("/api/dsh-mneme/rules", {
|
|
1121
|
+
method: "PUT",
|
|
1122
|
+
headers: { "Content-Type": "application/json" },
|
|
1123
|
+
body: JSON.stringify({ rules: next })
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
async function addRule() {
|
|
1128
|
+
const text = newRule.trim();
|
|
1129
|
+
if (!text) return;
|
|
1130
|
+
const next = [...rules, text];
|
|
1131
|
+
await putRules(next);
|
|
1132
|
+
setRules(next);
|
|
1133
|
+
setNewRule("");
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
async function removeRule(index) {
|
|
1137
|
+
const next = rules.filter((_, i) => i !== index);
|
|
1138
|
+
await putRules(next);
|
|
1139
|
+
setRules(next);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
async function addCommand() {
|
|
1143
|
+
const name = newCmd.name.trim();
|
|
1144
|
+
const instruction = newCmd.instruction.trim();
|
|
1145
|
+
if (!name || !instruction) return;
|
|
1146
|
+
try {
|
|
1147
|
+
const res = await apiFetch("/api/dsh-mneme/commands", {
|
|
1148
|
+
method: "POST",
|
|
1149
|
+
headers: { "Content-Type": "application/json" },
|
|
1150
|
+
body: JSON.stringify({ name, description: newCmd.description, instruction })
|
|
1151
|
+
});
|
|
1152
|
+
const data = await res.json();
|
|
1153
|
+
if (!res.ok) { setCmdError(data.error || "failed"); return; }
|
|
1154
|
+
setCmdError("");
|
|
1155
|
+
setCommands([...commands, data.command]);
|
|
1156
|
+
setNewCmd({ name: "", description: "", instruction: "" });
|
|
1157
|
+
} catch { setCmdError("failed"); }
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
async function removeCommand(id) {
|
|
1161
|
+
await apiFetch(`/api/dsh-mneme/commands?id=${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
1162
|
+
setCommands(commands.filter((c) => c.id !== id));
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
async function saveVector() {
|
|
1166
|
+
try {
|
|
1167
|
+
await apiFetch("/api/dsh-mneme/vector-config", {
|
|
1168
|
+
method: "PUT",
|
|
1169
|
+
headers: { "Content-Type": "application/json" },
|
|
1170
|
+
body: JSON.stringify(vector)
|
|
1171
|
+
});
|
|
1172
|
+
setVectorSaved(true);
|
|
1173
|
+
setTimeout(() => setVectorSaved(false), 1500);
|
|
1174
|
+
} catch { /* ignore */ }
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
async function reindex() {
|
|
1178
|
+
setReindexing(true);
|
|
1179
|
+
setReindexMsg("");
|
|
1180
|
+
try {
|
|
1181
|
+
const res = await apiFetch("/api/dsh-mneme/vector-reindex");
|
|
1182
|
+
const data = await res.json();
|
|
1183
|
+
const n = data.indexed ?? 0;
|
|
1184
|
+
setReindexMsg(t("memory.settings.vectorReindexDone").replace("{n}", String(n)));
|
|
1185
|
+
} catch { setReindexMsg(""); }
|
|
1186
|
+
setReindexing(false);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function saveToken() {
|
|
1190
|
+
try {
|
|
1191
|
+
if (apiToken.trim()) window.localStorage.setItem("dsh-mneme-api-token", apiToken.trim());
|
|
1192
|
+
else window.localStorage.removeItem("dsh-mneme-api-token");
|
|
1193
|
+
setApiTokenSaved(true);
|
|
1194
|
+
setTimeout(() => setApiTokenSaved(false), 1500);
|
|
1195
|
+
} catch { /* ignore */ }
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
return h("div", null,
|
|
1199
|
+
// 用户画像 — who the agent is talking to
|
|
1200
|
+
h("section", { className: "mneme-set-sec" },
|
|
1201
|
+
h("div", { className: "mneme-set-title" }, t("memory.settings.profile")),
|
|
1202
|
+
h("div", { className: "mneme-set-desc" }, t("memory.settings.profileHint")),
|
|
1203
|
+
h("textarea", {
|
|
1204
|
+
className: "mneme-set-input mneme-set-input--area",
|
|
1205
|
+
value: profile,
|
|
1206
|
+
placeholder: t("memory.settings.profile"),
|
|
1207
|
+
onChange: (e) => setProfile(e.target.value)
|
|
1208
|
+
}),
|
|
1209
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: 8 } },
|
|
1210
|
+
h("button", { className: "mneme-btn", onClick: saveProfile }, t("memory.settings.profileSave")),
|
|
1211
|
+
saved && h("span", { className: "mneme-saved" }, t("memory.settings.profileSaved"))
|
|
1212
|
+
)
|
|
1213
|
+
),
|
|
1214
|
+
// 规则 — numbered rows, hover reveals the delete affordance
|
|
1215
|
+
h("section", { className: "mneme-set-sec" },
|
|
1216
|
+
h("div", { className: "mneme-set-title" }, t("memory.settings.rules")),
|
|
1217
|
+
h("div", { className: "mneme-set-desc" }, t("memory.settings.rulesHint")),
|
|
1218
|
+
rules.length === 0 && h("div", { className: "mneme-xempty" }, t("memory.settings.empty")),
|
|
1219
|
+
rules.map((rule, i) =>
|
|
1220
|
+
h("div", { key: i, className: "mneme-set-row" },
|
|
1221
|
+
h("span", { className: "mneme-set-idx" }, String(i + 1)),
|
|
1222
|
+
h("span", { className: "mneme-set-ruletext" }, rule),
|
|
1223
|
+
h("button", {
|
|
1224
|
+
className: "mneme-set-del",
|
|
1225
|
+
title: t("memory.settings.cmdDelete"),
|
|
1226
|
+
"aria-label": t("memory.settings.cmdDelete"),
|
|
1227
|
+
onClick: () => removeRule(i)
|
|
1228
|
+
}, "×")
|
|
1229
|
+
)
|
|
1230
|
+
),
|
|
1231
|
+
h("div", { style: { display: "flex", gap: 8 } },
|
|
1232
|
+
h("input", {
|
|
1233
|
+
className: "mneme-set-input",
|
|
1234
|
+
style: { marginBottom: 0 },
|
|
1235
|
+
value: newRule,
|
|
1236
|
+
placeholder: t("memory.settings.rulePlaceholder"),
|
|
1237
|
+
onChange: (e) => setNewRule(e.target.value),
|
|
1238
|
+
onKeyDown: (e) => { if (e.key === "Enter") addRule(); }
|
|
1239
|
+
}),
|
|
1240
|
+
h("button", { className: "mneme-btn", onClick: addRule }, t("memory.settings.ruleAdd"))
|
|
1241
|
+
)
|
|
1242
|
+
),
|
|
1243
|
+
// 自定义指令 — slash commands as titled rows
|
|
1244
|
+
h("section", { className: "mneme-set-sec" },
|
|
1245
|
+
h("div", { className: "mneme-set-title" }, t("memory.settings.commands")),
|
|
1246
|
+
h("div", { className: "mneme-set-desc" }, t("memory.settings.commandsHint")),
|
|
1247
|
+
commands.length === 0 && h("div", { className: "mneme-xempty" }, t("memory.settings.empty")),
|
|
1248
|
+
commands.map((cmd) =>
|
|
1249
|
+
h("div", { key: cmd.id, className: "mneme-set-row" },
|
|
1250
|
+
h("div", { className: "mneme-set-cmd" },
|
|
1251
|
+
h("div", { className: "mneme-set-cmdname" }, `/${cmd.name}`),
|
|
1252
|
+
(cmd.description || cmd.instruction) && h("div", { className: "mneme-set-cmddesc" }, cmd.description || cmd.instruction)
|
|
1253
|
+
),
|
|
1254
|
+
h("button", {
|
|
1255
|
+
className: "mneme-set-del",
|
|
1256
|
+
title: t("memory.settings.cmdDelete"),
|
|
1257
|
+
"aria-label": t("memory.settings.cmdDelete"),
|
|
1258
|
+
onClick: () => removeCommand(cmd.id)
|
|
1259
|
+
}, "×")
|
|
1260
|
+
)
|
|
1261
|
+
),
|
|
1262
|
+
h("div", { style: { display: "grid", gap: 8, marginTop: 10 } },
|
|
1263
|
+
h("div", { style: { display: "flex", gap: 8 } },
|
|
1264
|
+
h("input", { className: "mneme-set-input", style: { marginBottom: 0 }, value: newCmd.name, placeholder: t("memory.settings.cmdName"), onChange: (e) => setNewCmd({ ...newCmd, name: e.target.value }) }),
|
|
1265
|
+
h("input", { className: "mneme-set-input", style: { marginBottom: 0 }, value: newCmd.description, placeholder: t("memory.settings.cmdDesc"), onChange: (e) => setNewCmd({ ...newCmd, description: e.target.value }) })
|
|
1266
|
+
),
|
|
1267
|
+
h("textarea", { className: "mneme-set-input mneme-set-input--area", style: { minHeight: 56 }, value: newCmd.instruction, placeholder: t("memory.settings.cmdInstruction"), onChange: (e) => setNewCmd({ ...newCmd, instruction: e.target.value }) }),
|
|
1268
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: 8 } },
|
|
1269
|
+
h("button", { className: "mneme-btn", onClick: addCommand }, t("memory.settings.cmdAdd")),
|
|
1270
|
+
cmdError && h("span", { style: { fontSize: 12, color: "var(--dsw-alias-state-error, #c33)" } }, cmdError)
|
|
1271
|
+
)
|
|
1272
|
+
)
|
|
1273
|
+
),
|
|
1274
|
+
// 向量搜索 — semantic recall over an embeddings API
|
|
1275
|
+
h("section", { className: "mneme-set-sec" },
|
|
1276
|
+
h("div", { className: "mneme-set-title" }, t("memory.settings.vectorTitle")),
|
|
1277
|
+
h("div", { className: "mneme-set-desc" }, t("memory.settings.vectorHint")),
|
|
1278
|
+
h("label", { style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 10, fontSize: 13, color: "var(--dsw-alias-label-primary)" } },
|
|
1279
|
+
h("input", { type: "checkbox", checked: !!vector.enabled, onChange: (e) => setVector({ ...vector, enabled: e.target.checked }) }),
|
|
1280
|
+
h("span", null, t("memory.settings.vectorEnabled"))
|
|
1281
|
+
),
|
|
1282
|
+
h("input", { className: "mneme-set-input", value: vector.baseUrl, placeholder: t("memory.settings.vectorBaseUrl"), onChange: (e) => setVector({ ...vector, baseUrl: e.target.value }) }),
|
|
1283
|
+
h("input", { className: "mneme-set-input", type: "password", value: vector.apiKey, placeholder: t("memory.settings.vectorApiKey"), onChange: (e) => setVector({ ...vector, apiKey: e.target.value }) }),
|
|
1284
|
+
h("input", { className: "mneme-set-input", value: vector.model, placeholder: t("memory.settings.vectorModel"), onChange: (e) => setVector({ ...vector, model: e.target.value }) }),
|
|
1285
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" } },
|
|
1286
|
+
h("button", { className: "mneme-btn", onClick: saveVector }, t("memory.settings.vectorSave")),
|
|
1287
|
+
vectorSaved && h("span", { className: "mneme-saved" }, t("memory.settings.vectorSaved")),
|
|
1288
|
+
h("button", { className: "mneme-btn", onClick: reindex, disabled: reindexing }, reindexing ? t("memory.settings.vectorReindexing") : t("memory.settings.vectorReindex")),
|
|
1289
|
+
reindexMsg && h("span", { style: { fontSize: 12, color: "var(--dsw-alias-label-secondary, #666)" } }, reindexMsg)
|
|
1290
|
+
)
|
|
1291
|
+
),
|
|
1292
|
+
// API Token — advanced, last
|
|
1293
|
+
h("section", { className: "mneme-set-sec" },
|
|
1294
|
+
h("div", { className: "mneme-set-title" }, t("memory.settings.apiTokenTitle")),
|
|
1295
|
+
h("div", { className: "mneme-set-desc" }, t("memory.settings.apiTokenHint")),
|
|
1296
|
+
h("div", { style: { display: "flex", gap: 8 } },
|
|
1297
|
+
h("input", {
|
|
1298
|
+
className: "mneme-set-input",
|
|
1299
|
+
style: { marginBottom: 0 },
|
|
1300
|
+
type: "password",
|
|
1301
|
+
value: apiToken,
|
|
1302
|
+
placeholder: t("memory.settings.apiTokenPlaceholder"),
|
|
1303
|
+
onChange: (e) => setApiToken(e.target.value)
|
|
1304
|
+
}),
|
|
1305
|
+
h("button", { className: "mneme-btn", onClick: saveToken }, t("memory.settings.apiTokenSave"))
|
|
1306
|
+
),
|
|
1307
|
+
apiTokenSaved && h("div", { style: { marginTop: 8 } }, h("span", { className: "mneme-saved" }, t("memory.settings.apiTokenSaved")))
|
|
1308
|
+
)
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// --- Main-area memory library: the single home for every memory feature ---
|
|
1313
|
+
// Registered under conversation.view beside Chat / Trajectory. The old
|
|
1314
|
+
// right-hand drawer is gone: the sidebar foot entry activates this tab
|
|
1315
|
+
// directly. The framework keeps the active-view setter private to the
|
|
1316
|
+
// conversation package, and the DOM tab click is the one stable
|
|
1317
|
+
// activation path available to plugins.
|
|
1318
|
+
//
|
|
1319
|
+
// One host constraint remains: the conversation header (and with it the
|
|
1320
|
+
// whole tab ring) is hidden while a session is blank — the new-chat hero
|
|
1321
|
+
// screen. There the tab simply does not exist, so activateExplorerTab
|
|
1322
|
+
// resolves false and the sidebar entry falls back to the full-viewport
|
|
1323
|
+
// overlay surface below.
|
|
1324
|
+
// Candidate tabs whose label matches ours exactly. The conversation tab
|
|
1325
|
+
// ring carries no registrant id in the DOM, so the label is all we have —
|
|
1326
|
+
// but other plugins may register views (or settings dialogs may render
|
|
1327
|
+
// panes) with the same text, and hidden panes still sit in the document.
|
|
1328
|
+
// Restrict to rendered tabs (offsetParent filters display:none subtrees)
|
|
1329
|
+
// and verify by rendering, not by label alone (see activateExplorerTab).
|
|
1330
|
+
function findExplorerTabs(label) {
|
|
1331
|
+
const matches = [];
|
|
1332
|
+
for (const tab of document.querySelectorAll('[role="tab"]')) {
|
|
1333
|
+
if ((tab.textContent || "").trim() !== label) continue;
|
|
1334
|
+
if (tab.offsetParent === null && getComputedStyle(tab).position !== "fixed") continue;
|
|
1335
|
+
matches.push(tab);
|
|
1336
|
+
}
|
|
1337
|
+
return matches;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// Click the memory library tab and wait (bounded, rAF-polled) until the
|
|
1341
|
+
// host marks it selected AND our explorer actually rendered (`.mneme-x`).
|
|
1342
|
+
// aria-selected alone cannot tell our tab from a same-labelled tab of
|
|
1343
|
+
// another plugin — the sidebar entry must not activate theirs. On a
|
|
1344
|
+
// failed candidate, move on to the next one; resolve false when the
|
|
1345
|
+
// fallback overlay should take over.
|
|
1346
|
+
function activateExplorerTab(label) {
|
|
1347
|
+
return new Promise((resolve) => {
|
|
1348
|
+
const candidates = findExplorerTabs(label);
|
|
1349
|
+
if (candidates.length === 0) { resolve(false); return; }
|
|
1350
|
+
let index = 0;
|
|
1351
|
+
const tryNext = () => {
|
|
1352
|
+
if (index >= candidates.length) { resolve(false); return; }
|
|
1353
|
+
const tab = candidates[index++];
|
|
1354
|
+
tab.click();
|
|
1355
|
+
const deadline = Date.now() + 400;
|
|
1356
|
+
(function check() {
|
|
1357
|
+
const rendered = document.querySelector(".mneme-x") !== null;
|
|
1358
|
+
if (tab.getAttribute("aria-selected") === "true" && rendered) { resolve(true); return; }
|
|
1359
|
+
if (Date.now() >= deadline) { setTimeout(tryNext, 0); return; }
|
|
1360
|
+
requestAnimationFrame(check);
|
|
1361
|
+
})();
|
|
1362
|
+
};
|
|
1363
|
+
tryNext();
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// --- Hero fallback overlay state (module-level pub/sub) ---
|
|
1368
|
+
const overlayListeners = new Set();
|
|
1369
|
+
let overlayOpenState = false;
|
|
1370
|
+
function setOverlayOpen(v) {
|
|
1371
|
+
if (v === overlayOpenState) return;
|
|
1372
|
+
overlayOpenState = v;
|
|
1373
|
+
for (const fn of overlayListeners) fn();
|
|
1374
|
+
}
|
|
1375
|
+
function useOverlayOpen() {
|
|
1376
|
+
const [open, setOpen] = useState(overlayOpenState);
|
|
1377
|
+
useEffect(() => {
|
|
1378
|
+
const fn = () => setOpen(overlayOpenState);
|
|
1379
|
+
overlayListeners.add(fn);
|
|
1380
|
+
return () => { overlayListeners.delete(fn); };
|
|
1381
|
+
}, []);
|
|
1382
|
+
return [open, setOverlayOpen];
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// Full-viewport memory library for states without a tab ring (new-chat
|
|
1386
|
+
// hero). Same MemoryExplorer component as the tab view — identical
|
|
1387
|
+
// three-column layout, graph and settings — plus a slim top bar with a
|
|
1388
|
+
// close affordance. Esc closes. Portalled to <body> so sidebar stacking
|
|
1389
|
+
// contexts cannot clip it.
|
|
1390
|
+
function MemoryOverlay({ t }) {
|
|
1391
|
+
const [open, setOpen] = useOverlayOpen();
|
|
1392
|
+
useEffect(() => {
|
|
1393
|
+
if (!open) return undefined;
|
|
1394
|
+
const onKey = (e) => { if (e.key === "Escape") setOpen(false); };
|
|
1395
|
+
window.addEventListener("keydown", onKey);
|
|
1396
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
1397
|
+
}, [open]);
|
|
1398
|
+
if (!open) return null;
|
|
1399
|
+
const tree = h("div", { className: "mneme-overlay", role: "region", "aria-label": t("memory.view.label") },
|
|
1400
|
+
h("div", { className: "mneme-overlaybar" },
|
|
1401
|
+
h("span", { className: "mneme-overlaytitle" }, t("memory.view.label")),
|
|
1402
|
+
h("button", {
|
|
1403
|
+
type: "button",
|
|
1404
|
+
className: "mneme-footbtn",
|
|
1405
|
+
onClick: () => setOpen(false)
|
|
1406
|
+
}, t("memory.overlay.close"))
|
|
1407
|
+
),
|
|
1408
|
+
h("div", { className: "mneme-overlaybody" }, h(MemoryExplorer, { t }))
|
|
1409
|
+
);
|
|
1410
|
+
if (reactDom && typeof document !== "undefined") return reactDom.createPortal(tree, document.body);
|
|
1411
|
+
return tree;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
const EXPLORER_TYPES = ["preference", "project", "decision", "summary", "history"];
|
|
1415
|
+
const PAGE_SIZE = 100; // browse page size — the tree grows by 100-row pages
|
|
1416
|
+
|
|
1417
|
+
// --- Status sub-view: a responsive grid of stat cards. Every card owns
|
|
1418
|
+
// its fetch, loading ("…") and error state, so one failing endpoint
|
|
1419
|
+
// never blanks or blocks the others. ---
|
|
1420
|
+
function StatusCard({ t, title, loading, error, num, cap }) {
|
|
1421
|
+
return h("div", { className: "mneme-statuscard" },
|
|
1422
|
+
h("div", { className: "mneme-xcolhead" }, title),
|
|
1423
|
+
loading
|
|
1424
|
+
? h("div", { className: "mneme-statusnum" }, "…")
|
|
1425
|
+
: error
|
|
1426
|
+
? h("div", { className: "mneme-statuscap", style: { color: "var(--dsw-alias-state-error,#c33)" } }, t("memory.status.error"))
|
|
1427
|
+
: h(react.Fragment, null,
|
|
1428
|
+
h("div", { className: "mneme-statusnum" }, num),
|
|
1429
|
+
cap ? h("div", { className: "mneme-statuscap" }, cap) : null
|
|
1430
|
+
)
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// 记忆总数 — list total plus per-type subtotals; each subtotal request
|
|
1435
|
+
// may fail independently (rendered as "—") without sinking the card.
|
|
1436
|
+
function MemoriesStatusCard({ t }) {
|
|
1437
|
+
const [state, setState] = useState({ loading: true, error: false, total: 0, byType: [] });
|
|
1438
|
+
useEffect(() => {
|
|
1439
|
+
let cancelled = false;
|
|
1440
|
+
const one = (ty) =>
|
|
1441
|
+
apiFetch(`/api/dsh-mneme/list?limit=1&order=chrono${ty ? `&type=${encodeURIComponent(ty)}` : ""}`)
|
|
1442
|
+
.then((res) => { if (!res.ok) throw new Error("http"); return res.json(); });
|
|
1443
|
+
one()
|
|
1444
|
+
.then((all) => {
|
|
1445
|
+
if (cancelled) return null;
|
|
1446
|
+
const total = all.total || 0;
|
|
1447
|
+
return Promise.all(EXPLORER_TYPES.map((ty) =>
|
|
1448
|
+
one(ty).then((j) => [ty, j.total || 0]).catch(() => [ty, null])
|
|
1449
|
+
)).then((byType) => {
|
|
1450
|
+
if (!cancelled) setState({ loading: false, error: false, total, byType });
|
|
1451
|
+
});
|
|
1452
|
+
})
|
|
1453
|
+
.catch(() => { if (!cancelled) setState({ loading: false, error: true, total: 0, byType: [] }); });
|
|
1454
|
+
return () => { cancelled = true; };
|
|
1455
|
+
}, []);
|
|
1456
|
+
const cap = state.byType
|
|
1457
|
+
.map(([ty, n]) => `${typeLabel(t, ty)} ${n === null ? "—" : n}`)
|
|
1458
|
+
.join(" · ");
|
|
1459
|
+
return h(StatusCard, {
|
|
1460
|
+
t,
|
|
1461
|
+
title: t("memory.status.memories"),
|
|
1462
|
+
loading: state.loading,
|
|
1463
|
+
error: state.error,
|
|
1464
|
+
num: state.total.toLocaleString(),
|
|
1465
|
+
cap
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// 实体 — directory snapshot grouped by entity type.
|
|
1470
|
+
function EntitiesStatusCard({ t }) {
|
|
1471
|
+
const [state, setState] = useState({ loading: true, error: false, total: 0, byType: [] });
|
|
1472
|
+
useEffect(() => {
|
|
1473
|
+
let cancelled = false;
|
|
1474
|
+
const order = ["organization", "person", "project", "technology", "concept"];
|
|
1475
|
+
apiFetch("/api/dsh-mneme/entities?limit=500")
|
|
1476
|
+
.then((res) => { if (!res.ok) throw new Error("http"); return res.json(); })
|
|
1477
|
+
.then((j) => {
|
|
1478
|
+
if (cancelled) return;
|
|
1479
|
+
const list = Array.isArray(j.entities) ? j.entities : [];
|
|
1480
|
+
const counts = new Map();
|
|
1481
|
+
for (const e of list) {
|
|
1482
|
+
const key = order.includes(e.type) ? e.type : "other";
|
|
1483
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
1484
|
+
}
|
|
1485
|
+
const byType = order.concat("other")
|
|
1486
|
+
.filter((k) => counts.has(k))
|
|
1487
|
+
.map((k) => [k, counts.get(k)]);
|
|
1488
|
+
setState({ loading: false, error: false, total: list.length, byType });
|
|
1489
|
+
})
|
|
1490
|
+
.catch(() => { if (!cancelled) setState({ loading: false, error: true, total: 0, byType: [] }); });
|
|
1491
|
+
return () => { cancelled = true; };
|
|
1492
|
+
}, []);
|
|
1493
|
+
const cap = state.byType.map(([ty, n]) => `${entityTypeLabel(t, ty)} ${n}`).join(" · ");
|
|
1494
|
+
return h(StatusCard, {
|
|
1495
|
+
t,
|
|
1496
|
+
title: t("memory.status.entities"),
|
|
1497
|
+
loading: state.loading,
|
|
1498
|
+
error: state.error,
|
|
1499
|
+
num: state.total.toLocaleString(),
|
|
1500
|
+
cap
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// 向量索引 — whether semantic recall is switched on.
|
|
1505
|
+
function VectorStatusCard({ t }) {
|
|
1506
|
+
const [state, setState] = useState({ loading: true, error: false, enabled: false });
|
|
1507
|
+
useEffect(() => {
|
|
1508
|
+
let cancelled = false;
|
|
1509
|
+
apiFetch("/api/dsh-mneme/vector-config")
|
|
1510
|
+
.then((res) => { if (!res.ok) throw new Error("http"); return res.json(); })
|
|
1511
|
+
.then((j) => { if (!cancelled) setState({ loading: false, error: false, enabled: !!j.config?.enabled }); })
|
|
1512
|
+
.catch(() => { if (!cancelled) setState({ loading: false, error: true, enabled: false }); });
|
|
1513
|
+
return () => { cancelled = true; };
|
|
1514
|
+
}, []);
|
|
1515
|
+
return h(StatusCard, {
|
|
1516
|
+
t,
|
|
1517
|
+
title: t("memory.status.vector"),
|
|
1518
|
+
loading: state.loading,
|
|
1519
|
+
error: state.error,
|
|
1520
|
+
num: state.enabled ? t("memory.status.vectorOn") : t("memory.status.vectorOff"),
|
|
1521
|
+
cap: ""
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// LLM 消耗 — calls + tokens over the trailing 7 days.
|
|
1526
|
+
function LlmStatusCard({ t }) {
|
|
1527
|
+
const [state, setState] = useState({ loading: true, error: false, calls: 0, tokens: 0 });
|
|
1528
|
+
useEffect(() => {
|
|
1529
|
+
let cancelled = false;
|
|
1530
|
+
apiFetch("/api/dsh-mneme/semantic/llm-audit/stats?days=7")
|
|
1531
|
+
.then((res) => { if (!res.ok) throw new Error("http"); return res.json(); })
|
|
1532
|
+
.then((j) => {
|
|
1533
|
+
if (cancelled) return;
|
|
1534
|
+
const s = j && typeof j === "object" ? (j.stats || j) : {};
|
|
1535
|
+
setState({ loading: false, error: false, calls: Number(s.total_calls ?? 0), tokens: Number(s.total_tokens ?? 0) });
|
|
1536
|
+
})
|
|
1537
|
+
.catch(() => { if (!cancelled) setState({ loading: false, error: true, calls: 0, tokens: 0 }); });
|
|
1538
|
+
return () => { cancelled = true; };
|
|
1539
|
+
}, []);
|
|
1540
|
+
return h(StatusCard, {
|
|
1541
|
+
t,
|
|
1542
|
+
title: t("memory.status.llm"),
|
|
1543
|
+
loading: state.loading,
|
|
1544
|
+
error: state.error,
|
|
1545
|
+
num: state.tokens.toLocaleString(),
|
|
1546
|
+
cap: t("memory.status.llmCalls").replace("{n}", state.calls.toLocaleString())
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
function StatusPanel({ t }) {
|
|
1551
|
+
return h("div", { className: "mneme-status" },
|
|
1552
|
+
h("div", { className: "mneme-statusgrid" },
|
|
1553
|
+
h(MemoriesStatusCard, { t }),
|
|
1554
|
+
h(EntitiesStatusCard, { t }),
|
|
1555
|
+
h(VectorStatusCard, { t }),
|
|
1556
|
+
h(LlmStatusCard, { t })
|
|
1557
|
+
)
|
|
1558
|
+
);
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
|
|
1562
|
+
function MemoryExplorer({ t }) {
|
|
1563
|
+
const [view, setView] = useState("memory"); // memory | entity | status | settings
|
|
1564
|
+
const [items, setItems] = useState([]); // loaded browse pages, chrono desc
|
|
1565
|
+
const [total, setTotal] = useState(0); // server count for the current type filter
|
|
1566
|
+
const [loading, setLoading] = useState(true);
|
|
1567
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
1568
|
+
const [type, setType] = useState("all");
|
|
1569
|
+
const [minImp, setMinImp] = useState(0); // importance floor: 0 = all, else 3/4/5
|
|
1570
|
+
const [query, setQuery] = useState("");
|
|
1571
|
+
const [semantic, setSemantic] = useState(false);
|
|
1572
|
+
const [vecEnabled, setVecEnabled] = useState(false);
|
|
1573
|
+
const [searchTopK, setSearchTopK] = useState(20);
|
|
1574
|
+
const [remoteItems, setRemoteItems] = useState(null);
|
|
1575
|
+
const [selectedId, setSelectedId] = useState(null);
|
|
1576
|
+
const [expandedMonths, setExpandedMonths] = useState(null); // null = 仅最新一个月展开
|
|
1577
|
+
const [collapsed, setCollapsed] = useState({});
|
|
1578
|
+
const [copied, setCopied] = useState(false);
|
|
1579
|
+
const [reloadKey, setReloadKey] = useState(0);
|
|
1580
|
+
const [confirmDelete, setConfirmDelete] = useState(false); // two-step delete armed
|
|
1581
|
+
const [deleting, setDeleting] = useState(false);
|
|
1582
|
+
const [deleteError, setDeleteError] = useState(false);
|
|
1583
|
+
const [deleteMsg, setDeleteMsg] = useState(false); // transient "deleted" notice
|
|
1584
|
+
const [graphFocus, setGraphFocus] = useState("");
|
|
1585
|
+
const itemRefs = useRef(new Map());
|
|
1586
|
+
const moreRef = useRef(null);
|
|
1587
|
+
|
|
1588
|
+
useEffect(() => {
|
|
1589
|
+
apiFetch("/api/dsh-mneme/vector-config")
|
|
1590
|
+
.then((res) => res.json())
|
|
1591
|
+
.then((d) => setVecEnabled(!!d.config?.enabled))
|
|
1592
|
+
.catch(() => {});
|
|
1593
|
+
}, []);
|
|
1594
|
+
|
|
1595
|
+
// One query string behind every browse-list fetch (initial page,
|
|
1596
|
+
// loadMore, auto-refresh): the type filter and the importance floor
|
|
1597
|
+
// always travel together, so counts and pagination stay consistent.
|
|
1598
|
+
const filterQS = (type === "all" ? "" : `&type=${encodeURIComponent(type)}`)
|
|
1599
|
+
+ (minImp ? `&minImportance=${minImp}` : "");
|
|
1600
|
+
const listUrl = (offset) => `/api/dsh-mneme/list?limit=${PAGE_SIZE}&offset=${offset}${filterQS}&order=chrono`;
|
|
1601
|
+
|
|
1602
|
+
// Browse = paged, pure-chronological pages (order=chrono). The default
|
|
1603
|
+
// importance ordering would interleave months across pages and break
|
|
1604
|
+
// the month tree; type filters server-side so pages stay consistent
|
|
1605
|
+
// however large the store grows.
|
|
1606
|
+
useEffect(() => {
|
|
1607
|
+
let cancelled = false;
|
|
1608
|
+
setLoading(true);
|
|
1609
|
+
apiFetch(listUrl(0))
|
|
1610
|
+
.then((res) => (res.ok ? res.json() : { items: [], total: 0 }))
|
|
1611
|
+
.then((d) => {
|
|
1612
|
+
if (cancelled) return;
|
|
1613
|
+
setItems(d.items || []);
|
|
1614
|
+
setTotal(d.total || 0);
|
|
1615
|
+
setExpandedMonths(null); // 新数据回到「仅最新月展开」
|
|
1616
|
+
setLoading(false);
|
|
1617
|
+
})
|
|
1618
|
+
.catch(() => { if (!cancelled) { setItems([]); setTotal(0); setLoading(false); } });
|
|
1619
|
+
return () => { cancelled = true; };
|
|
1620
|
+
}, [reloadKey, filterQS]);
|
|
1621
|
+
|
|
1622
|
+
const canLoadMore = !query.trim() && !remoteItems && items.length < total;
|
|
1623
|
+
const loadMore = useCallback(() => {
|
|
1624
|
+
if (loadingMore || query.trim() || remoteItems) return;
|
|
1625
|
+
if (items.length >= total) return;
|
|
1626
|
+
setLoadingMore(true);
|
|
1627
|
+
apiFetch(listUrl(items.length))
|
|
1628
|
+
.then((res) => (res.ok ? res.json() : { items: [] }))
|
|
1629
|
+
.then((d) => setItems((cur) => cur.concat(d.items || [])))
|
|
1630
|
+
.catch(() => {})
|
|
1631
|
+
.finally(() => setLoadingMore(false));
|
|
1632
|
+
}, [items.length, total, loadingMore, query, remoteItems, filterQS]);
|
|
1633
|
+
|
|
1634
|
+
// Infinite scroll: a sentinel just past the loaded rows pulls the next
|
|
1635
|
+
// page while browsing (search results arrive complete already).
|
|
1636
|
+
useEffect(() => {
|
|
1637
|
+
const el = moreRef.current;
|
|
1638
|
+
if (!el || !canLoadMore) return undefined;
|
|
1639
|
+
const io = new IntersectionObserver((entries) => {
|
|
1640
|
+
if (entries.some((e) => e.isIntersecting)) loadMore();
|
|
1641
|
+
}, { root: el.closest(".mneme-xtree"), rootMargin: "240px" });
|
|
1642
|
+
io.observe(el);
|
|
1643
|
+
return () => io.disconnect();
|
|
1644
|
+
}, [canLoadMore, loadMore]);
|
|
1645
|
+
|
|
1646
|
+
// Search hits the server so matches are global — not limited to the
|
|
1647
|
+
// loaded pages: keyword = literal text; vector = semantic ranking
|
|
1648
|
+
// (the pipeline falls back to keyword when no embedder is available).
|
|
1649
|
+
useEffect(() => {
|
|
1650
|
+
const query0 = query.trim();
|
|
1651
|
+
if (!query0 || query0.startsWith("entity:")) { setRemoteItems(null); return; }
|
|
1652
|
+
let cancelled = false;
|
|
1653
|
+
const timer = setTimeout(() => {
|
|
1654
|
+
const mode = semantic ? "vector" : "keyword";
|
|
1655
|
+
apiFetch(`/api/dsh-mneme/search?q=${encodeURIComponent(query0)}&mode=${mode}&topK=${searchTopK}`)
|
|
1656
|
+
.then((res) => (res.ok ? res.json() : { items: [] }))
|
|
1657
|
+
.then((d) => { if (!cancelled) setRemoteItems(d.items || []); })
|
|
1658
|
+
.catch(() => { if (!cancelled) setRemoteItems([]); });
|
|
1659
|
+
}, 250);
|
|
1660
|
+
return () => { cancelled = true; clearTimeout(timer); };
|
|
1661
|
+
}, [query, semantic, searchTopK]);
|
|
1662
|
+
|
|
1663
|
+
// Live view: while the memory tab is open, quietly re-fetch page 1
|
|
1664
|
+
// every 30s (and on every activation) so fresh memories surface
|
|
1665
|
+
// without a manual refresh. Later pages stay as loaded.
|
|
1666
|
+
const refreshFirstPage = useCallback(() => {
|
|
1667
|
+
if (query.trim() || remoteItems) return;
|
|
1668
|
+
apiFetch(listUrl(0))
|
|
1669
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
1670
|
+
.then((d) => {
|
|
1671
|
+
if (!d) return;
|
|
1672
|
+
setTotal(d.total || 0);
|
|
1673
|
+
setItems((cur) => {
|
|
1674
|
+
const fresh = d.items || [];
|
|
1675
|
+
const seen = new Set(fresh.map((m) => m.id));
|
|
1676
|
+
return fresh.concat(cur.filter((m) => !seen.has(m.id)));
|
|
1677
|
+
});
|
|
1678
|
+
})
|
|
1679
|
+
.catch(() => {});
|
|
1680
|
+
}, [query, remoteItems, filterQS]);
|
|
1681
|
+
|
|
1682
|
+
useEffect(() => {
|
|
1683
|
+
if (view !== "memory") return undefined;
|
|
1684
|
+
refreshFirstPage();
|
|
1685
|
+
const iv = setInterval(() => {
|
|
1686
|
+
if (document.visibilityState === "visible") refreshFirstPage();
|
|
1687
|
+
}, 30000);
|
|
1688
|
+
return () => clearInterval(iv);
|
|
1689
|
+
}, [view, refreshFirstPage]);
|
|
1690
|
+
|
|
1691
|
+
useEffect(() => {
|
|
1692
|
+
if (!selectedId) return;
|
|
1693
|
+
itemRefs.current.get(selectedId)?.scrollIntoView({ block: "nearest" });
|
|
1694
|
+
}, [selectedId]);
|
|
1695
|
+
|
|
1696
|
+
// The two-step delete arms per selection: switching or clearing the
|
|
1697
|
+
// target disarms it, so a stale confirm can never delete a new pick.
|
|
1698
|
+
useEffect(() => {
|
|
1699
|
+
setConfirmDelete(false);
|
|
1700
|
+
setDeleteError(false);
|
|
1701
|
+
}, [selectedId]);
|
|
1702
|
+
|
|
1703
|
+
const q = query.trim().toLowerCase();
|
|
1704
|
+
// "entity:" is the graph entry grammar: typing it means the user wants
|
|
1705
|
+
// the entity's neighborhood, not a memory list. Offer the jump instead
|
|
1706
|
+
// of auto-switching so the list stays predictable.
|
|
1707
|
+
const entityQuery = query.trim().startsWith("entity:")
|
|
1708
|
+
? query.trim().slice(7).trim()
|
|
1709
|
+
: "";
|
|
1710
|
+
// Search results were filtered server-side (global); the type rail
|
|
1711
|
+
// still narrows them. Browse mode serves the loaded pages as-is —
|
|
1712
|
+
// type filtering happened in the query.
|
|
1713
|
+
const visible = remoteItems
|
|
1714
|
+
? remoteItems.filter((m) => type === "all" || m.type === type)
|
|
1715
|
+
: items;
|
|
1716
|
+
|
|
1717
|
+
const counts = {};
|
|
1718
|
+
for (const m of items) counts[m.type] = (counts[m.type] || 0) + 1;
|
|
1719
|
+
const knownTypes = EXPLORER_TYPES.filter((k) => counts[k]);
|
|
1720
|
+
const extraTypes = Object.keys(counts)
|
|
1721
|
+
.filter((k) => !EXPLORER_TYPES.includes(k))
|
|
1722
|
+
.sort((a, b) => counts[b] - counts[a]);
|
|
1723
|
+
|
|
1724
|
+
// Time tree: sort newest first, then group month → day in one pass so
|
|
1725
|
+
// the grouping follows the sort order instead of re-sorting buckets.
|
|
1726
|
+
const sorted = [...visible].sort((a, b) =>
|
|
1727
|
+
new Date(b.updated_at || b.created_at || 0) - new Date(a.updated_at || a.created_at || 0));
|
|
1728
|
+
const months = [];
|
|
1729
|
+
let curMonth = null, curDay = null;
|
|
1730
|
+
for (const m of sorted) {
|
|
1731
|
+
const d = new Date(m.updated_at || m.created_at || 0);
|
|
1732
|
+
const valid = !Number.isNaN(d.getTime());
|
|
1733
|
+
const mk = valid ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}` : "unknown";
|
|
1734
|
+
if (!curMonth || curMonth.key !== mk) {
|
|
1735
|
+
curMonth = {
|
|
1736
|
+
key: mk,
|
|
1737
|
+
label: valid ? d.toLocaleDateString(undefined, { year: "numeric", month: "long" }) : "—",
|
|
1738
|
+
days: []
|
|
1739
|
+
};
|
|
1740
|
+
months.push(curMonth);
|
|
1741
|
+
curDay = null;
|
|
1742
|
+
}
|
|
1743
|
+
const dk = valid ? `${mk}-${String(d.getDate())}` : "unknown";
|
|
1744
|
+
if (!curDay || curDay.key !== dk) {
|
|
1745
|
+
curDay = { key: dk, label: valid ? d.toLocaleDateString(undefined, { day: "numeric" }) : "—", items: [] };
|
|
1746
|
+
curMonth.days.push(curDay);
|
|
1747
|
+
}
|
|
1748
|
+
curDay.items.push(m);
|
|
1749
|
+
}
|
|
1750
|
+
for (const mo of months) mo.count = mo.days.reduce((s, d) => s + d.items.length, 0);
|
|
1751
|
+
|
|
1752
|
+
const selected = visible.find((m) => m.id === selectedId) || null;
|
|
1753
|
+
|
|
1754
|
+
const copyContent = () => {
|
|
1755
|
+
if (!selected) return;
|
|
1756
|
+
navigator.clipboard?.writeText(selected.content || "").then(
|
|
1757
|
+
() => { setCopied(true); setTimeout(() => setCopied(false), 1500); },
|
|
1758
|
+
() => {}
|
|
1759
|
+
);
|
|
1760
|
+
};
|
|
1761
|
+
|
|
1762
|
+
// Two-step delete (no window.confirm): the red button arms, a second
|
|
1763
|
+
// click commits POST /api/dsh-mneme/delete. On success the row leaves
|
|
1764
|
+
// every local view (browse pages + search results), the selection
|
|
1765
|
+
// clears and the total shrinks; failures — 404 included — surface as
|
|
1766
|
+
// a transient red note next to the actions.
|
|
1767
|
+
const deleteSelected = () => {
|
|
1768
|
+
if (!selected || deleting) return;
|
|
1769
|
+
const id = selected.id;
|
|
1770
|
+
setDeleting(true);
|
|
1771
|
+
setDeleteError(false);
|
|
1772
|
+
apiFetch("/api/dsh-mneme/delete", {
|
|
1773
|
+
method: "POST",
|
|
1774
|
+
headers: { "Content-Type": "application/json" },
|
|
1775
|
+
body: JSON.stringify({ id })
|
|
1776
|
+
})
|
|
1777
|
+
.then((res) => { if (!res.ok) throw new Error("http"); })
|
|
1778
|
+
.then(() => {
|
|
1779
|
+
setItems((cur) => cur.filter((m) => m.id !== id));
|
|
1780
|
+
setRemoteItems((cur) => (cur ? cur.filter((m) => m.id !== id) : cur));
|
|
1781
|
+
setTotal((n) => Math.max(0, n - 1));
|
|
1782
|
+
setSelectedId(null);
|
|
1783
|
+
setConfirmDelete(false);
|
|
1784
|
+
setDeleteMsg(true);
|
|
1785
|
+
setTimeout(() => setDeleteMsg(false), 2500);
|
|
1786
|
+
})
|
|
1787
|
+
.catch(() => {
|
|
1788
|
+
setDeleteError(true);
|
|
1789
|
+
setTimeout(() => setDeleteError(false), 4000);
|
|
1790
|
+
})
|
|
1791
|
+
.finally(() => setDeleting(false));
|
|
1792
|
+
};
|
|
1793
|
+
|
|
1794
|
+
// Graph → memory jump: land on the browser tab with filters reset so
|
|
1795
|
+
// the target row is visible, selected and scrolled into view. A target
|
|
1796
|
+
// beyond the loaded pages renders as a single-row result set instead
|
|
1797
|
+
// of pulling page after page to find it.
|
|
1798
|
+
const jumpToMemory = (target) => {
|
|
1799
|
+
if (!target?.id) return;
|
|
1800
|
+
setView("memory");
|
|
1801
|
+
setType("all");
|
|
1802
|
+
setQuery("");
|
|
1803
|
+
setRemoteItems(items.some((m) => m.id === target.id) ? null : [target]);
|
|
1804
|
+
setExpandedMonths(null);
|
|
1805
|
+
setSelectedId(target.id);
|
|
1806
|
+
};
|
|
1807
|
+
|
|
1808
|
+
const openGraphFor = (name) => {
|
|
1809
|
+
setGraphFocus(name || "");
|
|
1810
|
+
setView("entity");
|
|
1811
|
+
};
|
|
1812
|
+
|
|
1813
|
+
const subviews = [
|
|
1814
|
+
{ key: "memory", label: t("memory.explorer.tabMemory") },
|
|
1815
|
+
{ key: "entity", label: t("memory.explorer.tabEntities") },
|
|
1816
|
+
{ key: "status", label: t("memory.explorer.tabStatus") },
|
|
1817
|
+
{ key: "settings", label: t("memory.explorer.tabSettings") }
|
|
1818
|
+
];
|
|
1819
|
+
|
|
1820
|
+
return h("div", { className: "mneme-x" },
|
|
1821
|
+
h("div", { className: "mneme-xbar" },
|
|
1822
|
+
h("nav", { className: "mneme-vtabs", "aria-label": t("memory.view.label") },
|
|
1823
|
+
subviews.map((s) =>
|
|
1824
|
+
h("button", {
|
|
1825
|
+
key: s.key,
|
|
1826
|
+
type: "button",
|
|
1827
|
+
className: view === s.key ? "mneme-vtab mneme-active" : "mneme-vtab",
|
|
1828
|
+
"aria-pressed": String(view === s.key),
|
|
1829
|
+
onClick: () => setView(s.key)
|
|
1830
|
+
},
|
|
1831
|
+
h(Icon, { name: s.key === "memory" ? "database" : s.key === "entity" ? "waypoints" : s.key === "status" ? "activity" : "settings", size: 14, className: "mneme-vtabico" }),
|
|
1832
|
+
s.label
|
|
1833
|
+
))
|
|
1834
|
+
),
|
|
1835
|
+
),
|
|
1836
|
+
view === "memory" && h("div", { className: "mneme-xmain" },
|
|
1837
|
+
h("div", { className: "mneme-xside" },
|
|
1838
|
+
h("div", { className: "mneme-xcolhead" }, t("memory.explorer.searchTitle")),
|
|
1839
|
+
h("div", { className: "mneme-xsearchwrap" },
|
|
1840
|
+
h(Icon, { name: "search", size: 14, className: "mneme-xsearchico" }),
|
|
1841
|
+
h("input", {
|
|
1842
|
+
className: "mneme-search mneme-xsearch",
|
|
1843
|
+
placeholder: t("memory.explorer.search"),
|
|
1844
|
+
value: query,
|
|
1845
|
+
onChange: (e) => setQuery(e.target.value)
|
|
1846
|
+
})
|
|
1847
|
+
),
|
|
1848
|
+
entityQuery && h("button", {
|
|
1849
|
+
className: "mneme-entitychip",
|
|
1850
|
+
style: { textAlign: "left", justifyContent: "flex-start" },
|
|
1851
|
+
onClick: () => openGraphFor(entityQuery)
|
|
1852
|
+
}, `${t("memory.graph.viewInGraph")} “${entityQuery}”`),
|
|
1853
|
+
h("div", { className: "mneme-xrow" },
|
|
1854
|
+
vecEnabled && h("button", {
|
|
1855
|
+
className: semantic ? "mneme-chip mneme-active" : "mneme-chip",
|
|
1856
|
+
title: t("memory.settings.vectorTitle"),
|
|
1857
|
+
onClick: () => setSemantic(!semantic)
|
|
1858
|
+
}, t("memory.panel.semantic")),
|
|
1859
|
+
h("select", {
|
|
1860
|
+
className: "mneme-select mneme-xselect",
|
|
1861
|
+
value: searchTopK,
|
|
1862
|
+
onChange: (e) => setSearchTopK(Number(e.target.value)),
|
|
1863
|
+
title: t("memory.explorer.topK")
|
|
1864
|
+
}, [5, 10, 20, 50].map((n) => h("option", { key: n, value: n }, t("memory.explorer.topKOption").replace("{n}", String(n)))))
|
|
1865
|
+
),
|
|
1866
|
+
h("div", { className: "mneme-xrow" },
|
|
1867
|
+
h("span", { className: "mneme-xcount" }, t("memory.explorer.count").replace("{n}", String(visible.length))),
|
|
1868
|
+
h("button", { className: "mneme-footbtn", onClick: () => setReloadKey((k) => k + 1) },
|
|
1869
|
+
h(Icon, { name: "refresh", size: 12 }), t("memory.explorer.refresh"))
|
|
1870
|
+
)
|
|
1871
|
+
),
|
|
1872
|
+
h("div", { className: "mneme-xside mneme-xside--filter" },
|
|
1873
|
+
h("div", { className: "mneme-xcolhead" }, t("memory.explorer.importance")),
|
|
1874
|
+
h("div", { className: "mneme-xrow" },
|
|
1875
|
+
[0, 3, 4, 5].map((v) =>
|
|
1876
|
+
h("button", {
|
|
1877
|
+
key: v,
|
|
1878
|
+
className: minImp === v ? "mneme-chip mneme-active" : "mneme-chip",
|
|
1879
|
+
onClick: () => setMinImp(v)
|
|
1880
|
+
}, v === 0 ? t("memory.tab.all") : `★${v}+`))
|
|
1881
|
+
),
|
|
1882
|
+
h("div", { className: "mneme-xcolhead" }, t("memory.explorer.types")),
|
|
1883
|
+
h("button", {
|
|
1884
|
+
className: type === "all" ? "mneme-xtype mneme-active" : "mneme-xtype",
|
|
1885
|
+
onClick: () => setType("all")
|
|
1886
|
+
}, h("span", { className: "mneme-xdot mneme-xdot--all", "aria-hidden": "true" }), h("span", null, t("memory.tab.all")), h("span", { className: "mneme-xcount2" }, String(items.length))),
|
|
1887
|
+
knownTypes.concat(extraTypes).map((key) =>
|
|
1888
|
+
h("button", {
|
|
1889
|
+
key,
|
|
1890
|
+
className: type === key ? "mneme-xtype mneme-active" : "mneme-xtype",
|
|
1891
|
+
onClick: () => setType(key)
|
|
1892
|
+
},
|
|
1893
|
+
h("span", { className: "mneme-xdot", style: { color: memoryTypeColor(key) }, title: typeLabel(t, key), "aria-hidden": "true" }),
|
|
1894
|
+
h("span", null, typeLabel(t, key)),
|
|
1895
|
+
h("span", { className: "mneme-xcount2" }, String(counts[key]))
|
|
1896
|
+
))
|
|
1897
|
+
),
|
|
1898
|
+
h("div", { className: "mneme-xbrowse" },
|
|
1899
|
+
h("div", { className: "mneme-xtree" },
|
|
1900
|
+
h("div", { className: "mneme-xcolhead" }, t("memory.explorer.timeline")),
|
|
1901
|
+
loading
|
|
1902
|
+
? h("div", { className: "mneme-xempty" }, "…")
|
|
1903
|
+
: visible.length === 0
|
|
1904
|
+
? h("div", { className: "mneme-xempty" },
|
|
1905
|
+
h(Icon, { name: "inbox", size: 20, className: "mneme-xemptyico" }),
|
|
1906
|
+
t("memory.explorer.empty"))
|
|
1907
|
+
: months.map((month, mi) => {
|
|
1908
|
+
// Only the newest month starts expanded; older months
|
|
1909
|
+
// stay a one-line header until clicked — a several-
|
|
1910
|
+
// thousand-row store renders a handful of DOM nodes.
|
|
1911
|
+
const open = expandedMonths ? !!expandedMonths[month.key] : mi === 0;
|
|
1912
|
+
return h("div", { key: month.key },
|
|
1913
|
+
h("button", {
|
|
1914
|
+
className: "mneme-xmonth",
|
|
1915
|
+
"aria-expanded": String(open),
|
|
1916
|
+
onClick: () => setExpandedMonths((c) => {
|
|
1917
|
+
const base = c || (months.length ? { [months[0].key]: true } : {});
|
|
1918
|
+
return { ...base, [month.key]: !open };
|
|
1919
|
+
})
|
|
1920
|
+
},
|
|
1921
|
+
h("span", { className: "mneme-xcaret" }, h(Icon, { name: open ? "chevronDown" : "chevronRight", size: 12 })),
|
|
1922
|
+
h("span", null, month.label),
|
|
1923
|
+
h("span", { className: "mneme-xmonthcount" }, String(month.count))
|
|
1924
|
+
),
|
|
1925
|
+
open && month.days.map((day) =>
|
|
1926
|
+
h("div", { key: day.key },
|
|
1927
|
+
h("button", {
|
|
1928
|
+
className: "mneme-xday",
|
|
1929
|
+
"aria-expanded": String(!collapsed[day.key]),
|
|
1930
|
+
onClick: () => setCollapsed((c) => ({ ...c, [day.key]: !c[day.key] }))
|
|
1931
|
+
},
|
|
1932
|
+
h("span", { className: "mneme-xcaret" }, h(Icon, { name: collapsed[day.key] ? "chevronRight" : "chevronDown", size: 11 })),
|
|
1933
|
+
day.label
|
|
1934
|
+
),
|
|
1935
|
+
!collapsed[day.key] && day.items.map((m) => {
|
|
1936
|
+
const d = new Date(m.updated_at || m.created_at || 0);
|
|
1937
|
+
const time = Number.isNaN(d.getTime())
|
|
1938
|
+
? ""
|
|
1939
|
+
: d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
1940
|
+
return h("button", {
|
|
1941
|
+
key: m.id,
|
|
1942
|
+
ref: (el) => { if (el) itemRefs.current.set(m.id, el); else itemRefs.current.delete(m.id); },
|
|
1943
|
+
className: m.id === selectedId ? "mneme-xitem mneme-active" : "mneme-xitem",
|
|
1944
|
+
onClick: () => setSelectedId(m.id)
|
|
1945
|
+
},
|
|
1946
|
+
h("span", { className: "mneme-xdot", style: { color: memoryTypeColor(m.type) }, title: typeLabel(t, m.type), "aria-hidden": "true" }),
|
|
1947
|
+
h("span", { className: "mneme-xtime" }, time),
|
|
1948
|
+
h("span", { className: "mneme-xname" }, m.title || m.content?.slice(0, 40))
|
|
1949
|
+
);
|
|
1950
|
+
})
|
|
1951
|
+
))
|
|
1952
|
+
);
|
|
1953
|
+
}),
|
|
1954
|
+
h("div", { ref: moreRef, className: "mneme-xmore" },
|
|
1955
|
+
canLoadMore
|
|
1956
|
+
? h("button", { className: "mneme-footbtn", disabled: loadingMore, onClick: loadMore },
|
|
1957
|
+
loadingMore ? "…" : `${t("memory.explorer.loadMore")}(${items.length}/${total})`)
|
|
1958
|
+
: (!loading && visible.length > 0 && !q && !remoteItems
|
|
1959
|
+
? h("span", { className: "mneme-xcount" }, `${items.length} / ${total}`)
|
|
1960
|
+
: null)
|
|
1961
|
+
)
|
|
1962
|
+
),
|
|
1963
|
+
h("div", { className: "mneme-xdetail" },
|
|
1964
|
+
selected
|
|
1965
|
+
? h(react.Fragment, { key: selected.id },
|
|
1966
|
+
h("div", { className: "mneme-xdinner" },
|
|
1967
|
+
h("div", { className: "mneme-xdtitle" }, selected.title),
|
|
1968
|
+
h("div", { className: "mneme-xdmeta" },
|
|
1969
|
+
h("span", { style: { display: "inline-flex", alignItems: "center", gap: "6px" } },
|
|
1970
|
+
h("span", { className: "mneme-xdot", style: { color: memoryTypeColor(selected.type) }, "aria-hidden": "true" }),
|
|
1971
|
+
`${typeLabel(t, selected.type)} · ${t("memory.explorer.importance")} ★${selected.importance}`
|
|
1972
|
+
),
|
|
1973
|
+
selected.source && h("span", { title: selected.source },
|
|
1974
|
+
`${t("memory.explorer.source")}: `,
|
|
1975
|
+
h("span", { className: "mneme-xdsrc" }, selected.source)),
|
|
1976
|
+
h("span", { title: formatDate(selected.created_at) }, `${t("memory.explorer.created")}: ${formatDateShort(selected.created_at)}`),
|
|
1977
|
+
h("span", { title: formatDate(selected.updated_at) }, `${t("memory.explorer.updated")}: ${formatRelativeTime(selected.updated_at, t)}`)
|
|
1978
|
+
),
|
|
1979
|
+
Array.isArray(selected.tags) && selected.tags.length > 0 && h("div", { className: "mneme-xdmeta" },
|
|
1980
|
+
h("span", null, `${t("memory.explorer.tags")}: ${selected.tags.join(" · ")}`)
|
|
1981
|
+
),
|
|
1982
|
+
h("div", { className: "mneme-xdcontent" }, selected.content),
|
|
1983
|
+
h("div", { className: "mneme-xdactions" },
|
|
1984
|
+
h("button", { className: "mneme-footbtn", onClick: copyContent },
|
|
1985
|
+
h(Icon, { name: copied ? "check" : "copy", size: 12 }),
|
|
1986
|
+
copied ? t("memory.explorer.copied") : t("memory.explorer.copy")),
|
|
1987
|
+
// two-step delete: red outline arms, solid red commits
|
|
1988
|
+
confirmDelete
|
|
1989
|
+
? h(react.Fragment, null,
|
|
1990
|
+
h("button", {
|
|
1991
|
+
className: "mneme-btn mneme-btndangerconfirm",
|
|
1992
|
+
disabled: deleting,
|
|
1993
|
+
onClick: deleteSelected
|
|
1994
|
+
}, t("memory.explorer.confirmDelete")),
|
|
1995
|
+
h("button", {
|
|
1996
|
+
className: "mneme-btn",
|
|
1997
|
+
onClick: () => setConfirmDelete(false)
|
|
1998
|
+
}, t("memory.explorer.cancel"))
|
|
1999
|
+
)
|
|
2000
|
+
: h("button", {
|
|
2001
|
+
className: "mneme-btn mneme-btndanger",
|
|
2002
|
+
onClick: () => setConfirmDelete(true)
|
|
2003
|
+
}, t("memory.explorer.delete")),
|
|
2004
|
+
deleteError && h("span", { style: { fontSize: 12, color: "var(--dsw-alias-state-error,#c33)" } },
|
|
2005
|
+
t("memory.explorer.deleteFailed"))
|
|
2006
|
+
)
|
|
2007
|
+
)
|
|
2008
|
+
)
|
|
2009
|
+
: h("div", { className: "mneme-xempty" },
|
|
2010
|
+
deleteMsg && h("div", { className: "mneme-saved", style: { marginBottom: 6 } }, t("memory.explorer.deleted")),
|
|
2011
|
+
t("memory.explorer.emptyDetail"))
|
|
2012
|
+
)
|
|
2013
|
+
)
|
|
2014
|
+
),
|
|
2015
|
+
view === "entity" && h(EntityPanel, { t, focusEntity: graphFocus, onJumpMemory: jumpToMemory }),
|
|
2016
|
+
view === "status" && h(StatusPanel, { t }),
|
|
2017
|
+
view === "settings" && h("div", { className: "mneme-set" },
|
|
2018
|
+
h("div", { className: "mneme-set-inner" }, h(SettingsContent, { t }))
|
|
2019
|
+
)
|
|
2020
|
+
);
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
// --- Sidebar foot entry: the wide row / rail icon that activates the
|
|
2024
|
+
// main-area memory library tab. When the tab ring is absent (new-chat
|
|
2025
|
+
// hero screen hides the conversation header entirely) the same click
|
|
2026
|
+
// opens the full-viewport overlay instead — the library stays reachable
|
|
2027
|
+
// from every conversation state.
|
|
2028
|
+
function SidebarTrigger({ wide, t }) {
|
|
2029
|
+
const [, setOpen] = useOverlayOpen();
|
|
2030
|
+
return h("button", {
|
|
2031
|
+
type: "button",
|
|
2032
|
+
className: wide ? "mneme-trigger" : "mneme-trigger mneme-rail",
|
|
2033
|
+
"aria-label": t("memory.sidebar.aria"),
|
|
2034
|
+
title: t("memory.sidebar.aria"),
|
|
2035
|
+
onClick: () => {
|
|
2036
|
+
activateExplorerTab(t("memory.view.label")).then((ok) => { if (!ok) setOpen(true); });
|
|
2037
|
+
}
|
|
2038
|
+
},
|
|
2039
|
+
h(IconArchiveOutline20, { size: wide ? 16 : 18 }),
|
|
2040
|
+
wide && h("span", { className: "mneme-trigger-label" }, t("memory.panel.open"))
|
|
2041
|
+
);
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
function apply(ctx) {
|
|
2045
|
+
ctx.effect(() => ctx.locale.register(NS, dictionaries), "dsh-mneme: dictionaries");
|
|
2046
|
+
|
|
2047
|
+
// Register the memory entry beside Settings at the sidebar foot. The
|
|
2048
|
+
// entry renders a wide row (icon + label) when the sidebar is expanded
|
|
2049
|
+
// and a rail icon when collapsed; clicking activates the memory
|
|
2050
|
+
// library conversation view.
|
|
2051
|
+
ctx.slots.inject("sidebar.footer.action", () => {
|
|
2052
|
+
const t = ctx.locale.bind(NS);
|
|
2053
|
+
return ctx.slots.register({
|
|
2054
|
+
name: "sidebar.footer.action",
|
|
2055
|
+
id: "dsh-mneme",
|
|
2056
|
+
order: 0,
|
|
2057
|
+
label: () => t("memory.panel.open")
|
|
2058
|
+
}, (props) => h(react.Fragment, null,
|
|
2059
|
+
h(SidebarTrigger, { ...props, t }),
|
|
2060
|
+
// The overlay mounts from the always-rendered sidebar slot so it
|
|
2061
|
+
// survives conversation switches; the portal moves it to <body>.
|
|
2062
|
+
h(MemoryOverlay, { t })
|
|
2063
|
+
));
|
|
2064
|
+
});
|
|
2065
|
+
|
|
2066
|
+
// Register the memory library as a conversation view tab, beside
|
|
2067
|
+
// Chat / Trajectory. The view ignores its session props: the library
|
|
2068
|
+
// reads the memory store over HTTP. It hosts every memory feature —
|
|
2069
|
+
// browse, graph and settings — in the main content area.
|
|
2070
|
+
ctx.slots.inject("conversation.view", () => {
|
|
2071
|
+
const t = ctx.locale.bind(NS);
|
|
2072
|
+
return ctx.slots.register({
|
|
2073
|
+
name: "conversation.view",
|
|
2074
|
+
id: "dsh-mneme-memory",
|
|
2075
|
+
order: 30,
|
|
2076
|
+
locale: NS,
|
|
2077
|
+
label: () => t("memory.view.label")
|
|
2078
|
+
}, () => h(MemoryExplorer, { t }));
|
|
2079
|
+
});
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
exports.apply = apply;
|
|
2083
|
+
exports.inject = inject;
|
|
2084
|
+
return module.exports;
|
|
2085
|
+
}
|
|
2086
|
+
});
|