@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +259 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +466 -0
  4. package/README.md +459 -0
  5. package/client.js +1953 -0
  6. package/cordis.patch.yml +13 -0
  7. package/docs/auto-settings.png +0 -0
  8. package/docs/distill-button.png +0 -0
  9. package/docs/graph-dark.png +0 -0
  10. package/docs/graph-light.png +0 -0
  11. package/docs/session-tab-cards.png +0 -0
  12. package/docs/session-tab-open.png +0 -0
  13. package/docs/session-tab.png +0 -0
  14. package/docs/vault-panel.png +0 -0
  15. package/eval/REPORT.md +437 -0
  16. package/eval/questions.json +277 -0
  17. package/eval/run.mjs +460 -0
  18. package/eval/seed.mjs +489 -0
  19. package/icon.svg +26 -0
  20. package/index.js +243 -0
  21. package/locale/en.json +6 -0
  22. package/locale/zh.json +6 -0
  23. package/package.json +77 -0
  24. package/src/auto.js +194 -0
  25. package/src/capture.js +237 -0
  26. package/src/config.js +280 -0
  27. package/src/distillapi.js +30 -0
  28. package/src/distillrun.js +106 -0
  29. package/src/extract.js +660 -0
  30. package/src/graph.js +1582 -0
  31. package/src/graphdata.js +475 -0
  32. package/src/http.js +32 -0
  33. package/src/pageapi.js +40 -0
  34. package/src/privacy.js +78 -0
  35. package/src/schema.js +323 -0
  36. package/src/search.js +317 -0
  37. package/src/tools.js +534 -0
  38. package/src/vault.js +888 -0
  39. package/test/auto.mjs +295 -0
  40. package/test/capture.mjs +264 -0
  41. package/test/client.mjs +1035 -0
  42. package/test/distillapi.mjs +212 -0
  43. package/test/extract.mjs +660 -0
  44. package/test/graphdata.mjs +295 -0
  45. package/test/live-supersede.mjs +78 -0
  46. package/test/live.mjs +156 -0
  47. package/test/page.mjs +116 -0
  48. package/test/schema.mjs +256 -0
  49. package/test/smoke.mjs +428 -0
  50. 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
+ }