@ilaomianyang/dsh-memory-graph 0.10.0
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/CHANGELOG.md +259 -0
- package/LICENSE +21 -0
- package/README.en.md +466 -0
- package/README.md +459 -0
- package/client.js +1953 -0
- package/cordis.patch.yml +13 -0
- package/docs/auto-settings.png +0 -0
- package/docs/distill-button.png +0 -0
- package/docs/graph-dark.png +0 -0
- package/docs/graph-light.png +0 -0
- package/docs/session-tab-cards.png +0 -0
- package/docs/session-tab-open.png +0 -0
- package/docs/session-tab.png +0 -0
- package/docs/vault-panel.png +0 -0
- package/eval/REPORT.md +437 -0
- package/eval/questions.json +277 -0
- package/eval/run.mjs +460 -0
- package/eval/seed.mjs +489 -0
- package/icon.svg +26 -0
- package/index.js +243 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +77 -0
- package/src/auto.js +194 -0
- package/src/capture.js +237 -0
- package/src/config.js +280 -0
- package/src/distillapi.js +30 -0
- package/src/distillrun.js +106 -0
- package/src/extract.js +660 -0
- package/src/graph.js +1582 -0
- package/src/graphdata.js +475 -0
- package/src/http.js +32 -0
- package/src/pageapi.js +40 -0
- package/src/privacy.js +78 -0
- package/src/schema.js +323 -0
- package/src/search.js +317 -0
- package/src/tools.js +534 -0
- package/src/vault.js +888 -0
- package/test/auto.mjs +295 -0
- package/test/capture.mjs +264 -0
- package/test/client.mjs +1035 -0
- package/test/distillapi.mjs +212 -0
- package/test/extract.mjs +660 -0
- package/test/graphdata.mjs +295 -0
- package/test/live-supersede.mjs +78 -0
- package/test/live.mjs +156 -0
- package/test/page.mjs +116 -0
- package/test/schema.mjs +256 -0
- package/test/smoke.mjs +428 -0
- package/test/tools.mjs +238 -0
package/client.js
ADDED
|
@@ -0,0 +1,1953 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-memory-graph — browser half: the 设置 → 记忆图谱 page.
|
|
3
|
+
*
|
|
4
|
+
* The host half exports a `Config` schema whose live-safe fields are marked
|
|
5
|
+
* `.volatile()`. `@deepseek-ai/dsh-settings` projects exactly those fields into a
|
|
6
|
+
* form and serves them under this plugin's profile entry id. This file is the
|
|
7
|
+
* page that renders that form: it registers a `settings.section`, reads the form
|
|
8
|
+
* through the client service `configForms`, and writes edits back as path ops.
|
|
9
|
+
*
|
|
10
|
+
* Why hand-drawn inputs instead of the `ui-primitives` settings widgets: the page
|
|
11
|
+
* groups 28 knobs, hides the ones that cannot apply (http-only fields while the
|
|
12
|
+
* transport is `harness`), shows which values are user overrides and can restore
|
|
13
|
+
* one, and none of that is a primitive's job. The write path is the one the
|
|
14
|
+
* official settings pages use — `configForms.get(ns).mutate(ops)` — so the
|
|
15
|
+
* revision fence, the write queue and the silent recovery on conflict all still
|
|
16
|
+
* apply.
|
|
17
|
+
*
|
|
18
|
+
* No build step and no JSX: the module loader runs this file as a plain script
|
|
19
|
+
* and calls the factory below with the frozen require table. Outside the browser
|
|
20
|
+
* it is an ordinary ES module whose only side effect is publishing `FORM_SPEC`,
|
|
21
|
+
* so `test/schema.mjs` can hold the page and the schema to each other.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const PACKAGE_NAME = '@ilaomianyang/dsh-memory-graph'
|
|
25
|
+
const HOST_ENTRY_ID = 'memory-graph'
|
|
26
|
+
const STYLE_ID = 'dsh-memory-graph-settings-css'
|
|
27
|
+
/** The Conversation view cell this plugin adds, and the host routes it reads. */
|
|
28
|
+
const GRAPH_VIEW_ID = 'memory-graph'
|
|
29
|
+
const GRAPH_API = '/api/memory-graph.graph'
|
|
30
|
+
const DISTILL_API = '/api/memory-graph.distill'
|
|
31
|
+
/**
|
|
32
|
+
* The sidebar entry and the main page behind it. One id for both: the panel list
|
|
33
|
+
* names the entry, `main` is keyed by it, and the shell joins them by that string.
|
|
34
|
+
*/
|
|
35
|
+
const PANEL_ID = 'memory-graph'
|
|
36
|
+
const PAGE_API = '/api/memory-graph.page'
|
|
37
|
+
|
|
38
|
+
const ZH = (() => {
|
|
39
|
+
try {
|
|
40
|
+
return String((typeof navigator === 'object' && navigator && navigator.language) || '')
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.startsWith('zh')
|
|
43
|
+
} catch {
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
})()
|
|
47
|
+
|
|
48
|
+
/** Pick a string by UI language. Both halves of the plugin are bilingual; the nav label follows suit. */
|
|
49
|
+
const t = (zh, en) => (ZH ? zh : en)
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// The form description
|
|
53
|
+
//
|
|
54
|
+
// `path` is the config path exactly as `src/schema.js` declares it: the test
|
|
55
|
+
// asserts that every path here is a volatile leaf in the schema, and that every
|
|
56
|
+
// volatile leaf appears here. A knob added to one side only fails the suite.
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
const SECTIONS = [
|
|
60
|
+
{
|
|
61
|
+
id: 'capture',
|
|
62
|
+
title: t('记录', 'Capture'),
|
|
63
|
+
hint: t(
|
|
64
|
+
'每一轮对话落成一条原始片段。片段不会被压缩,蒸馏成卡片之后仍然保留。',
|
|
65
|
+
'Every turn becomes a raw episode. Episodes are never compacted, and they stay after distillation.',
|
|
66
|
+
),
|
|
67
|
+
fields: [
|
|
68
|
+
{
|
|
69
|
+
path: ['capture', 'enabled'],
|
|
70
|
+
type: 'boolean',
|
|
71
|
+
label: t('记录对话', 'Record conversations'),
|
|
72
|
+
hint: t('关掉后不再写入新片段;已有记忆不受影响。', 'Off stops new episodes; existing memories stay.'),
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
path: ['capture', 'userChars'],
|
|
76
|
+
type: 'number',
|
|
77
|
+
min: 200,
|
|
78
|
+
max: 20000,
|
|
79
|
+
step: 100,
|
|
80
|
+
unit: t('字符', 'chars'),
|
|
81
|
+
label: t('用户消息保留', 'User message kept'),
|
|
82
|
+
hint: t('每条用户消息最多留多少字。', 'Characters kept per user message.'),
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
path: ['capture', 'assistantChars'],
|
|
86
|
+
type: 'number',
|
|
87
|
+
min: 200,
|
|
88
|
+
max: 20000,
|
|
89
|
+
step: 100,
|
|
90
|
+
unit: t('字符', 'chars'),
|
|
91
|
+
label: t('助手回复保留', 'Assistant reply kept'),
|
|
92
|
+
hint: t('每条回复最多留多少字。', 'Characters kept per reply.'),
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
path: ['capture', 'minChars'],
|
|
96
|
+
type: 'number',
|
|
97
|
+
min: 0,
|
|
98
|
+
max: 500,
|
|
99
|
+
step: 1,
|
|
100
|
+
unit: t('字符', 'chars'),
|
|
101
|
+
label: t('最短长度', 'Shortest kept'),
|
|
102
|
+
hint: t('短于这个长度的往返不记("好的"这种)。', 'Shorter exchanges are dropped (a bare "ok").'),
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
path: ['capture', 'includeToolOutput'],
|
|
106
|
+
type: 'boolean',
|
|
107
|
+
label: t('连工具输出一起记', 'Also record tool output'),
|
|
108
|
+
hint: t('默认关:工具输出多而杂,会挤占蒸馏预算。', 'Off by default: tool output is bulk and usually noise.'),
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
path: ['capture', 'skipPatterns'],
|
|
112
|
+
type: 'list',
|
|
113
|
+
wide: true,
|
|
114
|
+
label: t('跳过这些消息(正则)', 'Skip matching messages (regex)'),
|
|
115
|
+
hint: t(
|
|
116
|
+
'一行一条;整段替换内置的三条(压缩摘要、运行时上下文、system-reminder)。',
|
|
117
|
+
'One per line; replaces the three built-ins (checkpoints, runtime context, system-reminder).',
|
|
118
|
+
),
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: 'privacy',
|
|
124
|
+
title: t('隐私打码', 'Privacy'),
|
|
125
|
+
hint: t(
|
|
126
|
+
'在写入之前打码:匹配到的东西根本不会落盘,事后再清理是不做的。',
|
|
127
|
+
'Redaction happens before anything is written: a match never reaches the file.',
|
|
128
|
+
),
|
|
129
|
+
fields: [
|
|
130
|
+
{
|
|
131
|
+
path: ['privacy', 'useDefaultPatterns'],
|
|
132
|
+
type: 'boolean',
|
|
133
|
+
label: t('内置密钥规则', 'Built-in secret patterns'),
|
|
134
|
+
hint: t('sk- 开头的 key、GitHub/Google/AWS 令牌、私钥块等 11 条。', '11 rules: sk- keys, GitHub/Google/AWS tokens, private key blocks, bearer headers, password assignments.'),
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
path: ['privacy', 'redactEmails'],
|
|
138
|
+
type: 'boolean',
|
|
139
|
+
label: t('邮箱打码', 'Redact emails'),
|
|
140
|
+
hint: t('替换成 [email]。', 'Replaced with [email].'),
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
path: ['privacy', 'redactPaths'],
|
|
144
|
+
type: 'boolean',
|
|
145
|
+
label: t('本地路径打码', 'Redact home paths'),
|
|
146
|
+
hint: t('把家目录替换成 ~。', 'Replaces your home directory with ~.'),
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
path: ['privacy', 'denyPatterns'],
|
|
150
|
+
type: 'list',
|
|
151
|
+
wide: true,
|
|
152
|
+
label: t('额外强制打码(正则)', 'Extra redaction patterns (regex)'),
|
|
153
|
+
hint: t(
|
|
154
|
+
'一行一条,例如客户名、内部主机名;不合法的正则会被忽略而不是让记录停摆。',
|
|
155
|
+
'One per line, e.g. client names or internal hostnames; a malformed rule is ignored rather than breaking capture.',
|
|
156
|
+
),
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: 'extractor',
|
|
162
|
+
title: t('模型蒸馏', 'Distillation'),
|
|
163
|
+
hint: t(
|
|
164
|
+
'用一个模型把片段整理成带类型的记忆卡。默认走 harness:直接复用「设置 → 模型」里已经配好的 provider、密钥和思考强度,本插件不需要另存密钥。',
|
|
165
|
+
'A model turns episodes into typed cards. The default route reuses the provider, key and thinking level already configured in Settings → Models, so this plugin stores no credentials of its own.',
|
|
166
|
+
),
|
|
167
|
+
fields: [
|
|
168
|
+
{
|
|
169
|
+
path: ['extractor', 'enabled'],
|
|
170
|
+
type: 'boolean',
|
|
171
|
+
label: t('启用蒸馏', 'Enable distillation'),
|
|
172
|
+
hint: t('关着时 memory_distill 会直接拒绝,不会偷偷调模型。', 'Off makes memory_distill refuse instead of calling a model.'),
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
path: ['extractor', 'transport'],
|
|
176
|
+
type: 'enum',
|
|
177
|
+
options: [
|
|
178
|
+
{ value: 'harness', label: t('harness(复用设置里的模型)', 'harness (reuse the model in Settings)') },
|
|
179
|
+
{ value: 'http', label: t('http(自填地址和密钥)', 'http (own endpoint and key)') },
|
|
180
|
+
],
|
|
181
|
+
label: t('模型来源', 'Model route'),
|
|
182
|
+
hint: t('harness 用宿主已配置的路由;http 自己填 OpenAI 兼容接口。', 'harness borrows a route the harness already configured; http posts to your own endpoint.'),
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
path: ['extractor', 'useCurrentModel'],
|
|
186
|
+
type: 'boolean',
|
|
187
|
+
label: t('跟随当前模型', 'Follow the current model'),
|
|
188
|
+
hint: t(
|
|
189
|
+
'开着就跟着「设置 → 模型」里选中的那个;关掉才用下面的 provider / 模型名。',
|
|
190
|
+
'On follows whatever Settings → Models has selected; off uses the provider and model below.',
|
|
191
|
+
),
|
|
192
|
+
visible: (value) => getPath(value, ['extractor', 'transport']) === 'harness',
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
path: ['extractor', 'provider'],
|
|
196
|
+
type: 'text',
|
|
197
|
+
placeholder: 'deepseek / wb / agy-local',
|
|
198
|
+
label: t('provider', 'provider'),
|
|
199
|
+
hint: t('只有关掉"跟随当前模型"才需要。', 'Only needed when "follow the current model" is off.'),
|
|
200
|
+
visible: (value) =>
|
|
201
|
+
getPath(value, ['extractor', 'transport']) === 'harness' && getPath(value, ['extractor', 'useCurrentModel']) === false,
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
path: ['extractor', 'reasoningEffort'],
|
|
205
|
+
type: 'text',
|
|
206
|
+
placeholder: t('留空跟随模型默认', 'empty keeps the model default'),
|
|
207
|
+
label: t('思考强度', 'Thinking level'),
|
|
208
|
+
hint: t(
|
|
209
|
+
'例如 low / medium / high / max,取决于模型支持哪些;留空就是该模型的默认。',
|
|
210
|
+
'e.g. low / medium / high / max, depending on the model; empty keeps the route default.',
|
|
211
|
+
),
|
|
212
|
+
visible: (value) => getPath(value, ['extractor', 'transport']) === 'harness',
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
path: ['extractor', 'maxEpisodesPerRun'],
|
|
216
|
+
type: 'number',
|
|
217
|
+
min: 1,
|
|
218
|
+
max: 400,
|
|
219
|
+
step: 1,
|
|
220
|
+
unit: t('条', 'episodes'),
|
|
221
|
+
label: t('每次最多蒸馏', 'Episodes per run'),
|
|
222
|
+
hint: t('一次 memory_distill 处理多少片段。', 'How many episodes one memory_distill call handles.'),
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
path: ['extractor', 'maxOutputTokens'],
|
|
226
|
+
type: 'number',
|
|
227
|
+
min: 256,
|
|
228
|
+
max: 32000,
|
|
229
|
+
step: 1,
|
|
230
|
+
unit: 'tokens',
|
|
231
|
+
label: t('输出上限', 'Output cap'),
|
|
232
|
+
hint: t('单次蒸馏的 token 上限。', 'Token cap per distillation.'),
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
path: ['extractor', 'timeoutMs'],
|
|
236
|
+
type: 'number',
|
|
237
|
+
min: 5000,
|
|
238
|
+
max: 600000,
|
|
239
|
+
step: 1000,
|
|
240
|
+
unit: 'ms',
|
|
241
|
+
label: t('请求超时', 'Request timeout'),
|
|
242
|
+
hint: t('慢模型就调大。', 'Raise it for slow models.'),
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
path: ['extractor', 'model'],
|
|
246
|
+
type: 'text',
|
|
247
|
+
placeholder: t('留空 = deepseek-chat', 'empty = deepseek-chat'),
|
|
248
|
+
label: t('模型名', 'Model'),
|
|
249
|
+
hint: t(
|
|
250
|
+
'http 模式是接口里的模型名;harness 模式下关掉"跟随当前模型"才用到。',
|
|
251
|
+
'In http mode the API model name; in harness mode only used when "follow the current model" is off.',
|
|
252
|
+
),
|
|
253
|
+
visible: (value) =>
|
|
254
|
+
getPath(value, ['extractor', 'transport']) === 'http' || getPath(value, ['extractor', 'useCurrentModel']) === false,
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
path: ['extractor', 'baseUrl'],
|
|
258
|
+
type: 'text',
|
|
259
|
+
placeholder: 'https://api.deepseek.com/v1',
|
|
260
|
+
label: t('接口地址', 'Base URL'),
|
|
261
|
+
hint: t('OpenAI 兼容的 /v1 地址。', 'An OpenAI-compatible /v1 base URL.'),
|
|
262
|
+
visible: (value) => getPath(value, ['extractor', 'transport']) === 'http',
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
path: ['extractor', 'path'],
|
|
266
|
+
type: 'text',
|
|
267
|
+
placeholder: '/chat/completions',
|
|
268
|
+
label: t('补全路径', 'Completion path'),
|
|
269
|
+
hint: t('拼在接口地址后面。', 'Appended to the base URL.'),
|
|
270
|
+
visible: (value) => getPath(value, ['extractor', 'transport']) === 'http',
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
path: ['extractor', 'apiKeyEnv'],
|
|
274
|
+
type: 'text',
|
|
275
|
+
placeholder: 'MEMORY_LLM_API_KEY',
|
|
276
|
+
label: t('密钥来源', 'Key source'),
|
|
277
|
+
hint: t(
|
|
278
|
+
'一个环境变量名,或 ~/.dsh/.credentials.yaml 的 refs 里的一项。',
|
|
279
|
+
'An environment variable, or a name under refs: in ~/.dsh/.credentials.yaml.',
|
|
280
|
+
),
|
|
281
|
+
visible: (value) => getPath(value, ['extractor', 'transport']) === 'http',
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
path: ['extractor', 'apiKey'],
|
|
285
|
+
type: 'secret',
|
|
286
|
+
label: t('密钥(直接填)', 'Key (inline)'),
|
|
287
|
+
hint: t(
|
|
288
|
+
'不建议:会明文存进 profile 配置。优先用上面的密钥来源。留空表示不改动。',
|
|
289
|
+
'Not recommended: stored in the profile config. Prefer the key source above. Empty leaves it untouched.',
|
|
290
|
+
),
|
|
291
|
+
visible: (value) => getPath(value, ['extractor', 'transport']) === 'http',
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
path: ['extractor', 'temperature'],
|
|
295
|
+
type: 'number',
|
|
296
|
+
min: 0,
|
|
297
|
+
max: 2,
|
|
298
|
+
step: 0.05,
|
|
299
|
+
unit: '',
|
|
300
|
+
label: t('采样温度', 'Temperature'),
|
|
301
|
+
hint: t('只对 http 生效;harness 沿用模型设置。', 'http only; harness keeps the model settings.'),
|
|
302
|
+
visible: (value) => getPath(value, ['extractor', 'transport']) === 'http',
|
|
303
|
+
},
|
|
304
|
+
],
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
id: 'auto',
|
|
308
|
+
title: t('自动蒸馏', 'Automatic distillation'),
|
|
309
|
+
hint: t(
|
|
310
|
+
'关着的时候只有你按「蒸馏一次」或模型调 memory_distill 才会跑。打开后插件每分钟看一次有没有该蒸的(只看计数,不调模型、不花钱),命中下面任一规则才真的跑一次。',
|
|
311
|
+
'While off, only the Distill now button or a memory_distill call runs a distillation. Turned on, the plugin looks once a minute (counting only — no model call, no cost) and runs only when one of the rules below matches.',
|
|
312
|
+
),
|
|
313
|
+
fields: [
|
|
314
|
+
{
|
|
315
|
+
path: ['auto', 'enabled'],
|
|
316
|
+
type: 'boolean',
|
|
317
|
+
label: t('启用自动蒸馏', 'Enable automatic distillation'),
|
|
318
|
+
hint: t(
|
|
319
|
+
'自动跑的是同一个 distill(),用同一个模型、同一套打码规则。它花的是你的额度。',
|
|
320
|
+
'It runs the same distill() with the same model and the same redaction rules — and spends your model budget.',
|
|
321
|
+
),
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
path: ['auto', 'minEpisodes'],
|
|
325
|
+
type: 'number',
|
|
326
|
+
min: 0,
|
|
327
|
+
max: 400,
|
|
328
|
+
step: 1,
|
|
329
|
+
unit: t('条', 'episodes'),
|
|
330
|
+
label: t('攒够就蒸', 'Distill once this many pile up'),
|
|
331
|
+
hint: t('未蒸馏的情节攒到这个数就跑一次;0 = 关掉这条规则。', 'Runs when this many undistilled episodes are waiting; 0 turns the rule off.'),
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
path: ['auto', 'everyMinutes'],
|
|
335
|
+
type: 'number',
|
|
336
|
+
min: 0,
|
|
337
|
+
max: 10080,
|
|
338
|
+
step: 5,
|
|
339
|
+
unit: t('分钟', 'min'),
|
|
340
|
+
label: t('定时蒸', 'Distill on a schedule'),
|
|
341
|
+
hint: t(
|
|
342
|
+
'距上次自动蒸馏过了这么多分钟就跑一次;0 = 关掉这条规则。1440 是每天。',
|
|
343
|
+
'Runs once this many minutes have passed since the last automatic run; 0 turns the rule off. 1440 is once a day.',
|
|
344
|
+
),
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
path: ['auto', 'minGapMinutes'],
|
|
348
|
+
type: 'number',
|
|
349
|
+
min: 0,
|
|
350
|
+
max: 1440,
|
|
351
|
+
step: 5,
|
|
352
|
+
unit: t('分钟', 'min'),
|
|
353
|
+
label: t('最短间隔', 'Minimum gap'),
|
|
354
|
+
hint: t(
|
|
355
|
+
'两条规则共用:两次自动蒸馏至少隔这么久,免得刚攒够就被反复问。',
|
|
356
|
+
'Shared by both rules: never two automatic runs closer than this, so a busy vault does not keep re-asking.',
|
|
357
|
+
),
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
path: ['auto', 'quietHours'],
|
|
361
|
+
type: 'text',
|
|
362
|
+
placeholder: '23:00-08:00',
|
|
363
|
+
label: t('静默时段', 'Quiet hours'),
|
|
364
|
+
hint: t(
|
|
365
|
+
'这段时间里不跑(可以跨午夜);留空 = 全天都能跑。写不对就当没写,不会因此永远不跑。',
|
|
366
|
+
'Nothing runs inside this window (it may cross midnight); empty means always. Unreadable text is ignored rather than silencing the feature forever.',
|
|
367
|
+
),
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
path: ['auto', 'project'],
|
|
371
|
+
type: 'text',
|
|
372
|
+
placeholder: t('留空 = 最近情节的项目', "empty = the newest episode's project"),
|
|
373
|
+
label: t('记到哪个项目', 'Project'),
|
|
374
|
+
hint: t('自动蒸馏出的卡片归属哪个项目。', 'Which project automatic cards are filed under.'),
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
id: 'retrieval',
|
|
380
|
+
title: t('召回', 'Recall'),
|
|
381
|
+
hint: t(
|
|
382
|
+
'模型主动调用 memory_search / memory_timeline 时才召回,绝不自动塞进上下文。',
|
|
383
|
+
'Recall runs only when the agent calls memory_search / memory_timeline; nothing is injected automatically.',
|
|
384
|
+
),
|
|
385
|
+
fields: [
|
|
386
|
+
{
|
|
387
|
+
path: ['retrieval', 'limit'],
|
|
388
|
+
type: 'number',
|
|
389
|
+
min: 1,
|
|
390
|
+
max: 40,
|
|
391
|
+
step: 1,
|
|
392
|
+
unit: t('条', 'cards'),
|
|
393
|
+
label: t('默认返回', 'Default results'),
|
|
394
|
+
hint: t('memory_search 不指定 limit 时的条数。', 'Used when memory_search gets no limit.'),
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
path: ['retrieval', 'hops'],
|
|
398
|
+
type: 'number',
|
|
399
|
+
min: 0,
|
|
400
|
+
max: 2,
|
|
401
|
+
step: 1,
|
|
402
|
+
unit: t('跳', 'hops'),
|
|
403
|
+
label: t('实体扩展', 'Entity expansion'),
|
|
404
|
+
hint: t('沿共享实体再拉一层相邻卡片;0 只返回命中的卡片。', 'Pulls one more ring of cards through shared entities; 0 returns exact hits only.'),
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
path: ['retrieval', 'snippetChars'],
|
|
408
|
+
type: 'number',
|
|
409
|
+
min: 60,
|
|
410
|
+
max: 2000,
|
|
411
|
+
step: 20,
|
|
412
|
+
unit: t('字符', 'chars'),
|
|
413
|
+
label: t('摘要长度', 'Snippet length'),
|
|
414
|
+
hint: t('每条结果摘要截多长。', 'How much of each hit is shown.'),
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
path: ['retrieval', 'timelineLimit'],
|
|
418
|
+
type: 'number',
|
|
419
|
+
min: 1,
|
|
420
|
+
max: 200,
|
|
421
|
+
step: 1,
|
|
422
|
+
unit: t('条', 'cards'),
|
|
423
|
+
label: t('时间线默认条数', 'Timeline size'),
|
|
424
|
+
hint: t('memory_timeline 不指定条数时返回多少。', 'Used when memory_timeline gets no limit.'),
|
|
425
|
+
},
|
|
426
|
+
],
|
|
427
|
+
},
|
|
428
|
+
]
|
|
429
|
+
|
|
430
|
+
const ALL_FIELDS = SECTIONS.flatMap((section) => section.fields)
|
|
431
|
+
|
|
432
|
+
/** Public for `test/schema.mjs`: the page and the schema must describe the same fields. */
|
|
433
|
+
const FORM_SPEC = {
|
|
434
|
+
entryId: HOST_ENTRY_ID,
|
|
435
|
+
packageName: PACKAGE_NAME,
|
|
436
|
+
sections: SECTIONS,
|
|
437
|
+
/** Config paths the page can edit, dotted. */
|
|
438
|
+
paths: ALL_FIELDS.map((field) => field.path.join('.')),
|
|
439
|
+
/** Knobs that are deliberately *not* on this page. */
|
|
440
|
+
structural: ['enabled', 'vaultDir', 'defaultProject', 'crossProject'],
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// Value helpers (pure, shared by rendering and by the save path)
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
function getPath(value, path) {
|
|
448
|
+
let node = value
|
|
449
|
+
for (const key of path) {
|
|
450
|
+
if (node === null || node === undefined) return undefined
|
|
451
|
+
node = node[key]
|
|
452
|
+
}
|
|
453
|
+
return node
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Copy `value` with one dotted path replaced; the source object is left alone. */
|
|
457
|
+
function setPath(value, path, next) {
|
|
458
|
+
const [head, ...rest] = path
|
|
459
|
+
const source = value !== null && typeof value === 'object' ? value : {}
|
|
460
|
+
const copy = Array.isArray(source) ? source.slice() : { ...source }
|
|
461
|
+
copy[head] = rest.length === 0 ? next : setPath(copy[head], rest, next)
|
|
462
|
+
return copy
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const sameValue = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null)
|
|
466
|
+
|
|
467
|
+
/** What the control shows for a raw config value. */
|
|
468
|
+
function displayValue(field, raw) {
|
|
469
|
+
switch (field.type) {
|
|
470
|
+
case 'boolean':
|
|
471
|
+
return raw === true
|
|
472
|
+
case 'list':
|
|
473
|
+
return Array.isArray(raw) ? raw.join('\n') : raw === undefined || raw === null ? '' : String(raw)
|
|
474
|
+
case 'enum':
|
|
475
|
+
return raw === undefined || raw === null ? '' : String(raw)
|
|
476
|
+
default:
|
|
477
|
+
return raw === undefined || raw === null ? '' : String(raw)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** What a shown value means in config: the inverse of `displayValue`, plus whitespace trimming. */
|
|
482
|
+
function configValue(field, shown) {
|
|
483
|
+
switch (field.type) {
|
|
484
|
+
case 'boolean':
|
|
485
|
+
return shown === true
|
|
486
|
+
case 'number':
|
|
487
|
+
return typeof shown === 'number' ? shown : Number(String(shown).trim())
|
|
488
|
+
case 'list':
|
|
489
|
+
return String(shown)
|
|
490
|
+
.split('\n')
|
|
491
|
+
.map((line) => line.trim())
|
|
492
|
+
.filter((line) => line !== '')
|
|
493
|
+
case 'text':
|
|
494
|
+
case 'secret':
|
|
495
|
+
return String(shown)
|
|
496
|
+
default:
|
|
497
|
+
return shown
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function validateValue(field, value) {
|
|
502
|
+
if (field.type !== 'number') return null
|
|
503
|
+
if (!Number.isFinite(value)) return t('要填一个数字', 'a number is required')
|
|
504
|
+
if (value < field.min) return t(`不能小于 ${field.min}`, `must be at least ${field.min}`)
|
|
505
|
+
if (value > field.max) return t(`不能大于 ${field.max}`, `must be at most ${field.max}`)
|
|
506
|
+
return null
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
// Reading the served form
|
|
511
|
+
// ---------------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
function resolveForm(ctx) {
|
|
514
|
+
try {
|
|
515
|
+
const forms = ctx.get('configForms')
|
|
516
|
+
if (!forms || typeof forms.get !== 'function') return null
|
|
517
|
+
const form = forms.get(servedNamespace(ctx, forms))
|
|
518
|
+
return form && typeof form.getSnapshot === 'function' ? form : null
|
|
519
|
+
} catch {
|
|
520
|
+
return null
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Which settings namespace is ours.
|
|
526
|
+
*
|
|
527
|
+
* The browser boot gives every client plugin entry a generated id, so the entry
|
|
528
|
+
* id is only a candidate: the namespace the settings service actually serves is
|
|
529
|
+
* the one to use, and the host-side entry id from `cordis.patch.yml` is the
|
|
530
|
+
* fallback that also works when the describe mirror is not readable yet.
|
|
531
|
+
*/
|
|
532
|
+
function servedNamespace(ctx, forms) {
|
|
533
|
+
const candidates = []
|
|
534
|
+
try {
|
|
535
|
+
const entry = ctx && ctx.fiber ? ctx.fiber.entry : null
|
|
536
|
+
const id = entry ? entry.id : null
|
|
537
|
+
if (typeof id === 'string' && id !== '') {
|
|
538
|
+
const colon = id.lastIndexOf(':')
|
|
539
|
+
candidates.push(colon === -1 ? id : id.slice(colon + 1))
|
|
540
|
+
}
|
|
541
|
+
} catch {
|
|
542
|
+
// No entry behind the façade (dynamic module): the static candidates stand.
|
|
543
|
+
}
|
|
544
|
+
candidates.push(PACKAGE_NAME, HOST_ENTRY_ID)
|
|
545
|
+
try {
|
|
546
|
+
const describe = forms && typeof forms.describe === 'function' ? forms.describe() : null
|
|
547
|
+
const snapshot = describe && typeof describe.getSnapshot === 'function' ? describe.getSnapshot() : null
|
|
548
|
+
const view = snapshot && snapshot.view
|
|
549
|
+
const namespaces = view && Array.isArray(view.namespaces) ? view.namespaces : null
|
|
550
|
+
if (namespaces) {
|
|
551
|
+
for (const ns of candidates) {
|
|
552
|
+
if (namespaces.some((row) => row && row.ns === ns)) return ns
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
} catch {
|
|
556
|
+
// Fall through to the static id.
|
|
557
|
+
}
|
|
558
|
+
return HOST_ENTRY_ID
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ---------------------------------------------------------------------------
|
|
562
|
+
// Styles — one labelled <style> element, owned by this package so the host's
|
|
563
|
+
// per-package stylesheet housekeeping removes it with the plugin.
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
const CSS = `
|
|
567
|
+
/* Two rows, not one scroll box: the page fills the pane, the content scrolls
|
|
568
|
+
inside it, and the action bar is a plain sibling at the bottom. A sticky bar
|
|
569
|
+
inside the scroller was the obvious thing and it was wrong — sticky pins the
|
|
570
|
+
MARGIN box, so the negative bottom margin that cancelled the old padding also
|
|
571
|
+
lifted the bar off the floor, and it floated over the rows behind it. */
|
|
572
|
+
.mg-page {
|
|
573
|
+
display: flex;
|
|
574
|
+
flex-direction: column;
|
|
575
|
+
height: 100%;
|
|
576
|
+
min-height: 0;
|
|
577
|
+
}
|
|
578
|
+
.mg-root {
|
|
579
|
+
display: flex;
|
|
580
|
+
flex-direction: column;
|
|
581
|
+
gap: 14px;
|
|
582
|
+
box-sizing: border-box;
|
|
583
|
+
flex: 1 1 auto;
|
|
584
|
+
min-height: 0;
|
|
585
|
+
overflow-y: auto;
|
|
586
|
+
padding: 20px 26px 24px;
|
|
587
|
+
color: var(--dsw-alias-label-primary, #1f2328);
|
|
588
|
+
font-size: 13px;
|
|
589
|
+
line-height: 1.55;
|
|
590
|
+
}
|
|
591
|
+
.mg-head { display: flex; flex-direction: column; gap: 3px; }
|
|
592
|
+
.mg-title { font-size: 16px; font-weight: 600; }
|
|
593
|
+
.mg-sub { color: var(--dsw-alias-label-tertiary, #808080); font-size: 12px; }
|
|
594
|
+
.mg-card {
|
|
595
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
|
|
596
|
+
border-radius: 10px;
|
|
597
|
+
background: var(--dsw-alias-bg-layer-1, transparent);
|
|
598
|
+
}
|
|
599
|
+
.mg-card-h { padding: 12px 14px 2px; }
|
|
600
|
+
.mg-card-t { font-weight: 600; font-size: 13px; }
|
|
601
|
+
.mg-card-d { color: var(--dsw-alias-label-tertiary, #808080); font-size: 11.5px; margin-top: 3px; }
|
|
602
|
+
.mg-rows { padding: 4px 14px 8px; }
|
|
603
|
+
.mg-row {
|
|
604
|
+
display: flex;
|
|
605
|
+
align-items: center;
|
|
606
|
+
gap: 16px;
|
|
607
|
+
padding: 9px 0;
|
|
608
|
+
border-top: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.07));
|
|
609
|
+
}
|
|
610
|
+
.mg-row:first-child { border-top: none; }
|
|
611
|
+
.mg-row-wide { display: block; }
|
|
612
|
+
.mg-row-wide .mg-ctl { margin-top: 7px; }
|
|
613
|
+
.mg-main { flex: 1 1 auto; min-width: 0; }
|
|
614
|
+
.mg-label { display: flex; align-items: center; gap: 6px; font-weight: 500; }
|
|
615
|
+
.mg-hint { color: var(--dsw-alias-label-tertiary, #808080); font-size: 11.5px; margin-top: 2px; }
|
|
616
|
+
.mg-ctl { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; }
|
|
617
|
+
.mg-input {
|
|
618
|
+
height: 28px;
|
|
619
|
+
box-sizing: border-box;
|
|
620
|
+
padding: 0 9px;
|
|
621
|
+
border-radius: 7px;
|
|
622
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.16));
|
|
623
|
+
background: var(--dsw-alias-bg-layer-3, transparent);
|
|
624
|
+
color: inherit;
|
|
625
|
+
font: inherit;
|
|
626
|
+
font-size: 12.5px;
|
|
627
|
+
outline: none;
|
|
628
|
+
}
|
|
629
|
+
.mg-input:focus { border-color: var(--dsw-alias-brand-primary, #3b6ef0); }
|
|
630
|
+
.mg-input:disabled { opacity: 0.55; }
|
|
631
|
+
.mg-num { width: 96px; text-align: right; }
|
|
632
|
+
.mg-text { width: 210px; }
|
|
633
|
+
.mg-select { width: 240px; cursor: pointer; }
|
|
634
|
+
.mg-area {
|
|
635
|
+
width: 100%;
|
|
636
|
+
box-sizing: border-box;
|
|
637
|
+
padding: 7px 9px;
|
|
638
|
+
border-radius: 7px;
|
|
639
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.16));
|
|
640
|
+
background: var(--dsw-alias-bg-layer-3, transparent);
|
|
641
|
+
color: inherit;
|
|
642
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
643
|
+
font-size: 12px;
|
|
644
|
+
line-height: 1.5;
|
|
645
|
+
resize: vertical;
|
|
646
|
+
outline: none;
|
|
647
|
+
}
|
|
648
|
+
.mg-area:focus { border-color: var(--dsw-alias-brand-primary, #3b6ef0); }
|
|
649
|
+
.mg-unit { color: var(--dsw-alias-label-tertiary, #808080); font-size: 11.5px; min-width: 16px; }
|
|
650
|
+
.mg-sw {
|
|
651
|
+
position: relative;
|
|
652
|
+
flex: none;
|
|
653
|
+
width: 36px;
|
|
654
|
+
height: 20px;
|
|
655
|
+
padding: 0;
|
|
656
|
+
border: none;
|
|
657
|
+
border-radius: 999px;
|
|
658
|
+
background: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.22));
|
|
659
|
+
cursor: pointer;
|
|
660
|
+
transition: background 0.15s ease;
|
|
661
|
+
}
|
|
662
|
+
.mg-sw[data-on="true"] { background: var(--dsw-alias-brand-primary, #3b6ef0); }
|
|
663
|
+
.mg-sw > i {
|
|
664
|
+
position: absolute;
|
|
665
|
+
top: 2px;
|
|
666
|
+
left: 2px;
|
|
667
|
+
width: 16px;
|
|
668
|
+
height: 16px;
|
|
669
|
+
border-radius: 50%;
|
|
670
|
+
background: #fff;
|
|
671
|
+
transition: left 0.15s ease;
|
|
672
|
+
}
|
|
673
|
+
.mg-sw[data-on="true"] > i { left: 18px; }
|
|
674
|
+
.mg-sw:disabled { opacity: 0.5; cursor: default; }
|
|
675
|
+
.mg-badge {
|
|
676
|
+
font-size: 10.5px;
|
|
677
|
+
font-weight: 500;
|
|
678
|
+
padding: 1px 6px;
|
|
679
|
+
border-radius: 999px;
|
|
680
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(59, 110, 240, 0.14));
|
|
681
|
+
color: var(--dsw-alias-brand-primary, #3b6ef0);
|
|
682
|
+
}
|
|
683
|
+
.mg-link {
|
|
684
|
+
padding: 0;
|
|
685
|
+
border: none;
|
|
686
|
+
background: none;
|
|
687
|
+
color: var(--dsw-alias-brand-primary, #3b6ef0);
|
|
688
|
+
font: inherit;
|
|
689
|
+
font-size: 11.5px;
|
|
690
|
+
cursor: pointer;
|
|
691
|
+
}
|
|
692
|
+
.mg-link:disabled { opacity: 0.5; cursor: default; }
|
|
693
|
+
.mg-note {
|
|
694
|
+
display: flex;
|
|
695
|
+
flex-direction: column;
|
|
696
|
+
gap: 4px;
|
|
697
|
+
padding: 11px 14px;
|
|
698
|
+
border-radius: 10px;
|
|
699
|
+
border: 1px dashed var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
|
|
700
|
+
color: var(--dsw-alias-label-secondary, #5a5a5a);
|
|
701
|
+
font-size: 11.5px;
|
|
702
|
+
}
|
|
703
|
+
.mg-state { display: flex; flex-direction: column; gap: 8px; padding: 26px 4px; max-width: 520px; }
|
|
704
|
+
.mg-state-t { font-weight: 600; }
|
|
705
|
+
.mg-state-d { color: var(--dsw-alias-label-secondary, #5a5a5a); font-size: 12px; }
|
|
706
|
+
.mg-code {
|
|
707
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
708
|
+
font-size: 11.5px;
|
|
709
|
+
padding: 1px 5px;
|
|
710
|
+
border-radius: 5px;
|
|
711
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
|
|
712
|
+
}
|
|
713
|
+
.mg-bar {
|
|
714
|
+
display: flex;
|
|
715
|
+
align-items: center;
|
|
716
|
+
gap: 10px;
|
|
717
|
+
flex: 0 0 auto;
|
|
718
|
+
padding: 10px 26px;
|
|
719
|
+
border-top: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
|
|
720
|
+
background: var(--dsw-alias-bg-layer-1, #fff);
|
|
721
|
+
box-shadow: var(--dsw-elevation-prominent, 0 -6px 18px rgba(0, 0, 0, 0.06));
|
|
722
|
+
}
|
|
723
|
+
.mg-msg { flex: 1 1 auto; min-width: 0; font-size: 11.5px; }
|
|
724
|
+
.mg-msg[data-kind="ok"] { color: var(--dsw-alias-state-success-primary, #1a7f37); }
|
|
725
|
+
.mg-msg[data-kind="error"] { color: var(--dsw-alias-state-error-primary, #c62828); }
|
|
726
|
+
.mg-btn {
|
|
727
|
+
height: 28px;
|
|
728
|
+
padding: 0 14px;
|
|
729
|
+
border: none;
|
|
730
|
+
border-radius: 7px;
|
|
731
|
+
background: var(--dsw-alias-brand-primary, #3b6ef0);
|
|
732
|
+
color: var(--dsw-alias-label-primary-foreground, #fff);
|
|
733
|
+
font: inherit;
|
|
734
|
+
font-size: 12.5px;
|
|
735
|
+
font-weight: 500;
|
|
736
|
+
cursor: pointer;
|
|
737
|
+
}
|
|
738
|
+
.mg-btn:disabled { opacity: 0.55; cursor: default; }
|
|
739
|
+
.mg-btn-ghost {
|
|
740
|
+
height: 28px;
|
|
741
|
+
padding: 0 12px;
|
|
742
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.16));
|
|
743
|
+
border-radius: 7px;
|
|
744
|
+
background: transparent;
|
|
745
|
+
color: inherit;
|
|
746
|
+
font: inherit;
|
|
747
|
+
font-size: 12.5px;
|
|
748
|
+
cursor: pointer;
|
|
749
|
+
}
|
|
750
|
+
.mg-btn-ghost:disabled { opacity: 0.55; cursor: default; }
|
|
751
|
+
/* The whole-vault panel: a bar, then the served page filling the rest. */
|
|
752
|
+
.mgv-panel { display: flex; flex-direction: column; height: 100%; min-height: 0; }
|
|
753
|
+
.mgv-panel-bar {
|
|
754
|
+
display: flex;
|
|
755
|
+
align-items: center;
|
|
756
|
+
gap: 10px;
|
|
757
|
+
padding: 8px 14px;
|
|
758
|
+
border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08));
|
|
759
|
+
flex: 0 0 auto;
|
|
760
|
+
}
|
|
761
|
+
.mgv-panel-title { font-size: 12.5px; font-weight: 600; }
|
|
762
|
+
.mgv-panel-spacer { flex: 1 1 auto; }
|
|
763
|
+
.mgv-panel-frame {
|
|
764
|
+
flex: 1 1 auto;
|
|
765
|
+
width: 100%;
|
|
766
|
+
/* The panel is a flex child of a shell seat that may not hand down a definite
|
|
767
|
+
height; without a floor the frame would collapse to nothing there. */
|
|
768
|
+
min-height: 60vh;
|
|
769
|
+
border: 0;
|
|
770
|
+
background: var(--dsw-alias-bg-l1, #fff);
|
|
771
|
+
}
|
|
772
|
+
.mgv-root { display: flex; flex-direction: column; height: 100%; min-height: 0; gap: 0; }
|
|
773
|
+
.mgv-head {
|
|
774
|
+
display: flex;
|
|
775
|
+
align-items: center;
|
|
776
|
+
gap: 10px;
|
|
777
|
+
padding: 10px 16px 8px;
|
|
778
|
+
border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08));
|
|
779
|
+
flex: 0 0 auto;
|
|
780
|
+
}
|
|
781
|
+
.mgv-title { font-size: 12.5px; font-weight: 600; }
|
|
782
|
+
.mgv-counts { color: var(--dsw-alias-label-tertiary, #8a8a8a); font-size: 11.5px; }
|
|
783
|
+
.mgv-chip {
|
|
784
|
+
padding: 1px 8px;
|
|
785
|
+
border-radius: 999px;
|
|
786
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
|
|
787
|
+
color: var(--dsw-alias-label-secondary, #5a5a5a);
|
|
788
|
+
font-size: 11px;
|
|
789
|
+
}
|
|
790
|
+
.mgv-spacer { flex: 1 1 auto; }
|
|
791
|
+
.mgv-note {
|
|
792
|
+
flex: 0 0 auto;
|
|
793
|
+
padding: 6px 16px 7px;
|
|
794
|
+
font-size: 11.5px;
|
|
795
|
+
line-height: 1.6;
|
|
796
|
+
color: var(--dsw-alias-label-secondary, #5a5a5a);
|
|
797
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04));
|
|
798
|
+
border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08));
|
|
799
|
+
}
|
|
800
|
+
.mgv-note-ok {
|
|
801
|
+
color: var(--dsw-alias-state-success-primary, #1a7f37);
|
|
802
|
+
background: rgba(26, 127, 55, 0.07);
|
|
803
|
+
}
|
|
804
|
+
.mgv-note-err {
|
|
805
|
+
color: var(--dsw-alias-state-error-primary, #c62828);
|
|
806
|
+
background: rgba(198, 40, 40, 0.06);
|
|
807
|
+
}
|
|
808
|
+
.mgv-body { display: flex; flex: 1 1 auto; min-height: 0; }
|
|
809
|
+
.mgv-canvas { flex: 1 1 auto; min-width: 0; display: block; touch-action: none; }
|
|
810
|
+
.mgv-link { stroke: var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.16)); stroke-width: 1; }
|
|
811
|
+
.mgv-link[data-k="recall"] { stroke: var(--dsw-alias-state-success-primary, #1a7f37); stroke-dasharray: 3 3; }
|
|
812
|
+
.mgv-link[data-k="supersedes"] { stroke: var(--dsw-alias-state-warning-primary, #b26a00); }
|
|
813
|
+
.mgv-node { cursor: pointer; }
|
|
814
|
+
.mgv-node text {
|
|
815
|
+
font-size: 10px;
|
|
816
|
+
fill: var(--dsw-alias-label-secondary, #5a5a5a);
|
|
817
|
+
pointer-events: none;
|
|
818
|
+
}
|
|
819
|
+
.mgv-node[data-k="session"] text { font-size: 11.5px; font-weight: 600; fill: var(--dsw-alias-label-primary, #1c1c1c); }
|
|
820
|
+
.mgv-node[data-k="card"] text { fill: var(--dsw-alias-label-primary, #1c1c1c); }
|
|
821
|
+
.mgv-node[data-k="episode"] text { font-size: 11px; }
|
|
822
|
+
.mgv-node[data-dim="true"] { opacity: 0.34; }
|
|
823
|
+
.mgv-node[data-selected="true"] circle { stroke: var(--dsw-alias-label-primary, #1c1c1c); stroke-width: 2; }
|
|
824
|
+
.mgv-aside {
|
|
825
|
+
flex: 0 0 286px;
|
|
826
|
+
border-left: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08));
|
|
827
|
+
padding: 12px 14px;
|
|
828
|
+
overflow: auto;
|
|
829
|
+
font-size: 12px;
|
|
830
|
+
}
|
|
831
|
+
.mgv-aside-h { font-weight: 600; font-size: 12.5px; line-height: 1.45; }
|
|
832
|
+
.mgv-aside-meta { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px; }
|
|
833
|
+
.mgv-aside-b { margin-top: 10px; color: var(--dsw-alias-label-secondary, #5a5a5a); line-height: 1.6; white-space: pre-wrap; }
|
|
834
|
+
.mgv-aside-tags { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 5px; }
|
|
835
|
+
.mgv-empty { padding: 40px 24px; max-width: 560px; }
|
|
836
|
+
.mgv-empty-t { font-weight: 600; margin-bottom: 8px; }
|
|
837
|
+
.mgv-empty-d { color: var(--dsw-alias-label-secondary, #5a5a5a); font-size: 12px; line-height: 1.7; }
|
|
838
|
+
.mgv-legend { display: flex; gap: 12px; align-items: center; color: var(--dsw-alias-label-tertiary, #8a8a8a); font-size: 11px; }
|
|
839
|
+
.mgv-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
|
|
840
|
+
`
|
|
841
|
+
|
|
842
|
+
function ensureStyle() {
|
|
843
|
+
if (typeof document === 'undefined') return
|
|
844
|
+
const host = document.head || document.documentElement
|
|
845
|
+
if (!host) return
|
|
846
|
+
let style = document.getElementById(STYLE_ID)
|
|
847
|
+
if (!style) {
|
|
848
|
+
style = document.createElement('style')
|
|
849
|
+
style.id = STYLE_ID
|
|
850
|
+
// Tag it as ours: the host collects unlabelled <style> elements into whichever
|
|
851
|
+
// package materialises next and deletes them on reload, which would strip this
|
|
852
|
+
// page without a refresh.
|
|
853
|
+
style.dataset.plugin = PACKAGE_NAME
|
|
854
|
+
style.dataset.pluginCss = STYLE_ID
|
|
855
|
+
style.textContent = CSS
|
|
856
|
+
host.appendChild(style)
|
|
857
|
+
} else if (style.textContent !== CSS) {
|
|
858
|
+
style.textContent = CSS
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// ---------------------------------------------------------------------------
|
|
863
|
+
// The page
|
|
864
|
+
// ---------------------------------------------------------------------------
|
|
865
|
+
|
|
866
|
+
function createPage(ctx, React) {
|
|
867
|
+
const h = React.createElement
|
|
868
|
+
const { useEffect, useRef, useState } = React
|
|
869
|
+
|
|
870
|
+
function Page() {
|
|
871
|
+
const [, bump] = useState(0)
|
|
872
|
+
const [form, setForm] = useState(null)
|
|
873
|
+
const [drafts, setDrafts] = useState({})
|
|
874
|
+
const [busy, setBusy] = useState(false)
|
|
875
|
+
const [notice, setNotice] = useState(null)
|
|
876
|
+
const [hostReady, setHostReady] = useState(true)
|
|
877
|
+
const [slowRead, setSlowRead] = useState(false)
|
|
878
|
+
const attempts = useRef(0)
|
|
879
|
+
|
|
880
|
+
useEffect(() => {
|
|
881
|
+
ensureStyle()
|
|
882
|
+
let disposed = false
|
|
883
|
+
let stop = null
|
|
884
|
+
let timer = null
|
|
885
|
+
const attach = () => {
|
|
886
|
+
if (disposed) return
|
|
887
|
+
const found = resolveForm(ctx)
|
|
888
|
+
if (!found) {
|
|
889
|
+
// The client service arrives with whichever plugin provides it, so the
|
|
890
|
+
// page can be mounted before `configForms` is reachable.
|
|
891
|
+
attempts.current += 1
|
|
892
|
+
if (attempts.current < 40) timer = setTimeout(attach, 250)
|
|
893
|
+
else setHostReady(false)
|
|
894
|
+
return
|
|
895
|
+
}
|
|
896
|
+
setForm(found)
|
|
897
|
+
try {
|
|
898
|
+
stop = found.subscribe(() => bump((n) => n + 1))
|
|
899
|
+
} catch {
|
|
900
|
+
// A form without subscribe still shows the first read.
|
|
901
|
+
}
|
|
902
|
+
bump((n) => n + 1)
|
|
903
|
+
}
|
|
904
|
+
attach()
|
|
905
|
+
return () => {
|
|
906
|
+
disposed = true
|
|
907
|
+
if (timer) clearTimeout(timer)
|
|
908
|
+
try {
|
|
909
|
+
if (typeof stop === 'function') stop()
|
|
910
|
+
} catch {
|
|
911
|
+
// ignore
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
// The form is resolved once per mount; the snapshot subscription drives redraws.
|
|
915
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
916
|
+
}, [])
|
|
917
|
+
|
|
918
|
+
const snapshot = form && typeof form.getSnapshot === 'function' ? safeSnapshot(form) : null
|
|
919
|
+
const value = snapshot && snapshot.status === 'ready' ? snapshot.value : null
|
|
920
|
+
const user = (snapshot && snapshot.user) || {}
|
|
921
|
+
const base = (snapshot && snapshot.base) || {}
|
|
922
|
+
const writable = !snapshot || snapshot.writable !== false
|
|
923
|
+
const status = snapshot ? snapshot.status : 'loading'
|
|
924
|
+
|
|
925
|
+
// `value` is decoded by the settings service, so a form that never leaves
|
|
926
|
+
// "loading" means the served value failed validation — worth saying out loud
|
|
927
|
+
// rather than spinning forever.
|
|
928
|
+
useEffect(() => {
|
|
929
|
+
if (!form || status !== 'loading') {
|
|
930
|
+
setSlowRead(false)
|
|
931
|
+
return undefined
|
|
932
|
+
}
|
|
933
|
+
const timer = setTimeout(() => setSlowRead(true), 3000)
|
|
934
|
+
return () => clearTimeout(timer)
|
|
935
|
+
}, [form, status])
|
|
936
|
+
|
|
937
|
+
const setDraft = (key, next) => {
|
|
938
|
+
setNotice(null)
|
|
939
|
+
setDrafts((prev) => ({ ...prev, [key]: next }))
|
|
940
|
+
}
|
|
941
|
+
const clearDraft = (key) => {
|
|
942
|
+
setDrafts((prev) => {
|
|
943
|
+
if (!(key in prev)) return prev
|
|
944
|
+
const next = { ...prev }
|
|
945
|
+
delete next[key]
|
|
946
|
+
return next
|
|
947
|
+
})
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** The value a control shows: the draft if the user touched it, else the served value. */
|
|
951
|
+
const shownOf = (field) => {
|
|
952
|
+
const key = field.path.join('.')
|
|
953
|
+
if (Object.prototype.hasOwnProperty.call(drafts, key)) return drafts[key]
|
|
954
|
+
return displayValue(field, getPath(value, field.path))
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// Conditional knobs follow what the user is looking at, not the value the host
|
|
958
|
+
// last sent: picking `http` has to reveal the endpoint fields before the save,
|
|
959
|
+
// or the route reads as if it had no settings at all.
|
|
960
|
+
const shownValue =
|
|
961
|
+
Object.keys(drafts).length === 0
|
|
962
|
+
? value
|
|
963
|
+
: ALL_FIELDS.reduce((acc, field) => {
|
|
964
|
+
const key = field.path.join('.')
|
|
965
|
+
if (!Object.prototype.hasOwnProperty.call(drafts, key)) return acc
|
|
966
|
+
return setPath(acc, field.path, configValue(field, drafts[key]))
|
|
967
|
+
}, value)
|
|
968
|
+
|
|
969
|
+
const isDirty = (field) => {
|
|
970
|
+
const key = field.path.join('.')
|
|
971
|
+
if (!Object.prototype.hasOwnProperty.call(drafts, key)) return false
|
|
972
|
+
const current = configValue(field, displayValue(field, getPath(value, field.path)))
|
|
973
|
+
const next = configValue(field, drafts[key])
|
|
974
|
+
if (field.type === 'number' && (!Number.isFinite(current) || !Number.isFinite(next))) return true
|
|
975
|
+
return !sameValue(current, next)
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const overridden = (field) => {
|
|
979
|
+
const own = getPath(user, field.path)
|
|
980
|
+
if (own === undefined) return false
|
|
981
|
+
// A secret never rides back as a value, so the path being present is the
|
|
982
|
+
// only signal there is. Everything else has to actually differ: one save
|
|
983
|
+
// pins the whole served row, and badging values that merely repeat the
|
|
984
|
+
// inherited default would claim edits nobody made.
|
|
985
|
+
if (field.type === 'secret') return true
|
|
986
|
+
const mine = configValue(field, displayValue(field, own))
|
|
987
|
+
const inherited = configValue(field, displayValue(field, getPath(base, field.path)))
|
|
988
|
+
return !sameValue(mine, inherited)
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const collect = () => {
|
|
992
|
+
const ops = []
|
|
993
|
+
const errors = []
|
|
994
|
+
for (const field of ALL_FIELDS) {
|
|
995
|
+
if (!isDirty(field)) continue
|
|
996
|
+
const key = field.path.join('.')
|
|
997
|
+
const next = configValue(field, drafts[key])
|
|
998
|
+
if (field.type === 'secret' && String(next).trim() === '') continue
|
|
999
|
+
const problem = validateValue(field, next)
|
|
1000
|
+
if (problem) {
|
|
1001
|
+
errors.push(`${field.label}: ${problem}`)
|
|
1002
|
+
continue
|
|
1003
|
+
}
|
|
1004
|
+
ops.push({ op: 'set', path: field.path.slice(), value: next })
|
|
1005
|
+
}
|
|
1006
|
+
return { ops, errors }
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const write = (ops, done) => {
|
|
1010
|
+
const run = () => {
|
|
1011
|
+
let pending
|
|
1012
|
+
try {
|
|
1013
|
+
pending = form.mutate(ops)
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
setBusy(false)
|
|
1016
|
+
setNotice({ kind: 'error', text: String((error && error.message) || error) })
|
|
1017
|
+
return
|
|
1018
|
+
}
|
|
1019
|
+
Promise.resolve(pending).then(
|
|
1020
|
+
(accepted) => {
|
|
1021
|
+
setBusy(false)
|
|
1022
|
+
if (accepted === false) {
|
|
1023
|
+
// The controller already re-read the host state on refusal, so the
|
|
1024
|
+
// page is consistent again; say so instead of silently reverting.
|
|
1025
|
+
setDrafts({})
|
|
1026
|
+
setNotice({
|
|
1027
|
+
kind: 'error',
|
|
1028
|
+
text: t(
|
|
1029
|
+
'宿主拒绝了这次保存(配置刚被别处改过?),已重新读取。',
|
|
1030
|
+
'The host refused the write (edited elsewhere?). Reloaded.',
|
|
1031
|
+
),
|
|
1032
|
+
})
|
|
1033
|
+
return
|
|
1034
|
+
}
|
|
1035
|
+
setDrafts({})
|
|
1036
|
+
setNotice({ kind: 'ok', text: t('已保存,立即生效', 'Saved, live now') })
|
|
1037
|
+
if (done) done()
|
|
1038
|
+
},
|
|
1039
|
+
(error) => {
|
|
1040
|
+
setBusy(false)
|
|
1041
|
+
setNotice({ kind: 'error', text: String((error && error.message) || error) })
|
|
1042
|
+
},
|
|
1043
|
+
)
|
|
1044
|
+
}
|
|
1045
|
+
setBusy(true)
|
|
1046
|
+
setNotice(null)
|
|
1047
|
+
run()
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const save = () => {
|
|
1051
|
+
if (!form) return
|
|
1052
|
+
const { ops, errors } = collect()
|
|
1053
|
+
if (errors.length > 0) {
|
|
1054
|
+
setNotice({ kind: 'error', text: errors.join(t(';', '; ')) })
|
|
1055
|
+
return
|
|
1056
|
+
}
|
|
1057
|
+
if (ops.length === 0) {
|
|
1058
|
+
setDrafts({})
|
|
1059
|
+
setNotice(null)
|
|
1060
|
+
return
|
|
1061
|
+
}
|
|
1062
|
+
write(ops)
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const restore = (field) => {
|
|
1066
|
+
if (!form) return
|
|
1067
|
+
clearDraft(field.path.join('.'))
|
|
1068
|
+
write([{ op: 'unset', path: field.path.slice() }])
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const dirtyCount = ALL_FIELDS.filter((field) => isDirty(field)).length
|
|
1072
|
+
|
|
1073
|
+
// ---- states that are not the form ------------------------------------
|
|
1074
|
+
|
|
1075
|
+
const statePanel = (title, lines) =>
|
|
1076
|
+
h(
|
|
1077
|
+
'div',
|
|
1078
|
+
{ className: 'mg-root' },
|
|
1079
|
+
h(
|
|
1080
|
+
'div',
|
|
1081
|
+
{ className: 'mg-state' },
|
|
1082
|
+
h('div', { className: 'mg-state-t' }, title),
|
|
1083
|
+
...lines.map((line, index) =>
|
|
1084
|
+
h('div', { key: index, className: 'mg-state-d' }, ...(Array.isArray(line) ? line : [line])),
|
|
1085
|
+
),
|
|
1086
|
+
),
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
if (!hostReady) {
|
|
1090
|
+
return statePanel(t('这一页需要设置服务', 'This page needs the settings service'), [
|
|
1091
|
+
t(
|
|
1092
|
+
'浏览器端没有找到 settings 的配置表单服务(configForms),通常是「设置」这一域没有被装配。重启 DeepSeek Harness 之后再来一次。',
|
|
1093
|
+
'The browser half could not reach the settings form service (configForms), which usually means the settings domain was never composed. Restart DeepSeek Harness and come back.',
|
|
1094
|
+
),
|
|
1095
|
+
])
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
if (!form) {
|
|
1099
|
+
return statePanel(t('正在读取配置…', 'Reading configuration…'), [])
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
if (status === 'loading') {
|
|
1103
|
+
return statePanel(t('正在读取配置…', 'Reading configuration…'), slowRead
|
|
1104
|
+
? [
|
|
1105
|
+
t(
|
|
1106
|
+
'读到了这一节,但值一直没通过校验,所以停在这里。多数情况是进程里跑的还是更新前的模块:重启 DeepSeek Harness 即可。',
|
|
1107
|
+
'The section was found but its value never validated, so the page waits. Usually the process still runs the pre-update module: restart DeepSeek Harness.',
|
|
1108
|
+
),
|
|
1109
|
+
]
|
|
1110
|
+
: [])
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
if (status !== 'ready' || !value) {
|
|
1114
|
+
return statePanel(t('这一页暂时读不到配置', 'Configuration is not served right now'), [
|
|
1115
|
+
t(
|
|
1116
|
+
'设置服务没有提供 memory-graph 这一节的字段。先确认该插件条目已启用;如果是刚更新的插件,重启 DeepSeek Harness 之后这一页就会出现。',
|
|
1117
|
+
'The settings service does not serve the memory-graph section. Check that the plugin entry is enabled; after a plugin update the page appears once DeepSeek Harness restarts.',
|
|
1118
|
+
),
|
|
1119
|
+
[h('span', null, t('条目 id:', 'entry id: ')), h('span', { className: 'mg-code' }, HOST_ENTRY_ID), h('span', null, t(' 状态:', ' status: ')), h('span', { className: 'mg-code' }, status)],
|
|
1120
|
+
])
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// ---- the form --------------------------------------------------------
|
|
1124
|
+
|
|
1125
|
+
const control = (field) => {
|
|
1126
|
+
const key = field.path.join('.')
|
|
1127
|
+
const shown = shownOf(field)
|
|
1128
|
+
const disabled = !writable || busy
|
|
1129
|
+
const set = (next) => setDraft(key, next)
|
|
1130
|
+
|
|
1131
|
+
if (field.type === 'boolean') {
|
|
1132
|
+
const on = shown === true
|
|
1133
|
+
return h(
|
|
1134
|
+
'button',
|
|
1135
|
+
{
|
|
1136
|
+
type: 'button',
|
|
1137
|
+
className: 'mg-sw',
|
|
1138
|
+
'data-field': key,
|
|
1139
|
+
'data-on': on ? 'true' : 'false',
|
|
1140
|
+
'aria-pressed': on,
|
|
1141
|
+
disabled,
|
|
1142
|
+
onClick: () => set(!on),
|
|
1143
|
+
},
|
|
1144
|
+
h('i', null),
|
|
1145
|
+
)
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
if (field.type === 'enum') {
|
|
1149
|
+
return h(
|
|
1150
|
+
'select',
|
|
1151
|
+
{
|
|
1152
|
+
className: 'mg-input mg-select',
|
|
1153
|
+
'data-field': key,
|
|
1154
|
+
value: shown,
|
|
1155
|
+
disabled,
|
|
1156
|
+
onChange: (event) => set(event.target.value),
|
|
1157
|
+
},
|
|
1158
|
+
(field.options || []).map((option) => h('option', { key: option.value, value: option.value }, option.label)),
|
|
1159
|
+
)
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
if (field.type === 'list') {
|
|
1163
|
+
const lines = String(shown === '' ? '' : shown).split('\n').length
|
|
1164
|
+
return h('textarea', {
|
|
1165
|
+
className: 'mg-area',
|
|
1166
|
+
'data-field': key,
|
|
1167
|
+
rows: Math.min(9, Math.max(3, lines + 1)),
|
|
1168
|
+
spellCheck: false,
|
|
1169
|
+
value: String(shown),
|
|
1170
|
+
disabled,
|
|
1171
|
+
onChange: (event) => set(event.target.value),
|
|
1172
|
+
})
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const inputType = field.type === 'secret' ? 'password' : 'text'
|
|
1176
|
+
return h('input', {
|
|
1177
|
+
className: field.type === 'number' ? 'mg-input mg-num' : 'mg-input mg-text',
|
|
1178
|
+
'data-field': key,
|
|
1179
|
+
type: inputType,
|
|
1180
|
+
value: String(shown),
|
|
1181
|
+
placeholder: field.placeholder || '',
|
|
1182
|
+
spellCheck: false,
|
|
1183
|
+
inputMode: field.type === 'number' ? 'decimal' : undefined,
|
|
1184
|
+
autoComplete: field.type === 'secret' ? 'new-password' : undefined,
|
|
1185
|
+
disabled,
|
|
1186
|
+
onChange: (event) => set(event.target.value),
|
|
1187
|
+
})
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
const row = (field) => {
|
|
1191
|
+
const key = field.path.join('.')
|
|
1192
|
+
const dirty = isDirty(field)
|
|
1193
|
+
const own = overridden(field)
|
|
1194
|
+
return h(
|
|
1195
|
+
'div',
|
|
1196
|
+
{ key, className: field.wide ? 'mg-row mg-row-wide' : 'mg-row', 'data-path': key },
|
|
1197
|
+
h(
|
|
1198
|
+
'div',
|
|
1199
|
+
{ className: 'mg-main' },
|
|
1200
|
+
h(
|
|
1201
|
+
'div',
|
|
1202
|
+
{ className: 'mg-label' },
|
|
1203
|
+
h('span', null, field.label),
|
|
1204
|
+
own ? h('span', { className: 'mg-badge' }, t('已自定义', 'custom')) : null,
|
|
1205
|
+
dirty ? h('span', { className: 'mg-badge' }, t('未保存', 'unsaved')) : null,
|
|
1206
|
+
),
|
|
1207
|
+
field.hint ? h('div', { className: 'mg-hint' }, field.hint) : null,
|
|
1208
|
+
),
|
|
1209
|
+
h(
|
|
1210
|
+
'div',
|
|
1211
|
+
{ className: 'mg-ctl' },
|
|
1212
|
+
control(field),
|
|
1213
|
+
field.type === 'number' && field.unit ? h('span', { className: 'mg-unit' }, field.unit) : null,
|
|
1214
|
+
own && dirty
|
|
1215
|
+
? h(
|
|
1216
|
+
'button',
|
|
1217
|
+
{ type: 'button', className: 'mg-link', disabled: busy || !writable, onClick: () => restore(field) },
|
|
1218
|
+
t('恢复默认', 'reset'),
|
|
1219
|
+
)
|
|
1220
|
+
: null,
|
|
1221
|
+
),
|
|
1222
|
+
)
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
const visibleFields = (section) => section.fields.filter((field) => !field.visible || field.visible(shownValue))
|
|
1226
|
+
|
|
1227
|
+
return h(
|
|
1228
|
+
'div',
|
|
1229
|
+
{ className: 'mg-page' },
|
|
1230
|
+
h(
|
|
1231
|
+
'div',
|
|
1232
|
+
{ className: 'mg-root' },
|
|
1233
|
+
h(
|
|
1234
|
+
'div',
|
|
1235
|
+
{ className: 'mg-head' },
|
|
1236
|
+
h('div', { className: 'mg-title' }, t('记忆图谱', 'Memory graph')),
|
|
1237
|
+
h(
|
|
1238
|
+
'div',
|
|
1239
|
+
{ className: 'mg-sub' },
|
|
1240
|
+
t(
|
|
1241
|
+
'长期联想记忆:原始片段 → 带类型的记忆卡 → 实体页。下面的每一项都立即生效,不需要重启。',
|
|
1242
|
+
'Long-term associative memory: raw episodes → typed cards → entity pages. Every knob below applies immediately, no restart.',
|
|
1243
|
+
),
|
|
1244
|
+
),
|
|
1245
|
+
),
|
|
1246
|
+
|
|
1247
|
+
...SECTIONS.map((section) =>
|
|
1248
|
+
h(
|
|
1249
|
+
'section',
|
|
1250
|
+
{ key: section.id, className: 'mg-card' },
|
|
1251
|
+
h(
|
|
1252
|
+
'div',
|
|
1253
|
+
{ className: 'mg-card-h' },
|
|
1254
|
+
h('div', { className: 'mg-card-t' }, section.title),
|
|
1255
|
+
section.hint ? h('div', { className: 'mg-card-d' }, section.hint) : null,
|
|
1256
|
+
),
|
|
1257
|
+
h('div', { className: 'mg-rows' }, visibleFields(section).map(row)),
|
|
1258
|
+
),
|
|
1259
|
+
),
|
|
1260
|
+
|
|
1261
|
+
h(
|
|
1262
|
+
'div',
|
|
1263
|
+
{ className: 'mg-note' },
|
|
1264
|
+
h(
|
|
1265
|
+
'div',
|
|
1266
|
+
null,
|
|
1267
|
+
t(
|
|
1268
|
+
'下面几项只在插件启动时读一次,因此不在这一页:总开关、记忆库目录、默认项目名、跨项目连接。改它们需要重载插件。',
|
|
1269
|
+
'Not on this page, because the plugin reads them once while activating: the master switch, the vault directory, the default project tag and cross-project linking. Changing one reloads the plugin.',
|
|
1270
|
+
),
|
|
1271
|
+
),
|
|
1272
|
+
h(
|
|
1273
|
+
'div',
|
|
1274
|
+
null,
|
|
1275
|
+
t('改它们:profile 的 ', 'To change one, edit '),
|
|
1276
|
+
h('span', { className: 'mg-code' }, 'cordis.patch.yml'),
|
|
1277
|
+
t(' 里 ', ' in the profile and look for the '),
|
|
1278
|
+
h('span', { className: 'mg-code' }, '- id: memory-graph'),
|
|
1279
|
+
t(' 那一行的 config。', ' row, under config.'),
|
|
1280
|
+
),
|
|
1281
|
+
h(
|
|
1282
|
+
'div',
|
|
1283
|
+
null,
|
|
1284
|
+
t('当前记忆库:', 'Vault: '),
|
|
1285
|
+
h('span', { className: 'mg-code' }, String(getPath(value, ['vaultDir']) || '')),
|
|
1286
|
+
),
|
|
1287
|
+
),
|
|
1288
|
+
),
|
|
1289
|
+
|
|
1290
|
+
h(
|
|
1291
|
+
'div',
|
|
1292
|
+
{ className: 'mg-bar' },
|
|
1293
|
+
h(
|
|
1294
|
+
'div',
|
|
1295
|
+
{ className: 'mg-msg', 'data-kind': (notice && notice.kind) || '' },
|
|
1296
|
+
notice ? notice.text : dirtyCount > 0 ? t(`${dirtyCount} 项改动未保存`, `${dirtyCount} unsaved`) : '',
|
|
1297
|
+
),
|
|
1298
|
+
h(
|
|
1299
|
+
'button',
|
|
1300
|
+
{ type: 'button', className: 'mg-btn-ghost', disabled: busy || dirtyCount === 0, onClick: () => { setDrafts({}); setNotice(null) } },
|
|
1301
|
+
t('撤销改动', 'Discard'),
|
|
1302
|
+
),
|
|
1303
|
+
h(
|
|
1304
|
+
'button',
|
|
1305
|
+
{ type: 'button', className: 'mg-btn', disabled: busy || dirtyCount === 0 || !writable, onClick: save },
|
|
1306
|
+
busy ? t('保存中…', 'Saving…') : t('保存', 'Save'),
|
|
1307
|
+
),
|
|
1308
|
+
),
|
|
1309
|
+
)
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
return Page
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function safeSnapshot(form) {
|
|
1316
|
+
try {
|
|
1317
|
+
return form.getSnapshot() || null
|
|
1318
|
+
} catch {
|
|
1319
|
+
return null
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// ---------------------------------------------------------------------------
|
|
1324
|
+
// 对话页签:这个会话的记忆
|
|
1325
|
+
//
|
|
1326
|
+
// The tab sits in the Conversation view row beside 对话 and 上下文, so it is
|
|
1327
|
+
// reached from the conversation it describes. The host answers on one exact
|
|
1328
|
+
// Fetch route (`/api/memory-graph.graph`); Connection admits the browser's own
|
|
1329
|
+
// cookie first and hands back only an owner Peer, so the panel never invents an
|
|
1330
|
+
// auth scheme of its own.
|
|
1331
|
+
//
|
|
1332
|
+
// The layout is computed on the host. The browser therefore only maps points
|
|
1333
|
+
// onto the viewBox and draws them: no simulation per render, no second layout
|
|
1334
|
+
// when the tab is reopened, and the same picture for the same vault.
|
|
1335
|
+
// ---------------------------------------------------------------------------
|
|
1336
|
+
|
|
1337
|
+
const KIND_FILL = {
|
|
1338
|
+
decision: '#3b6ef0',
|
|
1339
|
+
preference: '#7a4fd0',
|
|
1340
|
+
fact: '#1a7f37',
|
|
1341
|
+
plan: '#b26a00',
|
|
1342
|
+
constraint: '#c62828',
|
|
1343
|
+
note: '#5a6470',
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// The two structural layers are not card kinds, so they get their own colours:
|
|
1347
|
+
// an episode is this conversation's own words, an entity is what links it to
|
|
1348
|
+
// everything else.
|
|
1349
|
+
const EPISODE_FILL = '#9fb4d3'
|
|
1350
|
+
const ENTITY_FILL = '#9aa4b2'
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* The registered route is an absolute `/api/...` path, but the page that runs
|
|
1354
|
+
* this bundle is not always at the origin root: a mounted deployment serves the
|
|
1355
|
+
* GUI under a prefix, and a custom-scheme page has no usable origin at all. So
|
|
1356
|
+
* resolve explicit origin + path when the page has a real http(s) origin, and
|
|
1357
|
+
* fall back to letting the page resolve the path itself otherwise.
|
|
1358
|
+
*/
|
|
1359
|
+
function apiUrl(path) {
|
|
1360
|
+
try {
|
|
1361
|
+
const location = globalThis.location
|
|
1362
|
+
const origin = String((location && location.origin) || '')
|
|
1363
|
+
if (/^https?:\/\//.test(origin)) return `${origin}${path}`
|
|
1364
|
+
} catch {
|
|
1365
|
+
// No usable location: fall through to the bare path.
|
|
1366
|
+
}
|
|
1367
|
+
return path
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function fillOf(node) {
|
|
1371
|
+
if (node.k === 'session') return '#111318'
|
|
1372
|
+
if (node.k === 'episode') return EPISODE_FILL
|
|
1373
|
+
if (node.k === 'entity') return ENTITY_FILL
|
|
1374
|
+
if (node.foreign) return '#e2e6ee'
|
|
1375
|
+
return KIND_FILL[node.kind] ?? KIND_FILL.note
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function createGraphView(ctx, React) {
|
|
1379
|
+
const h = React.createElement
|
|
1380
|
+
const { useCallback, useEffect, useMemo, useRef, useState } = React
|
|
1381
|
+
|
|
1382
|
+
function kindText(node) {
|
|
1383
|
+
if (node.k === 'session') return t('本次会话', 'this session')
|
|
1384
|
+
if (node.k === 'episode') return t('情节', 'episode')
|
|
1385
|
+
if (node.k === 'entity') return t('实体', 'entity')
|
|
1386
|
+
const map = {
|
|
1387
|
+
decision: t('决定', 'decision'),
|
|
1388
|
+
preference: t('偏好', 'preference'),
|
|
1389
|
+
fact: t('事实', 'fact'),
|
|
1390
|
+
plan: t('计划', 'plan'),
|
|
1391
|
+
constraint: t('限制', 'constraint'),
|
|
1392
|
+
note: t('笔记', 'note'),
|
|
1393
|
+
}
|
|
1394
|
+
return map[node.kind] ?? node.kind
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function statusText(status) {
|
|
1398
|
+
if (status === 'superseded') return t('已被推翻', 'superseded')
|
|
1399
|
+
if (status === 'retracted') return t('已撤回', 'retracted')
|
|
1400
|
+
return t('有效', 'active')
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
return function SessionMemoryView(props) {
|
|
1404
|
+
const sessionId = props && props.sessionId ? String(props.sessionId) : ''
|
|
1405
|
+
const [data, setData] = useState(null)
|
|
1406
|
+
const [error, setError] = useState('')
|
|
1407
|
+
const [busy, setBusy] = useState(false)
|
|
1408
|
+
const [distilling, setDistilling] = useState(false)
|
|
1409
|
+
const [distillNote, setDistillNote] = useState(null)
|
|
1410
|
+
const [selectedKey, setSelectedKey] = useState('')
|
|
1411
|
+
const [hoverKey, setHoverKey] = useState('')
|
|
1412
|
+
const ticket = useRef(0)
|
|
1413
|
+
const mounted = useRef(true)
|
|
1414
|
+
|
|
1415
|
+
const load = useCallback(
|
|
1416
|
+
(quiet) => {
|
|
1417
|
+
if (sessionId === '') return undefined
|
|
1418
|
+
if (quiet !== true) setBusy(true)
|
|
1419
|
+
const mine = ++ticket.current
|
|
1420
|
+
return fetch(apiUrl(`${GRAPH_API}?session=${encodeURIComponent(sessionId)}`), {
|
|
1421
|
+
headers: { accept: 'application/json' },
|
|
1422
|
+
credentials: 'same-origin',
|
|
1423
|
+
})
|
|
1424
|
+
.then((response) =>
|
|
1425
|
+
response
|
|
1426
|
+
.json()
|
|
1427
|
+
.catch(() => null)
|
|
1428
|
+
.then((body) => ({ response, body })),
|
|
1429
|
+
)
|
|
1430
|
+
.then(({ response, body }) => {
|
|
1431
|
+
if (!mounted.current || mine !== ticket.current) return
|
|
1432
|
+
if (!response.ok || !body || body.ok !== true) {
|
|
1433
|
+
const why = (body && (body.message || body.reason)) || `HTTP ${response.status}`
|
|
1434
|
+
setError(String(why))
|
|
1435
|
+
setBusy(false)
|
|
1436
|
+
return
|
|
1437
|
+
}
|
|
1438
|
+
setData(body)
|
|
1439
|
+
setError('')
|
|
1440
|
+
setBusy(false)
|
|
1441
|
+
})
|
|
1442
|
+
.catch((failure) => {
|
|
1443
|
+
if (!mounted.current || mine !== ticket.current) return
|
|
1444
|
+
setError(String((failure && failure.message) || failure))
|
|
1445
|
+
setBusy(false)
|
|
1446
|
+
})
|
|
1447
|
+
},
|
|
1448
|
+
[sessionId],
|
|
1449
|
+
)
|
|
1450
|
+
|
|
1451
|
+
useEffect(() => {
|
|
1452
|
+
mounted.current = true
|
|
1453
|
+
setData(null)
|
|
1454
|
+
setError('')
|
|
1455
|
+
setSelectedKey('')
|
|
1456
|
+
setHoverKey('')
|
|
1457
|
+
setDistillNote(null)
|
|
1458
|
+
if (sessionId === '') return undefined
|
|
1459
|
+
load(false)
|
|
1460
|
+
// The graph changes when you talk, distill, or edit a card elsewhere. A
|
|
1461
|
+
// quiet poll keeps the open tab honest without a spinner every 20 seconds.
|
|
1462
|
+
const timer = setInterval(() => {
|
|
1463
|
+
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
|
|
1464
|
+
load(true)
|
|
1465
|
+
}, 20000)
|
|
1466
|
+
return () => {
|
|
1467
|
+
mounted.current = false
|
|
1468
|
+
clearInterval(timer)
|
|
1469
|
+
ticket.current += 1
|
|
1470
|
+
}
|
|
1471
|
+
}, [sessionId, load])
|
|
1472
|
+
|
|
1473
|
+
const nodes = (data && data.nodes) || []
|
|
1474
|
+
const links = (data && data.links) || []
|
|
1475
|
+
const byKey = useMemo(() => {
|
|
1476
|
+
const map = new Map()
|
|
1477
|
+
for (const node of nodes) map.set(node.key, node)
|
|
1478
|
+
return map
|
|
1479
|
+
}, [nodes])
|
|
1480
|
+
const neighbours = useMemo(() => {
|
|
1481
|
+
const map = new Map()
|
|
1482
|
+
for (const node of nodes) map.set(node.key, new Set())
|
|
1483
|
+
for (const link of links) {
|
|
1484
|
+
map.get(link.s)?.add(link.t)
|
|
1485
|
+
map.get(link.t)?.add(link.s)
|
|
1486
|
+
}
|
|
1487
|
+
return map
|
|
1488
|
+
}, [nodes, links])
|
|
1489
|
+
|
|
1490
|
+
const focus = hoverKey !== '' ? hoverKey : selectedKey
|
|
1491
|
+
const near = neighbours.get(focus)
|
|
1492
|
+
const selected = (selectedKey !== '' && byKey.get(selectedKey)) || null
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* Run one distillation and then re-read the graph.
|
|
1496
|
+
*
|
|
1497
|
+
* This is the only place in the panel that spends anything: the button posts
|
|
1498
|
+
* to the host, the host runs the same `distill()` the `memory_distill` tool
|
|
1499
|
+
* runs. Nothing is distilled unless somebody presses it — capture is passive,
|
|
1500
|
+
* and a background model call the user did not ask for would be a surprise
|
|
1501
|
+
* bill.
|
|
1502
|
+
*/
|
|
1503
|
+
const distillNow = useCallback(() => {
|
|
1504
|
+
if (distilling) return
|
|
1505
|
+
setDistilling(true)
|
|
1506
|
+
setDistillNote(null)
|
|
1507
|
+
return fetch(apiUrl(DISTILL_API), {
|
|
1508
|
+
method: 'POST',
|
|
1509
|
+
headers: { accept: 'application/json' },
|
|
1510
|
+
credentials: 'same-origin',
|
|
1511
|
+
})
|
|
1512
|
+
.then((response) =>
|
|
1513
|
+
response
|
|
1514
|
+
.json()
|
|
1515
|
+
.catch(() => null)
|
|
1516
|
+
.then((body) => ({ response, body })),
|
|
1517
|
+
)
|
|
1518
|
+
.then(({ response, body }) => {
|
|
1519
|
+
if (!mounted.current) return
|
|
1520
|
+
if (body && body.busy === true) {
|
|
1521
|
+
setDistillNote({ kind: 'err', text: t('已经有一次蒸馏在跑,等它结束。', 'A distillation is already running; let it finish.') })
|
|
1522
|
+
} else if (!body || body.ok !== true) {
|
|
1523
|
+
const why = (body && (body.message || body.reason)) || `HTTP ${response.status}`
|
|
1524
|
+
setDistillNote({ kind: 'err', text: String(why) })
|
|
1525
|
+
} else if ((body.episodes ?? 0) === 0) {
|
|
1526
|
+
setDistillNote({ kind: 'ok', text: String(body.reason || t('没有新情节要蒸馏。', 'Nothing new to distill.')) })
|
|
1527
|
+
} else {
|
|
1528
|
+
const batches = body.batches && body.batches.done > 1 ? t(` · ${body.batches.done} 批`, ` · ${body.batches.done} batches`) : ''
|
|
1529
|
+
setDistillNote({
|
|
1530
|
+
kind: 'ok',
|
|
1531
|
+
text: t(`读出 ${body.episodes} 条情节 → ${body.written} 张卡片${batches}`, `read ${body.episodes} episodes → ${body.written} cards${batches}`),
|
|
1532
|
+
})
|
|
1533
|
+
}
|
|
1534
|
+
setDistilling(false)
|
|
1535
|
+
load(true)
|
|
1536
|
+
})
|
|
1537
|
+
.catch((failure) => {
|
|
1538
|
+
if (!mounted.current) return
|
|
1539
|
+
setDistillNote({ kind: 'err', text: String((failure && failure.message) || failure) })
|
|
1540
|
+
setDistilling(false)
|
|
1541
|
+
})
|
|
1542
|
+
}, [distilling, load])
|
|
1543
|
+
|
|
1544
|
+
const head = h(
|
|
1545
|
+
'div',
|
|
1546
|
+
{ className: 'mgv-head' },
|
|
1547
|
+
h('span', { className: 'mgv-title' }, t('这个会话的记忆', 'Memory for this session')),
|
|
1548
|
+
data
|
|
1549
|
+
? h(
|
|
1550
|
+
'span',
|
|
1551
|
+
{ className: 'mgv-counts' },
|
|
1552
|
+
t(
|
|
1553
|
+
`${data.counts.episodes} 条情节 · ${data.counts.cards} 张卡片 · ${data.counts.entities} 个实体`,
|
|
1554
|
+
`${data.counts.episodes} episodes · ${data.counts.cards} cards · ${data.counts.entities} entities`,
|
|
1555
|
+
),
|
|
1556
|
+
)
|
|
1557
|
+
: null,
|
|
1558
|
+
data && data.project ? h('span', { className: 'mgv-chip' }, data.project) : null,
|
|
1559
|
+
data && data.counts.foreign > 0
|
|
1560
|
+
? h('span', { className: 'mgv-chip' }, t(`跨项目 ${data.counts.foreign}`, `${data.counts.foreign} cross-project`))
|
|
1561
|
+
: null,
|
|
1562
|
+
h('span', { className: 'mgv-spacer' }),
|
|
1563
|
+
h(
|
|
1564
|
+
'div',
|
|
1565
|
+
{ className: 'mgv-legend' },
|
|
1566
|
+
h('span', null, h('i', { className: 'mgv-dot', style: { background: EPISODE_FILL } }), t('情节', 'episode')),
|
|
1567
|
+
h('span', null, h('i', { className: 'mgv-dot', style: { background: KIND_FILL.decision } }), t('卡片', 'card')),
|
|
1568
|
+
h('span', null, h('i', { className: 'mgv-dot', style: { background: ENTITY_FILL } }), t('实体', 'entity')),
|
|
1569
|
+
),
|
|
1570
|
+
h(
|
|
1571
|
+
'button',
|
|
1572
|
+
{
|
|
1573
|
+
type: 'button',
|
|
1574
|
+
className: 'mg-btn-ghost mgv-distill',
|
|
1575
|
+
disabled: distilling || sessionId === '',
|
|
1576
|
+
title: t(
|
|
1577
|
+
'把还没蒸馏的情节变成卡片(借设置里选中的模型,会花它的额度)',
|
|
1578
|
+
'Turn undistilled episodes into cards, borrowing the model selected in Settings (it spends that model budget)',
|
|
1579
|
+
),
|
|
1580
|
+
onClick: distillNow,
|
|
1581
|
+
},
|
|
1582
|
+
distilling ? t('蒸馏中…', 'Distilling…') : t('蒸馏一次', 'Distill now'),
|
|
1583
|
+
),
|
|
1584
|
+
h(
|
|
1585
|
+
'button',
|
|
1586
|
+
{ type: 'button', className: 'mg-btn-ghost', disabled: busy || sessionId === '', onClick: () => load(false) },
|
|
1587
|
+
busy ? t('读取中…', 'Reading…') : t('刷新', 'Refresh'),
|
|
1588
|
+
),
|
|
1589
|
+
)
|
|
1590
|
+
|
|
1591
|
+
if (sessionId === '') {
|
|
1592
|
+
return h(
|
|
1593
|
+
'div',
|
|
1594
|
+
{ className: 'mgv-root' },
|
|
1595
|
+
head,
|
|
1596
|
+
h(
|
|
1597
|
+
'div',
|
|
1598
|
+
{ className: 'mgv-empty' },
|
|
1599
|
+
h('div', { className: 'mgv-empty-t' }, t('还没有选中的会话', 'No session selected')),
|
|
1600
|
+
h('div', { className: 'mgv-empty-d' }, t('打开一个会话再回来。', 'Open a conversation and come back.')),
|
|
1601
|
+
),
|
|
1602
|
+
)
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
if (error !== '') {
|
|
1606
|
+
return h(
|
|
1607
|
+
'div',
|
|
1608
|
+
{ className: 'mgv-root' },
|
|
1609
|
+
head,
|
|
1610
|
+
h(
|
|
1611
|
+
'div',
|
|
1612
|
+
{ className: 'mgv-empty' },
|
|
1613
|
+
h('div', { className: 'mgv-empty-t' }, t('读不到记忆', 'Cannot read the memory vault')),
|
|
1614
|
+
h('div', { className: 'mgv-empty-d' }, error),
|
|
1615
|
+
),
|
|
1616
|
+
)
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
if (!data) {
|
|
1620
|
+
return h(
|
|
1621
|
+
'div',
|
|
1622
|
+
{ className: 'mgv-root' },
|
|
1623
|
+
head,
|
|
1624
|
+
h('div', { className: 'mgv-empty' }, h('div', { className: 'mgv-empty-d' }, t('读取中…', 'Reading…'))),
|
|
1625
|
+
)
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
if (data.empty) {
|
|
1629
|
+
return h(
|
|
1630
|
+
'div',
|
|
1631
|
+
{ className: 'mgv-root' },
|
|
1632
|
+
head,
|
|
1633
|
+
h(
|
|
1634
|
+
'div',
|
|
1635
|
+
{ className: 'mgv-empty' },
|
|
1636
|
+
h('div', { className: 'mgv-empty-t' }, t('这个会话还没有留下情节', 'This session has not left an episode yet')),
|
|
1637
|
+
h(
|
|
1638
|
+
'div',
|
|
1639
|
+
{ className: 'mgv-empty-d' },
|
|
1640
|
+
t(
|
|
1641
|
+
'每聊完一轮就落一条原始片段。聊一轮再刷新,或者确认 设置 → 记忆图谱 里「记录对话」是开着的。',
|
|
1642
|
+
'One episode is written per finished turn. Talk a turn and refresh, or check that 记录对话 is on in Settings → Memory graph.',
|
|
1643
|
+
),
|
|
1644
|
+
),
|
|
1645
|
+
),
|
|
1646
|
+
)
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
return h(
|
|
1650
|
+
'div',
|
|
1651
|
+
{ className: 'mgv-root' },
|
|
1652
|
+
head,
|
|
1653
|
+
// What the last press of 蒸馏一次 did: a count, "nothing new", or why it
|
|
1654
|
+
// could not run. Printed as a line rather than a toast because the numbers
|
|
1655
|
+
// are worth reading twice.
|
|
1656
|
+
distillNote
|
|
1657
|
+
? h('div', { className: `mgv-note mgv-note-${distillNote.kind} mgv-distill-note` }, distillNote.text)
|
|
1658
|
+
: null,
|
|
1659
|
+
// An empty half of the picture should say which half is missing: with no
|
|
1660
|
+
// cards in the vault the graph can only ever show episodes, and that reads
|
|
1661
|
+
// as "broken" unless it is spelled out.
|
|
1662
|
+
data && data.counts.cards === 0 && data.counts.episodes > 0
|
|
1663
|
+
? h(
|
|
1664
|
+
'div',
|
|
1665
|
+
{ className: 'mgv-note' },
|
|
1666
|
+
t(
|
|
1667
|
+
'记忆库里还没有卡片:蒸馏一次之后,这里会从这些情节长出卡片,以及把它们连起来的实体。',
|
|
1668
|
+
'No cards in the vault yet: one distillation grows cards — and the entities that link them — out of these episodes.',
|
|
1669
|
+
),
|
|
1670
|
+
)
|
|
1671
|
+
: null,
|
|
1672
|
+
h(
|
|
1673
|
+
'div',
|
|
1674
|
+
{ className: 'mgv-body' },
|
|
1675
|
+
h(
|
|
1676
|
+
'svg',
|
|
1677
|
+
{
|
|
1678
|
+
className: 'mgv-canvas',
|
|
1679
|
+
viewBox: '0 0 1080 640',
|
|
1680
|
+
preserveAspectRatio: 'xMidYMid meet',
|
|
1681
|
+
onClick: () => setSelectedKey(''),
|
|
1682
|
+
},
|
|
1683
|
+
links.map((link, i) => {
|
|
1684
|
+
const a = byKey.get(link.s)
|
|
1685
|
+
const b = byKey.get(link.t)
|
|
1686
|
+
if (!a || !b) return null
|
|
1687
|
+
const dim = focus !== '' && !(link.s === focus || link.t === focus)
|
|
1688
|
+
return h('line', {
|
|
1689
|
+
key: `l${i}`,
|
|
1690
|
+
className: 'mgv-link',
|
|
1691
|
+
'data-k': link.k,
|
|
1692
|
+
x1: a.x,
|
|
1693
|
+
y1: a.y,
|
|
1694
|
+
x2: b.x,
|
|
1695
|
+
y2: b.y,
|
|
1696
|
+
opacity: dim ? 0.35 : 0.9,
|
|
1697
|
+
})
|
|
1698
|
+
}),
|
|
1699
|
+
nodes.map((node) => {
|
|
1700
|
+
const dim = near !== undefined && !near.has(node.key) && node.key !== focus
|
|
1701
|
+
return h(
|
|
1702
|
+
'g',
|
|
1703
|
+
{
|
|
1704
|
+
key: node.key,
|
|
1705
|
+
className: 'mgv-node',
|
|
1706
|
+
'data-k': node.k,
|
|
1707
|
+
'data-dim': dim ? 'true' : 'false',
|
|
1708
|
+
'data-selected': node.key === selectedKey ? 'true' : 'false',
|
|
1709
|
+
onClick: (event) => {
|
|
1710
|
+
event.stopPropagation()
|
|
1711
|
+
setSelectedKey(node.key === selectedKey ? '' : node.key)
|
|
1712
|
+
},
|
|
1713
|
+
onMouseEnter: () => setHoverKey(node.key),
|
|
1714
|
+
onMouseLeave: () => setHoverKey(''),
|
|
1715
|
+
},
|
|
1716
|
+
h('circle', {
|
|
1717
|
+
cx: node.x,
|
|
1718
|
+
cy: node.y,
|
|
1719
|
+
r: node.r,
|
|
1720
|
+
fill: fillOf(node),
|
|
1721
|
+
stroke: node.k === 'session' ? 'none' : 'rgba(0,0,0,0.10)',
|
|
1722
|
+
strokeWidth: 1,
|
|
1723
|
+
strokeDasharray: node.foreign ? '2 2' : undefined,
|
|
1724
|
+
}),
|
|
1725
|
+
h(
|
|
1726
|
+
'text',
|
|
1727
|
+
{
|
|
1728
|
+
x: node.x + node.r + 5,
|
|
1729
|
+
y: node.y + 3.5,
|
|
1730
|
+
textAnchor: node.k === 'session' ? 'middle' : 'start',
|
|
1731
|
+
...(node.k === 'session' ? { x: node.x, y: node.y - node.r - 8 } : {}),
|
|
1732
|
+
},
|
|
1733
|
+
node.k === 'session' ? t('本次会话', 'this session') : node.label,
|
|
1734
|
+
),
|
|
1735
|
+
)
|
|
1736
|
+
}),
|
|
1737
|
+
),
|
|
1738
|
+
selected
|
|
1739
|
+
? h(
|
|
1740
|
+
'aside',
|
|
1741
|
+
{ className: 'mgv-aside' },
|
|
1742
|
+
h('div', { className: 'mgv-aside-h' }, selected.k === 'session' ? t('本次会话', 'This session') : selected.k === 'entity' ? selected.label : selected.label),
|
|
1743
|
+
h(
|
|
1744
|
+
'div',
|
|
1745
|
+
{ className: 'mgv-aside-meta' },
|
|
1746
|
+
h('span', { className: 'mgv-chip' }, kindText(selected)),
|
|
1747
|
+
selected.project ? h('span', { className: 'mgv-chip' }, selected.project) : null,
|
|
1748
|
+
selected.status ? h('span', { className: 'mgv-chip' }, statusText(selected.status)) : null,
|
|
1749
|
+
selected.created ? h('span', { className: 'mgv-chip' }, selected.created) : null,
|
|
1750
|
+
),
|
|
1751
|
+
h(
|
|
1752
|
+
'div',
|
|
1753
|
+
{ className: 'mgv-aside-b' },
|
|
1754
|
+
selected.k === 'session'
|
|
1755
|
+
? t(
|
|
1756
|
+
`这个会话写下了 ${data.counts.episodes} 条情节,其中 ${data.recall.length} 张卡片会被它自己的话唤回(虚线的边)。`,
|
|
1757
|
+
`${data.counts.episodes} episodes from this session; ${data.recall.length} cards would be recalled by its own words (dashed edges).`,
|
|
1758
|
+
)
|
|
1759
|
+
: selected.k === 'episode'
|
|
1760
|
+
? selected.text || t('(这一轮没有留下用户原文)', '(no user text in this turn)')
|
|
1761
|
+
: selected.k === 'card'
|
|
1762
|
+
? selected.body || ''
|
|
1763
|
+
: selected.k === 'entity'
|
|
1764
|
+
? t(
|
|
1765
|
+
`被 ${selected.mentions ?? 0} 段记忆提到过。图上连着的卡片就是它串起来的记忆。`,
|
|
1766
|
+
`Mentioned by ${selected.mentions ?? 0} memories. The cards joined to it are the memories it links.`,
|
|
1767
|
+
)
|
|
1768
|
+
: '',
|
|
1769
|
+
),
|
|
1770
|
+
selected.entityNames && selected.entityNames.length > 0
|
|
1771
|
+
? h(
|
|
1772
|
+
'div',
|
|
1773
|
+
{ className: 'mgv-aside-tags' },
|
|
1774
|
+
selected.entityNames.map((name, i) => h('span', { key: `e${i}`, className: 'mgv-chip' }, name)),
|
|
1775
|
+
)
|
|
1776
|
+
: null,
|
|
1777
|
+
selected.status === 'superseded' && selected.supersededBy
|
|
1778
|
+
? h('div', { className: 'mgv-aside-tags' }, h('span', { className: 'mgv-chip' }, t(`被 ${selected.supersededBy} 取代`, `replaced by ${selected.supersededBy}`)))
|
|
1779
|
+
: null,
|
|
1780
|
+
selected.tags && selected.tags.length > 0
|
|
1781
|
+
? h(
|
|
1782
|
+
'div',
|
|
1783
|
+
{ className: 'mgv-aside-tags' },
|
|
1784
|
+
selected.tags.map((tag, i) => h('span', { key: `t${i}`, className: 'mgv-chip' }, `#${tag}`)),
|
|
1785
|
+
)
|
|
1786
|
+
: null,
|
|
1787
|
+
)
|
|
1788
|
+
: null,
|
|
1789
|
+
),
|
|
1790
|
+
)
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// ---------------------------------------------------------------------------
|
|
1795
|
+
// Entry point
|
|
1796
|
+
// ---------------------------------------------------------------------------
|
|
1797
|
+
|
|
1798
|
+
/**
|
|
1799
|
+
* The two slots this bundle fills. Split out of `apply` so the suite can drive
|
|
1800
|
+
* it with a stub `ctx` and assert the exact registrations: which slot, which id,
|
|
1801
|
+
* which order, and that a fresh id is used rather than a shipped one (reusing
|
|
1802
|
+
* `chat` or `trajectory` would replace that cell instead of adding one).
|
|
1803
|
+
*/
|
|
1804
|
+
/**
|
|
1805
|
+
* The sidebar rail glyph: one node with three neighbours, drawn at the size the
|
|
1806
|
+
* rail asks for.
|
|
1807
|
+
*/
|
|
1808
|
+
function createGraphIcon(React) {
|
|
1809
|
+
const h = React.createElement
|
|
1810
|
+
const stroke = { stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round' }
|
|
1811
|
+
return function MemoryGraphIcon(props) {
|
|
1812
|
+
const size = typeof props?.size === 'number' ? props.size : 18
|
|
1813
|
+
return h(
|
|
1814
|
+
'svg',
|
|
1815
|
+
{ width: size, height: size, viewBox: '0 0 20 20', fill: 'none', 'aria-hidden': 'true' },
|
|
1816
|
+
h('path', { d: 'M10 11.6 5.2 11M10 11.6 14.8 11M10 9.2V4.7', ...stroke }),
|
|
1817
|
+
h('circle', { cx: 10, cy: 11.6, r: 2.3, ...stroke }),
|
|
1818
|
+
h('circle', { cx: 5.2, cy: 11, r: 1.5, ...stroke }),
|
|
1819
|
+
h('circle', { cx: 14.8, cy: 11, r: 1.5, ...stroke }),
|
|
1820
|
+
h('circle', { cx: 10, cy: 3.9, r: 1.5, ...stroke }),
|
|
1821
|
+
)
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
/**
|
|
1826
|
+
* The whole-vault panel: the graph you would otherwise open from the vault folder,
|
|
1827
|
+
* inside the app.
|
|
1828
|
+
*
|
|
1829
|
+
* It does not redraw the graph in React. `graph.html` already is the whole-vault
|
|
1830
|
+
* renderer — search, project filter, hover cards, dark mode — and a second
|
|
1831
|
+
* implementation here would be a second thing to keep in step with the vault. So
|
|
1832
|
+
* the panel embeds the page the host serves at PAGE_API, which is the same bytes
|
|
1833
|
+
* as the file on disk: one renderer, nothing to drift.
|
|
1834
|
+
*/
|
|
1835
|
+
function createVaultPanel(React) {
|
|
1836
|
+
const h = React.createElement
|
|
1837
|
+
return function VaultGraphPanel() {
|
|
1838
|
+
// The iframe is the cache: a new query string is how it reloads.
|
|
1839
|
+
const [nonce, setNonce] = React.useState(0)
|
|
1840
|
+
const src = nonce === 0 ? PAGE_API : `${PAGE_API}?v=${nonce}`
|
|
1841
|
+
return h(
|
|
1842
|
+
'div',
|
|
1843
|
+
{ className: 'mgv-panel' },
|
|
1844
|
+
h(
|
|
1845
|
+
'div',
|
|
1846
|
+
{ className: 'mgv-panel-bar' },
|
|
1847
|
+
h('span', { className: 'mgv-panel-title' }, t('整个记忆库', 'The whole vault')),
|
|
1848
|
+
h('span', { className: 'mgv-panel-spacer' }),
|
|
1849
|
+
h(
|
|
1850
|
+
'button',
|
|
1851
|
+
{ type: 'button', className: 'mg-btn', onClick: () => setNonce((n) => n + 1) },
|
|
1852
|
+
t('刷新', 'Refresh'),
|
|
1853
|
+
),
|
|
1854
|
+
h(
|
|
1855
|
+
'a',
|
|
1856
|
+
{ className: 'mg-btn', href: PAGE_API, target: '_blank', rel: 'noreferrer' },
|
|
1857
|
+
t('新窗口打开', 'Open in a window'),
|
|
1858
|
+
),
|
|
1859
|
+
),
|
|
1860
|
+
h('iframe', { className: 'mgv-panel-frame', src, title: t('记忆图', 'Memory graph') }),
|
|
1861
|
+
)
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
function registerSlots(ctx, React) {
|
|
1866
|
+
ensureStyle()
|
|
1867
|
+
const Page = createPage(ctx, React)
|
|
1868
|
+
ctx.slots.inject('settings.section', () =>
|
|
1869
|
+
ctx.slots.register(
|
|
1870
|
+
{
|
|
1871
|
+
name: 'settings.section',
|
|
1872
|
+
id: HOST_ENTRY_ID,
|
|
1873
|
+
// 15 is 插件, 20 预设, 21 导入, 40 桌宠: sit between presets and the pet page.
|
|
1874
|
+
order: 25,
|
|
1875
|
+
label: () => t('记忆图谱', 'Memory Graph'),
|
|
1876
|
+
},
|
|
1877
|
+
Page,
|
|
1878
|
+
),
|
|
1879
|
+
)
|
|
1880
|
+
|
|
1881
|
+
// A view of its own in the Conversation row: after the chat (0) and the
|
|
1882
|
+
// context it burned (10), what survived it. Registering the entry is enough —
|
|
1883
|
+
// the row shows a tab once the session has more than one view.
|
|
1884
|
+
const SessionMemoryView = createGraphView(ctx, React)
|
|
1885
|
+
ctx.slots.inject('conversation.view', () =>
|
|
1886
|
+
ctx.slots.register(
|
|
1887
|
+
{
|
|
1888
|
+
name: 'conversation.view',
|
|
1889
|
+
id: GRAPH_VIEW_ID,
|
|
1890
|
+
order: 30,
|
|
1891
|
+
label: () => t('记忆', 'Memory'),
|
|
1892
|
+
},
|
|
1893
|
+
SessionMemoryView,
|
|
1894
|
+
),
|
|
1895
|
+
)
|
|
1896
|
+
|
|
1897
|
+
// An entry of its own in the sidebar rail, and the page behind it. The tab above
|
|
1898
|
+
// answers "what did THIS conversation remember"; this answers "everything I have
|
|
1899
|
+
// ever remembered", which is a different question and was until now only reachable
|
|
1900
|
+
// by opening graph.html from the vault folder.
|
|
1901
|
+
const VaultPanel = createVaultPanel(React)
|
|
1902
|
+
const VaultIcon = createGraphIcon(React)
|
|
1903
|
+
ctx.slots.inject('sidebar.panellist', () =>
|
|
1904
|
+
ctx.slots.register(
|
|
1905
|
+
{
|
|
1906
|
+
name: 'sidebar.panellist',
|
|
1907
|
+
id: PANEL_ID,
|
|
1908
|
+
// 20 is 上下文洞察: this sits right after it.
|
|
1909
|
+
order: 30,
|
|
1910
|
+
label: () => t('记忆图谱', 'Memory Graph'),
|
|
1911
|
+
},
|
|
1912
|
+
VaultIcon,
|
|
1913
|
+
),
|
|
1914
|
+
)
|
|
1915
|
+
ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PANEL_ID }, VaultPanel))
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
if (typeof window === 'undefined' || !window.__ModuleLoader__ || typeof window.__ModuleLoader__.load !== 'function') {
|
|
1919
|
+
// Plain Node import (tests, schema tooling): publish what the suite needs and
|
|
1920
|
+
// touch nothing else. Headless by design — no document, no styles.
|
|
1921
|
+
globalThis.__memoryGraphFormSpec = FORM_SPEC
|
|
1922
|
+
globalThis.__memoryGraphClient = { FORM_SPEC, createPage, createGraphView, createVaultPanel, registerSlots, ensureStyle, PACKAGE_NAME, HOST_ENTRY_ID, STYLE_ID, GRAPH_API, DISTILL_API, PAGE_API, GRAPH_VIEW_ID, PANEL_ID }
|
|
1923
|
+
} else {
|
|
1924
|
+
window.__ModuleLoader__.load({
|
|
1925
|
+
id: PACKAGE_NAME,
|
|
1926
|
+
factory: (require) => {
|
|
1927
|
+
const module = { exports: {} }
|
|
1928
|
+
const exports = module.exports
|
|
1929
|
+
const React = require('react')
|
|
1930
|
+
const h = React.createElement
|
|
1931
|
+
|
|
1932
|
+
const name = PACKAGE_NAME
|
|
1933
|
+
const inject = ['slots']
|
|
1934
|
+
|
|
1935
|
+
function apply(ctx) {
|
|
1936
|
+
registerSlots(ctx, React)
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
exports.name = name
|
|
1940
|
+
exports.inject = inject
|
|
1941
|
+
exports.apply = apply
|
|
1942
|
+
// Exposed for the host-side schema test and for debugging in devtools.
|
|
1943
|
+
exports.formSpec = FORM_SPEC
|
|
1944
|
+
exports.createGraphView = createGraphView
|
|
1945
|
+
exports.GRAPH_API = GRAPH_API
|
|
1946
|
+
exports.DISTILL_API = DISTILL_API
|
|
1947
|
+
exports.GRAPH_VIEW_ID = GRAPH_VIEW_ID
|
|
1948
|
+
exports.PAGE_API = PAGE_API
|
|
1949
|
+
exports.PANEL_ID = PANEL_ID
|
|
1950
|
+
return module.exports
|
|
1951
|
+
},
|
|
1952
|
+
})
|
|
1953
|
+
}
|