@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/test/client.mjs
ADDED
|
@@ -0,0 +1,1035 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-page checks.
|
|
3
|
+
*
|
|
4
|
+
* `client.js` is the Settings page: it registers a `settings.section`, reads the
|
|
5
|
+
* served form through `configForms`, and turns edits into path operations. None
|
|
6
|
+
* of that is reachable from the harness test runner — the browser bundle needs a
|
|
7
|
+
* DOM and React — so this file mounts the component under a small React
|
|
8
|
+
* stand-in and drives it the way a click would.
|
|
9
|
+
*
|
|
10
|
+
* What it is worth: the write contract. A page that renders but wires the wrong
|
|
11
|
+
* path, drops a knob, or sends a value the host rejects looks perfectly fine on
|
|
12
|
+
* screen. These checks assert the exact operations that leave the page.
|
|
13
|
+
*/
|
|
14
|
+
import assert from 'node:assert/strict'
|
|
15
|
+
|
|
16
|
+
await import('../client.js')
|
|
17
|
+
|
|
18
|
+
const client = globalThis.__memoryGraphClient
|
|
19
|
+
const spec = globalThis.__memoryGraphFormSpec
|
|
20
|
+
|
|
21
|
+
let passed = 0
|
|
22
|
+
const check = async (name, fn) => {
|
|
23
|
+
try {
|
|
24
|
+
await fn()
|
|
25
|
+
passed += 1
|
|
26
|
+
console.log(` ok ${name}`)
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.log(` FAIL ${name}: ${error.message}`)
|
|
29
|
+
process.exitCode = 1
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// A React just big enough for one component with useState/useRef/useEffect
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const createReact = () => {
|
|
38
|
+
let current = null
|
|
39
|
+
const react = {
|
|
40
|
+
createElement(type, props, ...children) {
|
|
41
|
+
const flat = []
|
|
42
|
+
const push = (child) => {
|
|
43
|
+
if (Array.isArray(child)) {
|
|
44
|
+
for (const item of child) push(item)
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
if (child === null || child === undefined || typeof child === 'boolean') return
|
|
48
|
+
flat.push(child)
|
|
49
|
+
}
|
|
50
|
+
for (const child of children) push(child)
|
|
51
|
+
return { type, props: props || {}, children: flat }
|
|
52
|
+
},
|
|
53
|
+
useState(initial) {
|
|
54
|
+
return current.useState(initial)
|
|
55
|
+
},
|
|
56
|
+
useRef(initial) {
|
|
57
|
+
return current.useRef(initial)
|
|
58
|
+
},
|
|
59
|
+
useEffect(fn, deps) {
|
|
60
|
+
return current.useEffect(fn, deps)
|
|
61
|
+
},
|
|
62
|
+
useMemo(fn, deps) {
|
|
63
|
+
return current.useMemo(fn, deps)
|
|
64
|
+
},
|
|
65
|
+
useCallback(fn, deps) {
|
|
66
|
+
return current.useCallback(fn, deps)
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
return { react, bind: (instance) => { current = instance } }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const mount = (Component, runtime = createReact()) => {
|
|
73
|
+
const instance = {
|
|
74
|
+
runtime,
|
|
75
|
+
hooks: [],
|
|
76
|
+
cursor: 0,
|
|
77
|
+
tree: null,
|
|
78
|
+
dirty: false,
|
|
79
|
+
flushing: false,
|
|
80
|
+
pending: [],
|
|
81
|
+
useState(initial) {
|
|
82
|
+
const index = this.cursor++
|
|
83
|
+
if (!(index in this.hooks)) {
|
|
84
|
+
this.hooks[index] = { kind: 'state', value: typeof initial === 'function' ? initial() : initial }
|
|
85
|
+
}
|
|
86
|
+
const hook = this.hooks[index]
|
|
87
|
+
const set = (next) => {
|
|
88
|
+
const value = typeof next === 'function' ? next(hook.value) : next
|
|
89
|
+
if (Object.is(value, hook.value)) return
|
|
90
|
+
hook.value = value
|
|
91
|
+
this.dirty = true
|
|
92
|
+
if (!this.flushing) this.flush()
|
|
93
|
+
}
|
|
94
|
+
return [hook.value, set]
|
|
95
|
+
},
|
|
96
|
+
useRef(initial) {
|
|
97
|
+
const index = this.cursor++
|
|
98
|
+
if (!(index in this.hooks)) this.hooks[index] = { kind: 'ref', current: initial }
|
|
99
|
+
return this.hooks[index]
|
|
100
|
+
},
|
|
101
|
+
useEffect(fn, deps) {
|
|
102
|
+
const index = this.cursor++
|
|
103
|
+
const previous = this.hooks[index]
|
|
104
|
+
const changed =
|
|
105
|
+
!previous ||
|
|
106
|
+
!deps ||
|
|
107
|
+
!previous.deps ||
|
|
108
|
+
deps.length !== previous.deps.length ||
|
|
109
|
+
deps.some((dep, at) => !Object.is(dep, previous.deps[at]))
|
|
110
|
+
if (changed) {
|
|
111
|
+
const hook = { kind: 'effect', deps, fn, cleanup: previous ? previous.cleanup : undefined }
|
|
112
|
+
this.hooks[index] = hook
|
|
113
|
+
this.pending.push(hook)
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
useMemo(fn, deps) {
|
|
117
|
+
const index = this.cursor++
|
|
118
|
+
const previous = this.hooks[index]
|
|
119
|
+
const changed =
|
|
120
|
+
!previous ||
|
|
121
|
+
!deps ||
|
|
122
|
+
!previous.deps ||
|
|
123
|
+
deps.length !== previous.deps.length ||
|
|
124
|
+
deps.some((dep, at) => !Object.is(dep, previous.deps[at]))
|
|
125
|
+
if (changed) this.hooks[index] = { kind: 'memo', deps, value: fn() }
|
|
126
|
+
return this.hooks[index].value
|
|
127
|
+
},
|
|
128
|
+
useCallback(fn, deps) {
|
|
129
|
+
const index = this.cursor++
|
|
130
|
+
const previous = this.hooks[index]
|
|
131
|
+
const changed =
|
|
132
|
+
!previous ||
|
|
133
|
+
!deps ||
|
|
134
|
+
!previous.deps ||
|
|
135
|
+
deps.length !== previous.deps.length ||
|
|
136
|
+
deps.some((dep, at) => !Object.is(dep, previous.deps[at]))
|
|
137
|
+
if (changed) this.hooks[index] = { kind: 'memo', deps, value: fn }
|
|
138
|
+
return this.hooks[index].value
|
|
139
|
+
},
|
|
140
|
+
render() {
|
|
141
|
+
runtime.bind(this)
|
|
142
|
+
this.cursor = 0
|
|
143
|
+
this.pending = []
|
|
144
|
+
this.tree = Component()
|
|
145
|
+
const effects = this.pending
|
|
146
|
+
this.pending = []
|
|
147
|
+
for (const effect of effects) {
|
|
148
|
+
if (typeof effect.cleanup === 'function') effect.cleanup()
|
|
149
|
+
const cleanup = effect.fn()
|
|
150
|
+
effect.cleanup = typeof cleanup === 'function' ? cleanup : undefined
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
flush() {
|
|
154
|
+
if (this.flushing) return
|
|
155
|
+
this.flushing = true
|
|
156
|
+
let guard = 0
|
|
157
|
+
try {
|
|
158
|
+
do {
|
|
159
|
+
this.dirty = false
|
|
160
|
+
this.render()
|
|
161
|
+
} while (this.dirty && ++guard < 40)
|
|
162
|
+
} finally {
|
|
163
|
+
this.flushing = false
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
act(fn) {
|
|
167
|
+
fn()
|
|
168
|
+
this.flush()
|
|
169
|
+
},
|
|
170
|
+
/** Let a queued write settle, then redraw with its answer. */
|
|
171
|
+
async settle() {
|
|
172
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
173
|
+
this.flush()
|
|
174
|
+
},
|
|
175
|
+
unmount() {
|
|
176
|
+
for (const hook of this.hooks) {
|
|
177
|
+
if (hook && hook.kind === 'effect' && typeof hook.cleanup === 'function') hook.cleanup()
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
instance.flush()
|
|
182
|
+
return instance
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// A stand-in for the settings service
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
const makeForm = (value, options = {}) => {
|
|
190
|
+
const listeners = new Set()
|
|
191
|
+
const calls = []
|
|
192
|
+
return {
|
|
193
|
+
snapshot: {
|
|
194
|
+
status: options.status || 'ready',
|
|
195
|
+
value,
|
|
196
|
+
base: options.base || value,
|
|
197
|
+
user: options.user || {},
|
|
198
|
+
revision: 1,
|
|
199
|
+
writable: options.writable !== false,
|
|
200
|
+
},
|
|
201
|
+
calls,
|
|
202
|
+
getSnapshot() {
|
|
203
|
+
return this.snapshot
|
|
204
|
+
},
|
|
205
|
+
subscribe(listener) {
|
|
206
|
+
listeners.add(listener)
|
|
207
|
+
return () => listeners.delete(listener)
|
|
208
|
+
},
|
|
209
|
+
mutate(ops, revision) {
|
|
210
|
+
calls.push({ ops, revision })
|
|
211
|
+
return Promise.resolve(options.accept !== false)
|
|
212
|
+
},
|
|
213
|
+
patch(next) {
|
|
214
|
+
this.snapshot = { ...this.snapshot, ...next }
|
|
215
|
+
for (const listener of listeners) listener()
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const makeCtx = (form) => ({
|
|
221
|
+
get: (name) =>
|
|
222
|
+
name === 'configForms'
|
|
223
|
+
? {
|
|
224
|
+
get: () => form,
|
|
225
|
+
describe: () => ({
|
|
226
|
+
getSnapshot: () => ({ status: 'ready', view: { namespaces: [{ ns: 'memory-graph' }] } }),
|
|
227
|
+
}),
|
|
228
|
+
}
|
|
229
|
+
: undefined,
|
|
230
|
+
slots: { inject() {}, register() {} },
|
|
231
|
+
fiber: { entry: { id: 'client:memory-graph' } },
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
/** Mount the page over one form controller. */
|
|
235
|
+
const render = (form) => {
|
|
236
|
+
const runtime = createReact()
|
|
237
|
+
const Page = client.createPage(makeCtx(form), runtime.react)
|
|
238
|
+
return mount(Page, runtime)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Tree helpers
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
const walk = (node, visit) => {
|
|
246
|
+
if (!node || typeof node !== 'object') return
|
|
247
|
+
visit(node)
|
|
248
|
+
for (const child of node.children || []) walk(child, visit)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const nodes = (tree) => {
|
|
252
|
+
const out = []
|
|
253
|
+
walk(tree, (node) => out.push(node))
|
|
254
|
+
return out
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const control = (tree, key) => nodes(tree).find((node) => node.props && node.props['data-field'] === key)
|
|
258
|
+
const row = (tree, key) => nodes(tree).find((node) => node.props && node.props['data-path'] === key)
|
|
259
|
+
const byClass = (tree, name) =>
|
|
260
|
+
nodes(tree).filter(
|
|
261
|
+
(node) => typeof (node.props && node.props.className) === 'string' && node.props.className.split(' ').includes(name),
|
|
262
|
+
)
|
|
263
|
+
const text = (node) => {
|
|
264
|
+
if (typeof node === 'string') return node
|
|
265
|
+
if (!node || typeof node !== 'object') return ''
|
|
266
|
+
return (node.children || []).map(text).join('')
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const saveButton = (tree) => byClass(tree, 'mg-btn')[0]
|
|
270
|
+
const barText = (tree) => text(byClass(tree, 'mg-msg')[0])
|
|
271
|
+
|
|
272
|
+
/** What a fresh install serves. */
|
|
273
|
+
const SERVED = {
|
|
274
|
+
enabled: true,
|
|
275
|
+
vaultDir: '',
|
|
276
|
+
defaultProject: 'shared',
|
|
277
|
+
crossProject: true,
|
|
278
|
+
capture: {
|
|
279
|
+
enabled: true,
|
|
280
|
+
userChars: 1500,
|
|
281
|
+
assistantChars: 1500,
|
|
282
|
+
includeToolOutput: false,
|
|
283
|
+
minChars: 12,
|
|
284
|
+
skipPatterns: ['^Compacted', 'runtime context'],
|
|
285
|
+
},
|
|
286
|
+
privacy: { useDefaultPatterns: true, denyPatterns: [], redactEmails: true, redactPaths: true },
|
|
287
|
+
extractor: {
|
|
288
|
+
enabled: false,
|
|
289
|
+
transport: 'harness',
|
|
290
|
+
useCurrentModel: true,
|
|
291
|
+
provider: '',
|
|
292
|
+
reasoningEffort: '',
|
|
293
|
+
baseUrl: '',
|
|
294
|
+
path: '',
|
|
295
|
+
model: '',
|
|
296
|
+
apiKeyEnv: '',
|
|
297
|
+
apiKey: '',
|
|
298
|
+
maxOutputTokens: 8000,
|
|
299
|
+
temperature: 0.2,
|
|
300
|
+
timeoutMs: 120000,
|
|
301
|
+
maxEpisodesPerRun: 40,
|
|
302
|
+
},
|
|
303
|
+
auto: {
|
|
304
|
+
enabled: false,
|
|
305
|
+
minEpisodes: 0,
|
|
306
|
+
everyMinutes: 0,
|
|
307
|
+
minGapMinutes: 30,
|
|
308
|
+
quietHours: '',
|
|
309
|
+
project: '',
|
|
310
|
+
},
|
|
311
|
+
retrieval: { limit: 8, hops: 1, snippetChars: 320, timelineLimit: 20 },
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const clone = (value) => JSON.parse(JSON.stringify(value))
|
|
315
|
+
const visibleFields = (value) => spec.sections.flatMap((section) => section.fields.filter((field) => !field.visible || field.visible(value)))
|
|
316
|
+
|
|
317
|
+
console.log('client')
|
|
318
|
+
|
|
319
|
+
await check('the page renders every knob the served value makes visible', () => {
|
|
320
|
+
const instance = render(makeForm(clone(SERVED)))
|
|
321
|
+
try {
|
|
322
|
+
const expected = visibleFields(SERVED)
|
|
323
|
+
// The harness route hides the seven http-only knobs and the provider field.
|
|
324
|
+
assert.equal(expected.length, 27, `expected 27 visible fields, spec says ${expected.length}`)
|
|
325
|
+
for (const field of expected) {
|
|
326
|
+
const key = field.path.join('.')
|
|
327
|
+
assert.ok(control(instance.tree, key), `${key} has no control on the page`)
|
|
328
|
+
assert.ok(row(instance.tree, key), `${key} has no row on the page`)
|
|
329
|
+
}
|
|
330
|
+
assert.equal(byClass(instance.tree, 'mg-card').length, spec.sections.length)
|
|
331
|
+
// Nothing about the page is a structural knob.
|
|
332
|
+
for (const key of spec.structural) assert.equal(row(instance.tree, key), undefined, `${key} must not be on the page`)
|
|
333
|
+
} finally {
|
|
334
|
+
instance.unmount()
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
await check('the five sections are all there, each with a title', () => {
|
|
339
|
+
const instance = render(makeForm(clone(SERVED)))
|
|
340
|
+
try {
|
|
341
|
+
const titles = byClass(instance.tree, 'mg-card-t').map(text)
|
|
342
|
+
assert.equal(titles.length, 5)
|
|
343
|
+
for (const title of titles) assert.ok(title.length > 0)
|
|
344
|
+
} finally {
|
|
345
|
+
instance.unmount()
|
|
346
|
+
}
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
await check('the automatic distiller has both triggers on the page, each with its own switch', async () => {
|
|
350
|
+
const form = makeForm(clone(SERVED))
|
|
351
|
+
const instance = render(form)
|
|
352
|
+
try {
|
|
353
|
+
const section = spec.sections.find((s) => s.id === 'auto')
|
|
354
|
+
assert.ok(section, 'automatic distillation needs a section of its own, not a corner of another one')
|
|
355
|
+
assert.deepEqual(
|
|
356
|
+
section.fields.map((field) => field.path.join('.')),
|
|
357
|
+
['auto.enabled', 'auto.minEpisodes', 'auto.everyMinutes', 'auto.minGapMinutes', 'auto.quietHours', 'auto.project'],
|
|
358
|
+
)
|
|
359
|
+
// Both rules show as 0 — "this rule is off" — rather than as a blank.
|
|
360
|
+
assert.equal(control(instance.tree, 'auto.minEpisodes').props.value, '0')
|
|
361
|
+
assert.equal(control(instance.tree, 'auto.everyMinutes').props.value, '0')
|
|
362
|
+
assert.equal(control(instance.tree, 'auto.enabled').props['data-on'], 'false')
|
|
363
|
+
// A rule set to 0 must reach the host as 0, not as "no value": 0 is how a
|
|
364
|
+
// rule is turned off, and an absent key would fall back to the default.
|
|
365
|
+
instance.act(() => control(instance.tree, 'auto.enabled').props.onClick())
|
|
366
|
+
instance.act(() => control(instance.tree, 'auto.minEpisodes').props.onChange({ target: { value: '12' } }))
|
|
367
|
+
instance.act(() => control(instance.tree, 'auto.quietHours').props.onChange({ target: { value: '23:00-08:00' } }))
|
|
368
|
+
instance.act(() => saveButton(instance.tree).props.onClick())
|
|
369
|
+
await instance.settle()
|
|
370
|
+
const byPath = new Map(form.calls[0].ops.map((op) => [op.path.join('.'), op]))
|
|
371
|
+
assert.equal(byPath.get('auto.enabled').value, true, 'turning it on is a write, not a default')
|
|
372
|
+
assert.equal(byPath.get('auto.minEpisodes').value, 12, 'the threshold is a number, not the string "12"')
|
|
373
|
+
assert.equal(byPath.get('auto.quietHours').value, '23:00-08:00')
|
|
374
|
+
} finally {
|
|
375
|
+
instance.unmount()
|
|
376
|
+
}
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
await check('a switch renders the served value, not the default', () => {
|
|
380
|
+
const value = clone(SERVED)
|
|
381
|
+
value.capture.enabled = false
|
|
382
|
+
const instance = render(makeForm(value))
|
|
383
|
+
try {
|
|
384
|
+
assert.equal(control(instance.tree, 'capture.enabled').props['data-on'], 'false')
|
|
385
|
+
assert.equal(control(instance.tree, 'privacy.redactEmails').props['data-on'], 'true')
|
|
386
|
+
} finally {
|
|
387
|
+
instance.unmount()
|
|
388
|
+
}
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
await check('the http-only knobs appear only on the http route', () => {
|
|
392
|
+
const value = clone(SERVED)
|
|
393
|
+
const harness = render(makeForm(value))
|
|
394
|
+
try {
|
|
395
|
+
assert.equal(control(harness.tree, 'extractor.baseUrl'), undefined)
|
|
396
|
+
assert.equal(control(harness.tree, 'extractor.apiKey'), undefined)
|
|
397
|
+
assert.equal(control(harness.tree, 'extractor.provider'), undefined)
|
|
398
|
+
assert.equal(control(harness.tree, 'extractor.reasoningEffort').props.value, '')
|
|
399
|
+
} finally {
|
|
400
|
+
harness.unmount()
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const http = render(makeForm(value))
|
|
404
|
+
try {
|
|
405
|
+
const select = control(http.tree, 'extractor.transport')
|
|
406
|
+
assert.equal(select.props.value, 'harness')
|
|
407
|
+
http.act(() => select.props.onChange({ target: { value: 'http' } }))
|
|
408
|
+
for (const key of ['extractor.baseUrl', 'extractor.apiKey', 'extractor.model', 'extractor.temperature', 'extractor.apiKeyEnv']) {
|
|
409
|
+
assert.ok(control(http.tree, key), `${key} should be visible on the http route`)
|
|
410
|
+
}
|
|
411
|
+
// The inline key is a password field: a key must never sit in plain text.
|
|
412
|
+
assert.equal(control(http.tree, 'extractor.apiKey').props.type, 'password')
|
|
413
|
+
// Following the current model is a harness idea; provider stays hidden.
|
|
414
|
+
assert.equal(control(http.tree, 'extractor.useCurrentModel'), undefined)
|
|
415
|
+
assert.equal(control(http.tree, 'extractor.provider'), undefined)
|
|
416
|
+
} finally {
|
|
417
|
+
http.unmount()
|
|
418
|
+
}
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
await check('nothing is dirty on arrival, so save is disabled', () => {
|
|
422
|
+
const instance = render(makeForm(clone(SERVED)))
|
|
423
|
+
try {
|
|
424
|
+
assert.equal(saveButton(instance.tree).props.disabled, true)
|
|
425
|
+
assert.equal(barText(instance.tree), '')
|
|
426
|
+
} finally {
|
|
427
|
+
instance.unmount()
|
|
428
|
+
}
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
await check('a switch becomes one unsaved change and one path set', async () => {
|
|
432
|
+
const form = makeForm(clone(SERVED))
|
|
433
|
+
const instance = render(form)
|
|
434
|
+
try {
|
|
435
|
+
instance.act(() => control(instance.tree, 'capture.enabled').props.onClick())
|
|
436
|
+
assert.equal(control(instance.tree, 'capture.enabled').props['data-on'], 'false')
|
|
437
|
+
assert.equal(saveButton(instance.tree).props.disabled, false)
|
|
438
|
+
assert.match(barText(instance.tree), /\b1\b/)
|
|
439
|
+
|
|
440
|
+
instance.act(() => saveButton(instance.tree).props.onClick())
|
|
441
|
+
await instance.settle()
|
|
442
|
+
|
|
443
|
+
assert.equal(form.calls.length, 1)
|
|
444
|
+
assert.deepEqual(form.calls[0].ops, [{ op: 'set', path: ['capture', 'enabled'], value: false }])
|
|
445
|
+
// The revision is left to the controller: only it knows the latest one after
|
|
446
|
+
// a write that raced with another tab.
|
|
447
|
+
assert.equal(form.calls[0].revision, undefined)
|
|
448
|
+
assert.equal(byClass(instance.tree, 'mg-msg')[0].props['data-kind'], 'ok')
|
|
449
|
+
assert.ok(barText(instance.tree).length > 0, 'a save says so')
|
|
450
|
+
assert.equal(saveButton(instance.tree).props.disabled, true, 'the bar clears after a save')
|
|
451
|
+
} finally {
|
|
452
|
+
instance.unmount()
|
|
453
|
+
}
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
await check('a list knob is split on newlines and trimmed', async () => {
|
|
457
|
+
const form = makeForm(clone(SERVED))
|
|
458
|
+
const instance = render(form)
|
|
459
|
+
try {
|
|
460
|
+
const area = control(instance.tree, 'capture.skipPatterns')
|
|
461
|
+
assert.equal(area.props.value, '^Compacted\nruntime context')
|
|
462
|
+
instance.act(() => area.props.onChange({ target: { value: ' one \n\n two \n' } }))
|
|
463
|
+
instance.act(() => saveButton(instance.tree).props.onClick())
|
|
464
|
+
await instance.settle()
|
|
465
|
+
assert.deepEqual(form.calls[0].ops, [{ op: 'set', path: ['capture', 'skipPatterns'], value: ['one', 'two'] }])
|
|
466
|
+
} finally {
|
|
467
|
+
instance.unmount()
|
|
468
|
+
}
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
await check('a number the host would refuse never reaches the wire', async () => {
|
|
472
|
+
const form = makeForm(clone(SERVED))
|
|
473
|
+
const instance = render(form)
|
|
474
|
+
try {
|
|
475
|
+
instance.act(() => control(instance.tree, 'capture.userChars').props.onChange({ target: { value: '10' } }))
|
|
476
|
+
instance.act(() => saveButton(instance.tree).props.onClick())
|
|
477
|
+
await instance.settle()
|
|
478
|
+
assert.equal(form.calls.length, 0)
|
|
479
|
+
assert.equal(byClass(instance.tree, 'mg-msg')[0].props['data-kind'], 'error')
|
|
480
|
+
} finally {
|
|
481
|
+
instance.unmount()
|
|
482
|
+
}
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
await check('a number typed as a string is sent as a number', async () => {
|
|
486
|
+
const form = makeForm(clone(SERVED))
|
|
487
|
+
const instance = render(form)
|
|
488
|
+
try {
|
|
489
|
+
instance.act(() => control(instance.tree, 'retrieval.limit').props.onChange({ target: { value: '12' } }))
|
|
490
|
+
instance.act(() => saveButton(instance.tree).props.onClick())
|
|
491
|
+
await instance.settle()
|
|
492
|
+
assert.deepEqual(form.calls[0].ops, [{ op: 'set', path: ['retrieval', 'limit'], value: 12 }])
|
|
493
|
+
assert.equal(typeof form.calls[0].ops[0].value, 'number')
|
|
494
|
+
} finally {
|
|
495
|
+
instance.unmount()
|
|
496
|
+
}
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
await check('an override is badged and can be restored with an unset', async () => {
|
|
500
|
+
// The inherited layer says "off"; the profile pins "on": that is an override.
|
|
501
|
+
const base = clone(SERVED)
|
|
502
|
+
base.capture.enabled = false
|
|
503
|
+
const form = makeForm(clone(SERVED), { base, user: { capture: { enabled: true } } })
|
|
504
|
+
const instance = render(form)
|
|
505
|
+
try {
|
|
506
|
+
const badges = () => byClass(row(instance.tree, 'capture.enabled'), 'mg-badge').map(text)
|
|
507
|
+
assert.equal(badges().length, 1, 'an overridden value is badged')
|
|
508
|
+
assert.equal(byClass(row(instance.tree, 'capture.enabled'), 'mg-link').length, 0)
|
|
509
|
+
|
|
510
|
+
instance.act(() => control(instance.tree, 'capture.enabled').props.onClick())
|
|
511
|
+
assert.equal(badges().length, 2, 'custom + unsaved')
|
|
512
|
+
const link = byClass(row(instance.tree, 'capture.enabled'), 'mg-link')[0]
|
|
513
|
+
assert.ok(link, 'a dirty override offers a restore')
|
|
514
|
+
|
|
515
|
+
instance.act(() => link.props.onClick())
|
|
516
|
+
await instance.settle()
|
|
517
|
+
assert.deepEqual(form.calls[0].ops, [{ op: 'unset', path: ['capture', 'enabled'] }])
|
|
518
|
+
} finally {
|
|
519
|
+
instance.unmount()
|
|
520
|
+
}
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
await check('a pinned value that repeats the inherited default is not a customisation', async () => {
|
|
524
|
+
// One save pins the whole served row, so `user` holds keys nobody changed.
|
|
525
|
+
const value = clone(SERVED)
|
|
526
|
+
value.extractor.transport = 'http'
|
|
527
|
+
const form = makeForm(value, {
|
|
528
|
+
user: { capture: { enabled: value.capture.enabled }, extractor: { transport: 'http', apiKey: '<set>' } },
|
|
529
|
+
})
|
|
530
|
+
const instance = render(form)
|
|
531
|
+
try {
|
|
532
|
+
const badges = (field) => byClass(row(instance.tree, field), 'mg-badge').map(text)
|
|
533
|
+
assert.equal(badges('capture.enabled').length, 0, 'same as the inherited value')
|
|
534
|
+
assert.equal(badges('extractor.transport').length, 0, 'same as the inherited value')
|
|
535
|
+
assert.equal(badges('extractor.apiKey').length, 1, 'a secret rides no value, so presence is the signal')
|
|
536
|
+
} finally {
|
|
537
|
+
instance.unmount()
|
|
538
|
+
}
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
await check('an empty inline key is skipped instead of cleared', async () => {
|
|
542
|
+
const value = clone(SERVED)
|
|
543
|
+
value.extractor.transport = 'http'
|
|
544
|
+
value.extractor.apiKey = ''
|
|
545
|
+
const form = makeForm(value)
|
|
546
|
+
const instance = render(form)
|
|
547
|
+
try {
|
|
548
|
+
// Touching it and leaving it blank must not write anything: a secret never
|
|
549
|
+
// rides back from the host, so "" is "unchanged", not "delete".
|
|
550
|
+
instance.act(() => control(instance.tree, 'extractor.apiKey').props.onChange({ target: { value: '' } }))
|
|
551
|
+
instance.act(() => control(instance.tree, 'extractor.maxOutputTokens').props.onChange({ target: { value: '4000' } }))
|
|
552
|
+
instance.act(() => saveButton(instance.tree).props.onClick())
|
|
553
|
+
await instance.settle()
|
|
554
|
+
assert.deepEqual(form.calls[0].ops, [{ op: 'set', path: ['extractor', 'maxOutputTokens'], value: 4000 }])
|
|
555
|
+
} finally {
|
|
556
|
+
instance.unmount()
|
|
557
|
+
}
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
await check('a refused write is reported and the page re-reads', async () => {
|
|
561
|
+
const form = makeForm(clone(SERVED), { accept: false })
|
|
562
|
+
const instance = render(form)
|
|
563
|
+
try {
|
|
564
|
+
instance.act(() => control(instance.tree, 'privacy.redactPaths').props.onClick())
|
|
565
|
+
instance.act(() => saveButton(instance.tree).props.onClick())
|
|
566
|
+
await instance.settle()
|
|
567
|
+
assert.equal(form.calls.length, 1)
|
|
568
|
+
assert.equal(byClass(instance.tree, 'mg-msg')[0].props['data-kind'], 'error')
|
|
569
|
+
// The draft is dropped so the next click cannot send a stale value.
|
|
570
|
+
assert.equal(saveButton(instance.tree).props.disabled, true)
|
|
571
|
+
} finally {
|
|
572
|
+
instance.unmount()
|
|
573
|
+
}
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
await check('a redraw from the host does not resurrect a stale draft', () => {
|
|
577
|
+
const value = clone(SERVED)
|
|
578
|
+
const form = makeForm(value)
|
|
579
|
+
const instance = render(form)
|
|
580
|
+
try {
|
|
581
|
+
instance.act(() => control(instance.tree, 'capture.minChars').props.onChange({ target: { value: '40' } }))
|
|
582
|
+
const next = clone(value)
|
|
583
|
+
next.retrieval.limit = 5
|
|
584
|
+
form.patch({ value: next })
|
|
585
|
+
instance.flush()
|
|
586
|
+
assert.equal(control(instance.tree, 'retrieval.limit').props.value, '5', 'a host value shows through')
|
|
587
|
+
assert.equal(control(instance.tree, 'capture.minChars').props.value, '40', 'the unsaved edit survives')
|
|
588
|
+
} finally {
|
|
589
|
+
instance.unmount()
|
|
590
|
+
}
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
await check('an unserved namespace explains itself instead of showing an empty form', () => {
|
|
594
|
+
const instance = render(makeForm(undefined, { status: 'unavailable' }))
|
|
595
|
+
try {
|
|
596
|
+
assert.equal(byClass(instance.tree, 'mg-card').length, 0)
|
|
597
|
+
const title = text(byClass(instance.tree, 'mg-state-t')[0])
|
|
598
|
+
assert.ok(title.length > 0)
|
|
599
|
+
const body = text(instance.tree)
|
|
600
|
+
assert.ok(body.includes('memory-graph'), 'the panel names the entry id it looked for')
|
|
601
|
+
} finally {
|
|
602
|
+
instance.unmount()
|
|
603
|
+
}
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
await check('a read-only host disables the save button', () => {
|
|
607
|
+
const instance = render(makeForm(clone(SERVED), { writable: false }))
|
|
608
|
+
try {
|
|
609
|
+
instance.act(() => control(instance.tree, 'capture.enabled').props.onClick())
|
|
610
|
+
assert.equal(saveButton(instance.tree).props.disabled, true)
|
|
611
|
+
assert.equal(saveButton(instance.tree).props.onClick !== undefined, true)
|
|
612
|
+
} finally {
|
|
613
|
+
instance.unmount()
|
|
614
|
+
}
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
await check('the stylesheet is one labelled <style> element', () => {
|
|
618
|
+
const created = []
|
|
619
|
+
const previous = globalThis.document
|
|
620
|
+
globalThis.document = {
|
|
621
|
+
head: { appendChild: (node) => created.push(node) },
|
|
622
|
+
getElementById: (id) => created.find((node) => node.id === id) || null,
|
|
623
|
+
createElement: () => ({ dataset: {}, textContent: '' }),
|
|
624
|
+
}
|
|
625
|
+
try {
|
|
626
|
+
client.ensureStyle()
|
|
627
|
+
client.ensureStyle()
|
|
628
|
+
assert.equal(created.length, 1, 'a second mount reuses the element instead of adding another')
|
|
629
|
+
const style = created[0]
|
|
630
|
+
assert.equal(style.id, client.STYLE_ID)
|
|
631
|
+
// Untagged styles are collected by the host into whichever package
|
|
632
|
+
// materialises next and deleted on reload, which would strip this page.
|
|
633
|
+
assert.equal(style.dataset.plugin, client.PACKAGE_NAME)
|
|
634
|
+
assert.ok(style.textContent.includes('.mg-row'))
|
|
635
|
+
} finally {
|
|
636
|
+
globalThis.document = previous
|
|
637
|
+
}
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
await check('the entry point is a plain bundle, not a module', async () => {
|
|
641
|
+
// The loader evaluates the bundle as a script: a stray `export` is a syntax
|
|
642
|
+
// error there, and `require` is the only dependency channel.
|
|
643
|
+
const { readFile } = await import('node:fs/promises')
|
|
644
|
+
const source = await readFile(new URL('../client.js', import.meta.url), 'utf8')
|
|
645
|
+
assert.ok(!/^\s*export\s/m.test(source), 'client.js must not use export statements')
|
|
646
|
+
assert.ok(!/^\s*import\s/m.test(source), 'client.js must not use import statements')
|
|
647
|
+
assert.ok(source.includes('window.__ModuleLoader__.load'), 'client.js must register through the module loader')
|
|
648
|
+
assert.ok(source.includes("require('react')"), 'react comes from the frozen require table')
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
// ---------------------------------------------------------------------------
|
|
652
|
+
// The real browser entry
|
|
653
|
+
// ---------------------------------------------------------------------------
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Evaluate client.js the way the loader does: as a plain script on a page that
|
|
657
|
+
* has `window.__ModuleLoader__`, then run the factory with a require stub that
|
|
658
|
+
* only knows `react` (the frozen require table). Returns the registration and
|
|
659
|
+
* a context whose slot registry records what the module contributes.
|
|
660
|
+
*/
|
|
661
|
+
const openBrowserBundle = async (language = 'zh-CN') => {
|
|
662
|
+
const { readFile } = await import('node:fs/promises')
|
|
663
|
+
const { runInNewContext } = await import('node:vm')
|
|
664
|
+
const source = await readFile(new URL('../client.js', import.meta.url), 'utf8')
|
|
665
|
+
const registered = []
|
|
666
|
+
const styles = []
|
|
667
|
+
const sandbox = {
|
|
668
|
+
console,
|
|
669
|
+
navigator: { language },
|
|
670
|
+
// The page polls for the settings service on a timer. These checks only
|
|
671
|
+
// need the synchronous first paint, so timers park instead of firing.
|
|
672
|
+
setTimeout: () => 0,
|
|
673
|
+
clearTimeout: () => {},
|
|
674
|
+
setInterval: () => 0,
|
|
675
|
+
clearInterval: () => {},
|
|
676
|
+
document: {
|
|
677
|
+
head: { appendChild: (node) => styles.push(node) },
|
|
678
|
+
getElementById: (id) => styles.find((node) => node.id === id) || null,
|
|
679
|
+
createElement: () => ({ dataset: {}, textContent: '' }),
|
|
680
|
+
},
|
|
681
|
+
window: { __ModuleLoader__: { load: (registration) => registered.push(registration) } },
|
|
682
|
+
}
|
|
683
|
+
runInNewContext(source, sandbox, { filename: 'client.js' })
|
|
684
|
+
assert.equal(registered.length, 1, 'the bundle registers exactly one module')
|
|
685
|
+
|
|
686
|
+
const runtime = createReact()
|
|
687
|
+
const required = []
|
|
688
|
+
const exports = registered[0].factory((id) => {
|
|
689
|
+
required.push(id)
|
|
690
|
+
if (id === 'react') return runtime.react
|
|
691
|
+
throw new Error(`the bundle must not require ${id}`)
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
const injected = []
|
|
695
|
+
const sections = []
|
|
696
|
+
const form = makeForm(clone(SERVED))
|
|
697
|
+
const ctx = {
|
|
698
|
+
...makeCtx(form),
|
|
699
|
+
slots: {
|
|
700
|
+
inject: (name, fn) => {
|
|
701
|
+
injected.push(name)
|
|
702
|
+
fn()
|
|
703
|
+
},
|
|
704
|
+
register: (spec, Component) => sections.push({ spec, Component }),
|
|
705
|
+
},
|
|
706
|
+
}
|
|
707
|
+
return { registration: registered[0], exports, required, injected, sections, styles, runtime, ctx, form, sandbox }
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
await check('the browser entry registers a module the loader can address', async () => {
|
|
711
|
+
const bundle = await openBrowserBundle()
|
|
712
|
+
// The registration id is how the host matches this module to the package row
|
|
713
|
+
// it composed from `dsh.client`, so it has to be the package name itself.
|
|
714
|
+
assert.equal(bundle.registration.id, client.PACKAGE_NAME)
|
|
715
|
+
assert.ok(typeof bundle.registration.factory === 'function')
|
|
716
|
+
assert.deepEqual([...bundle.required], ['react'], 'react is the only module it asks for')
|
|
717
|
+
assert.equal(bundle.exports.name, client.PACKAGE_NAME)
|
|
718
|
+
assert.deepEqual([...bundle.exports.inject], ['slots'], 'the settings slot is the only capability needed')
|
|
719
|
+
assert.equal(typeof bundle.exports.apply, 'function')
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
await check('applying it contributes the Memory Graph page to Settings', async () => {
|
|
723
|
+
const bundle = await openBrowserBundle()
|
|
724
|
+
bundle.exports.apply(bundle.ctx)
|
|
725
|
+
assert.deepEqual([...bundle.injected], ['settings.section', 'conversation.view', 'sidebar.panellist', 'main'], 'it waits for every slot it fills')
|
|
726
|
+
const sections = bundle.sections.filter((entry) => entry.spec.name === 'settings.section')
|
|
727
|
+
assert.equal(sections.length, 1, 'exactly one section is registered')
|
|
728
|
+
const [{ spec, Component }] = sections
|
|
729
|
+
assert.equal(spec.id, client.HOST_ENTRY_ID)
|
|
730
|
+
// 15 插件 / 20 预设 / 21 导入 / 40 桌宠 — the page belongs after presets.
|
|
731
|
+
assert.equal(spec.order, 25)
|
|
732
|
+
assert.equal(bundle.styles.length, 1, 'applying installs the stylesheet')
|
|
733
|
+
assert.equal(bundle.styles[0].dataset.plugin, client.PACKAGE_NAME)
|
|
734
|
+
// The thing it registered is the whole page, wired to the served form: this
|
|
735
|
+
// is the path the browser takes, where the mount poll would find no timer.
|
|
736
|
+
const view = mount(Component, bundle.runtime)
|
|
737
|
+
assert.equal(byClass(view.tree, 'mg-root').length, 1)
|
|
738
|
+
assert.ok(control(view.tree, 'capture.enabled'), 'the served switch is on screen')
|
|
739
|
+
assert.ok(byClass(view.tree, 'mg-state').length === 0, 'a served form never shows the loading state')
|
|
740
|
+
view.unmount()
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
await check('the action bar is a sibling of the scroll box, not a sticky child of it', async () => {
|
|
744
|
+
const bundle = await openBrowserBundle()
|
|
745
|
+
bundle.exports.apply(bundle.ctx)
|
|
746
|
+
const [{ Component }] = bundle.sections.filter((entry) => entry.spec.name === 'settings.section')
|
|
747
|
+
const view = mount(Component, bundle.runtime)
|
|
748
|
+
const page = byClass(view.tree, 'mg-page')
|
|
749
|
+
assert.equal(page.length, 1, 'the page is one flex column')
|
|
750
|
+
const scroll = byClass(view.tree, 'mg-root')[0]
|
|
751
|
+
const bar = byClass(view.tree, 'mg-bar')[0]
|
|
752
|
+
assert.ok(scroll && bar, 'both the scroll box and the bar are on screen')
|
|
753
|
+
// Sticky inside the scroller is what put the bar in the middle of the page: it
|
|
754
|
+
// pinned the margin box, so the bar floated over the rows it was meant to sit
|
|
755
|
+
// under. Two rows of a flex column cannot do that.
|
|
756
|
+
assert.equal(nodes(scroll).includes(bar), false, 'the bar lives outside the scroller')
|
|
757
|
+
assert.ok(nodes(page[0]).includes(scroll), 'the scroller is inside the page')
|
|
758
|
+
assert.ok(nodes(page[0]).includes(bar), 'so is the bar')
|
|
759
|
+
view.unmount()
|
|
760
|
+
})
|
|
761
|
+
await check('the sidebar gets an entry of its own, and the main area a page behind it', async () => {
|
|
762
|
+
const bundle = await openBrowserBundle()
|
|
763
|
+
bundle.exports.apply(bundle.ctx)
|
|
764
|
+
const rail = bundle.sections.filter((entry) => entry.spec.name === 'sidebar.panellist')
|
|
765
|
+
assert.equal(rail.length, 1, 'one entry in the rail')
|
|
766
|
+
assert.equal(rail[0].spec.id, client.PANEL_ID)
|
|
767
|
+
// 20 is 上下文洞察: the whole-vault graph follows it.
|
|
768
|
+
assert.equal(rail[0].spec.order, 30)
|
|
769
|
+
assert.ok(typeof rail[0].spec.label === 'function', 'the label follows the UI language')
|
|
770
|
+
assert.ok(rail[0].spec.label().length > 0)
|
|
771
|
+
const page = bundle.sections.filter((entry) => entry.spec.name === 'main')
|
|
772
|
+
assert.equal(page.length, 1, 'one main page')
|
|
773
|
+
// The shell joins the rail entry to the page by this string, so it must be the
|
|
774
|
+
// same one on both sides.
|
|
775
|
+
assert.equal(page[0].spec.key, client.PANEL_ID)
|
|
776
|
+
assert.equal(client.PAGE_API, '/api/memory-graph.page')
|
|
777
|
+
})
|
|
778
|
+
|
|
779
|
+
await check('the panel embeds the served page instead of drawing a second graph', async () => {
|
|
780
|
+
const bundle = await openBrowserBundle()
|
|
781
|
+
bundle.exports.apply(bundle.ctx)
|
|
782
|
+
const [{ Component }] = bundle.sections.filter((entry) => entry.spec.name === 'main')
|
|
783
|
+
const view = mount(Component, bundle.runtime)
|
|
784
|
+
const frames = byClass(view.tree, 'mgv-panel-frame')
|
|
785
|
+
assert.equal(frames.length, 1, 'one frame')
|
|
786
|
+
assert.equal(frames[0].type, 'iframe')
|
|
787
|
+
// The vault directory never reaches the browser: the panel reads the host route.
|
|
788
|
+
assert.equal(frames[0].props.src, client.PAGE_API)
|
|
789
|
+
assert.equal(frames[0].props.src.includes('dsh-memory'), false, frames[0].props.src)
|
|
790
|
+
assert.equal(byClass(view.tree, 'mgv-panel-bar').length, 1, 'the bar carries the controls')
|
|
791
|
+
view.unmount()
|
|
792
|
+
})
|
|
793
|
+
await check('the sidebar label speaks whatever language the page is in', async () => {
|
|
794
|
+
const navLabel = async (language) => {
|
|
795
|
+
const bundle = await openBrowserBundle(language)
|
|
796
|
+
bundle.exports.apply(bundle.ctx)
|
|
797
|
+
const { spec } = bundle.sections.find((entry) => entry.spec.name === 'settings.section')
|
|
798
|
+
return typeof spec.label === 'function' ? spec.label() : spec.label
|
|
799
|
+
}
|
|
800
|
+
assert.equal(await navLabel('zh-CN'), '记忆图谱')
|
|
801
|
+
assert.equal(await navLabel('en-US'), 'Memory Graph')
|
|
802
|
+
})
|
|
803
|
+
|
|
804
|
+
// ---------------------------------------------------------------------------
|
|
805
|
+
// The Conversation view: a tab of its own, drawn from one host route
|
|
806
|
+
// ---------------------------------------------------------------------------
|
|
807
|
+
|
|
808
|
+
const openMemoryTab = async (language = 'zh-CN') => {
|
|
809
|
+
const bundle = await openBrowserBundle(language)
|
|
810
|
+
bundle.exports.apply(bundle.ctx)
|
|
811
|
+
const entry = bundle.sections.find((section) => section.spec.name === 'conversation.view')
|
|
812
|
+
assert.ok(entry, 'a conversation.view entry is registered')
|
|
813
|
+
return { ...bundle, spec: entry.spec, View: entry.Component }
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const SESSION_GRAPH = {
|
|
817
|
+
ok: true,
|
|
818
|
+
session: 'sess-1',
|
|
819
|
+
sessionId: 'sess-1',
|
|
820
|
+
project: 'proj-a',
|
|
821
|
+
generatedAt: '2026-11-02T00:00:00.000Z',
|
|
822
|
+
empty: false,
|
|
823
|
+
counts: { episodes: 2, cards: 1, entities: 1, foreign: 0, links: 2 },
|
|
824
|
+
truncated: { episodes: false, cards: false },
|
|
825
|
+
recall: ['c1'],
|
|
826
|
+
nodes: [
|
|
827
|
+
{ key: 's:session', k: 'session', label: '', r: 13, x: 540, y: 320 },
|
|
828
|
+
{ key: 'e:2026-11-02.jsonl:0', k: 'episode', label: 'T1', r: 5, x: 400, y: 320, text: '记忆库要用纯文本存。' },
|
|
829
|
+
{
|
|
830
|
+
key: 'c:c1',
|
|
831
|
+
k: 'card',
|
|
832
|
+
label: '记忆库用纯文本存储',
|
|
833
|
+
title: '记忆库用纯文本存储',
|
|
834
|
+
kind: 'decision',
|
|
835
|
+
project: 'proj-a',
|
|
836
|
+
status: 'active',
|
|
837
|
+
created: '2026-11-02',
|
|
838
|
+
body: '每个记忆一个文件,可以 diff。',
|
|
839
|
+
entityNames: ['memory-vault'],
|
|
840
|
+
hot: true,
|
|
841
|
+
r: 9,
|
|
842
|
+
x: 700,
|
|
843
|
+
y: 250,
|
|
844
|
+
},
|
|
845
|
+
],
|
|
846
|
+
links: [
|
|
847
|
+
{ s: 's:session', t: 'e:2026-11-02.jsonl:0', k: 'said' },
|
|
848
|
+
{ s: 's:session', t: 'c:c1', k: 'recall' },
|
|
849
|
+
],
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** Run one view render against a stubbed host route. */
|
|
853
|
+
const renderMemoryTab = async ({
|
|
854
|
+
language = 'zh-CN',
|
|
855
|
+
payload = SESSION_GRAPH,
|
|
856
|
+
payloadAfter = null,
|
|
857
|
+
distillReply = null,
|
|
858
|
+
} = {}) => {
|
|
859
|
+
const tab = await openMemoryTab(language)
|
|
860
|
+
const calls = []
|
|
861
|
+
const writes = []
|
|
862
|
+
let gets = 0
|
|
863
|
+
// The bundle was evaluated inside the sandbox, so its globals ARE the
|
|
864
|
+
// sandbox: the route stub has to live there, not on this realm. Each call
|
|
865
|
+
// gets its own sandbox, so the stub can stay installed for a later click.
|
|
866
|
+
tab.sandbox.fetch = async (url, options) => {
|
|
867
|
+
const method = (options && options.method) || 'GET'
|
|
868
|
+
if (method !== 'GET') {
|
|
869
|
+
writes.push(String(url))
|
|
870
|
+
return { ok: true, status: 200, json: async () => distillReply ?? { ok: false, reason: 'no stub' } }
|
|
871
|
+
}
|
|
872
|
+
calls.push(String(url))
|
|
873
|
+
const body = gets === 0 || payloadAfter === null ? payload : payloadAfter
|
|
874
|
+
gets += 1
|
|
875
|
+
return { ok: true, status: 200, json: async () => body }
|
|
876
|
+
}
|
|
877
|
+
const view = mount(() => tab.View({ sessionId: 'sess-1' }), tab.runtime)
|
|
878
|
+
await view.settle()
|
|
879
|
+
return { ...tab, view, calls, writes }
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
await check('applying it adds a Memory tab of its own to the Conversation row', async () => {
|
|
883
|
+
const { spec, View } = await openMemoryTab()
|
|
884
|
+
assert.equal(spec.id, client.GRAPH_VIEW_ID)
|
|
885
|
+
// A fresh id adds a cell; reusing 'chat' or 'trajectory' would replace it.
|
|
886
|
+
assert.ok(spec.id !== 'chat' && spec.id !== 'trajectory', 'the tab must not take a shipped id')
|
|
887
|
+
assert.ok(spec.order > 10, 'after 上下文 (trajectory, order 10)')
|
|
888
|
+
assert.equal(typeof View, 'function')
|
|
889
|
+
})
|
|
890
|
+
|
|
891
|
+
await check('the Memory tab label follows the page language', async () => {
|
|
892
|
+
for (const [language, expected] of [
|
|
893
|
+
['zh-CN', '记忆'],
|
|
894
|
+
['en-US', 'Memory'],
|
|
895
|
+
]) {
|
|
896
|
+
const { spec } = await openMemoryTab(language)
|
|
897
|
+
const label = typeof spec.label === 'function' ? spec.label() : spec.label
|
|
898
|
+
assert.equal(label, expected)
|
|
899
|
+
}
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
await check('the Memory tab asks the host for this session, and only this session', async () => {
|
|
903
|
+
const { calls, view } = await renderMemoryTab()
|
|
904
|
+
assert.equal(calls.length, 1, 'one request per paint')
|
|
905
|
+
assert.equal(calls[0], `${client.GRAPH_API}?session=sess-1`)
|
|
906
|
+
assert.equal(client.GRAPH_API, '/api/memory-graph.graph', 'the route the host registers')
|
|
907
|
+
assert.equal(byClass(view.tree, 'mgv-root').length, 1)
|
|
908
|
+
view.unmount()
|
|
909
|
+
})
|
|
910
|
+
|
|
911
|
+
await check('the Memory tab draws every node and edge the host sent', async () => {
|
|
912
|
+
const { view } = await renderMemoryTab()
|
|
913
|
+
assert.equal(byClass(view.tree, 'mgv-node').length, 3, 'session, episode and card are on screen')
|
|
914
|
+
assert.equal(byClass(view.tree, 'mgv-link').length, 2)
|
|
915
|
+
const kinds = byClass(view.tree, 'mgv-node').map((node) => node.props['data-k'])
|
|
916
|
+
assert.deepEqual(kinds, ['session', 'episode', 'card'])
|
|
917
|
+
const aside = byClass(view.tree, 'mgv-aside')
|
|
918
|
+
assert.equal(aside.length, 0, 'nothing is selected until a node is clicked')
|
|
919
|
+
view.unmount()
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
await check('clicking a card opens it in the side panel', async () => {
|
|
923
|
+
const { view } = await renderMemoryTab()
|
|
924
|
+
const card = byClass(view.tree, 'mgv-node').find((node) => node.props['data-k'] === 'card')
|
|
925
|
+
card.props.onClick({ stopPropagation() {} })
|
|
926
|
+
const aside = byClass(view.tree, 'mgv-aside')
|
|
927
|
+
assert.equal(aside.length, 1, 'the panel appears')
|
|
928
|
+
const shown = text(aside[0])
|
|
929
|
+
assert.ok(shown.includes('记忆库用纯文本存储'), 'the card title is shown')
|
|
930
|
+
assert.ok(shown.includes('每个记忆一个文件'), 'and its body, so the click answers "what was that?"')
|
|
931
|
+
assert.ok(shown.includes('memory-vault'), 'with the entities that connect it')
|
|
932
|
+
// Clicking empty canvas clears the selection.
|
|
933
|
+
byClass(view.tree, 'mgv-canvas')[0].props.onClick()
|
|
934
|
+
assert.equal(byClass(view.tree, 'mgv-aside').length, 0)
|
|
935
|
+
view.unmount()
|
|
936
|
+
})
|
|
937
|
+
|
|
938
|
+
await check('an empty card layer says which half is missing', async () => {
|
|
939
|
+
const withCards = await renderMemoryTab()
|
|
940
|
+
assert.equal(byClass(withCards.view.tree, 'mgv-note').length, 0, 'a vault with cards needs no note')
|
|
941
|
+
withCards.view.unmount()
|
|
942
|
+
|
|
943
|
+
// 0 cards is the state right after a first install: the graph can only show
|
|
944
|
+
// episodes, which looks broken unless the panel says why.
|
|
945
|
+
const noCards = await renderMemoryTab({
|
|
946
|
+
payload: {
|
|
947
|
+
...SESSION_GRAPH,
|
|
948
|
+
counts: { ...SESSION_GRAPH.counts, cards: 0 },
|
|
949
|
+
recall: [],
|
|
950
|
+
nodes: SESSION_GRAPH.nodes.filter((node) => node.k !== 'card'),
|
|
951
|
+
links: [{ s: 's:session', t: 'e:2026-11-02.jsonl:0', k: 'said' }],
|
|
952
|
+
},
|
|
953
|
+
})
|
|
954
|
+
const note = byClass(noCards.view.tree, 'mgv-note')
|
|
955
|
+
assert.equal(note.length, 1)
|
|
956
|
+
assert.ok(text(note[0]).includes('蒸馏'), 'it points at the step that fills the card layer')
|
|
957
|
+
assert.equal(byClass(noCards.view.tree, 'mgv-node').length, 2, 'the episodes are still drawn')
|
|
958
|
+
noCards.view.unmount()
|
|
959
|
+
})
|
|
960
|
+
|
|
961
|
+
await check('蒸馏一次 spends one POST, then re-reads the graph with the cards it wrote', async () => {
|
|
962
|
+
const { view, calls, writes } = await renderMemoryTab({
|
|
963
|
+
payload: { ...SESSION_GRAPH, counts: { ...SESSION_GRAPH.counts, cards: 0 } },
|
|
964
|
+
payloadAfter: { ...SESSION_GRAPH, counts: { ...SESSION_GRAPH.counts, cards: 3 } },
|
|
965
|
+
distillReply: { ok: true, episodes: 12, written: 3, batches: { done: 2, size: 6 } },
|
|
966
|
+
})
|
|
967
|
+
const button = byClass(view.tree, 'mgv-distill')
|
|
968
|
+
assert.equal(button.length, 1, 'the panel offers the button')
|
|
969
|
+
assert.equal(text(button[0]), '蒸馏一次')
|
|
970
|
+
assert.equal(writes.length, 0, 'nothing is spent by merely opening the tab')
|
|
971
|
+
assert.equal(calls.length, 1)
|
|
972
|
+
|
|
973
|
+
button[0].props.onClick()
|
|
974
|
+
await view.settle()
|
|
975
|
+
await view.settle()
|
|
976
|
+
|
|
977
|
+
assert.deepEqual(writes, [client.DISTILL_API], 'exactly one POST, to the host route')
|
|
978
|
+
assert.equal(calls.length, 2, 'the open panel re-read the graph after the run')
|
|
979
|
+
const note = byClass(view.tree, 'mgv-distill-note')
|
|
980
|
+
assert.equal(note.length, 1)
|
|
981
|
+
assert.ok(text(note[0]).includes('12'), 'how many episodes were read')
|
|
982
|
+
assert.ok(text(note[0]).includes('3'), 'how many cards came out')
|
|
983
|
+
// The card layer is no longer empty, so the "no cards yet" hint is gone.
|
|
984
|
+
assert.equal(byClass(view.tree, 'mgv-distill').length, 1)
|
|
985
|
+
view.unmount()
|
|
986
|
+
})
|
|
987
|
+
|
|
988
|
+
await check('a distill that cannot run prints the reason instead of spinning', async () => {
|
|
989
|
+
const { view, writes } = await renderMemoryTab({
|
|
990
|
+
distillReply: { ok: false, reason: 'Extractor is disabled. Set config extractor.enabled = true.' },
|
|
991
|
+
})
|
|
992
|
+
byClass(view.tree, 'mgv-distill')[0].props.onClick()
|
|
993
|
+
await view.settle()
|
|
994
|
+
await view.settle()
|
|
995
|
+
|
|
996
|
+
assert.deepEqual(writes, [client.DISTILL_API])
|
|
997
|
+
const note = byClass(view.tree, 'mgv-distill-note')
|
|
998
|
+
assert.equal(note.length, 1)
|
|
999
|
+
assert.ok(text(note[0]).includes('Extractor is disabled'), 'the host reason reaches the screen')
|
|
1000
|
+
assert.equal(byClass(view.tree, 'mgv-node').length > 0, true, 'the graph is still there')
|
|
1001
|
+
view.unmount()
|
|
1002
|
+
})
|
|
1003
|
+
|
|
1004
|
+
await check('a session with no episodes explains itself instead of drawing', async () => {
|
|
1005
|
+
const { view } = await renderMemoryTab({
|
|
1006
|
+
payload: { ...SESSION_GRAPH, empty: true, counts: { ...SESSION_GRAPH.counts, episodes: 0 }, nodes: [], links: [], recall: [] },
|
|
1007
|
+
})
|
|
1008
|
+
assert.equal(byClass(view.tree, 'mgv-canvas').length, 0, 'no empty drawing')
|
|
1009
|
+
const empty = byClass(view.tree, 'mgv-empty')
|
|
1010
|
+
assert.equal(empty.length, 1)
|
|
1011
|
+
assert.ok(text(empty[0]).includes('还没有留下情节'))
|
|
1012
|
+
view.unmount()
|
|
1013
|
+
})
|
|
1014
|
+
|
|
1015
|
+
await check('a failing route prints the reason instead of a blank panel', async () => {
|
|
1016
|
+
const tab = await openMemoryTab()
|
|
1017
|
+
const previousFetch = tab.sandbox.fetch
|
|
1018
|
+
tab.sandbox.fetch = async () => ({
|
|
1019
|
+
ok: false,
|
|
1020
|
+
status: 500,
|
|
1021
|
+
json: async () => ({ ok: false, reason: 'unavailable', message: 'vault is gone' }),
|
|
1022
|
+
})
|
|
1023
|
+
try {
|
|
1024
|
+
const view = mount(() => tab.View({ sessionId: 'sess-1' }), tab.runtime)
|
|
1025
|
+
await view.settle()
|
|
1026
|
+
const empty = byClass(view.tree, 'mgv-empty')
|
|
1027
|
+
assert.equal(empty.length, 1)
|
|
1028
|
+
assert.ok(text(empty[0]).includes('vault is gone'), 'the message the host sent is on screen')
|
|
1029
|
+
view.unmount()
|
|
1030
|
+
} finally {
|
|
1031
|
+
tab.sandbox.fetch = previousFetch
|
|
1032
|
+
}
|
|
1033
|
+
})
|
|
1034
|
+
|
|
1035
|
+
console.log(process.exitCode === 1 ? '\nSOME CHECKS FAILED' : `\nall ${passed} checks passed`)
|