@objectivex666/dsh-settings-search 1.13.1 → 1.14.1

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.
@@ -14,6 +14,8 @@ const instrumented = source.replace(hook, `
14
14
  modelsUrl, fetchAiModels,
15
15
  readIndexCache, serializeHarvest,
16
16
  readDshModelConfig, otherPluginFingerprint, DEEP_SLOTS,
17
+ pinyinKeys, tokenMatch, intentKeywords, directMatch,
18
+ localRelatedKeyword, aiRelatedKeyword,
17
19
  log, flushLogs, discardPendingLogs, sessionLogText, logTarget, readLogCount,
18
20
  })
19
21
  ${hook}`)
@@ -25,11 +27,17 @@ const storage = () => {
25
27
  removeItem: (key) => values.delete(key),
26
28
  }
27
29
  }
28
- const json = (data, status = 200) => ({
29
- ok: status >= 200 && status < 300,
30
- status,
31
- json: async () => data,
32
- })
30
+ const json = (data, status = 200) => {
31
+ const text = JSON.stringify(data)
32
+ return {
33
+ ok: status >= 200 && status < 300,
34
+ status,
35
+ headers: { get: () => String(new TextEncoder().encode(text).byteLength) },
36
+ body: null,
37
+ text: async () => text,
38
+ json: async () => data,
39
+ }
40
+ }
33
41
  const plain = (value) => JSON.parse(JSON.stringify(value))
34
42
  const ollama = { provider: 'ollama', baseUrl: '', model: 'local-model:7b', apiKey: '' }
35
43
  const openai = { provider: 'openai', baseUrl: 'https://example.test/v1', model: 'cloud-model', apiKey: 'test-secret' }
@@ -42,6 +50,151 @@ const reply = (provider, content = '{"keywords":["Language"]}') => provider ===
42
50
  ? { content: [{ type: 'text', text: content }] }
43
51
  : { choices: [{ message: { role: 'assistant', content } }] }
44
52
 
53
+ // --- A tiny synchronous DOM, enough for the in-page harvest (see the DSH classes below) ---
54
+
55
+ /** Parse one compound selector: an optional tag plus `[attr]`/`[attr="v"]`/`[attr*="v" i]`. */
56
+ const parseCompound = (token) => {
57
+ const tag = /^[a-z0-9]+/i.exec(token)?.[0] ?? ''
58
+ const attrs = []
59
+ const re = /\[([a-zA-Z-]+)(?:([*^$]?=)"([^"]*)"(?:\s+(i))?)?\]/g
60
+ let match
61
+ while ((match = re.exec(token)) !== null) {
62
+ attrs.push({ name: match[1], op: match[2], value: match[3], insensitive: match[4] === 'i' })
63
+ }
64
+ return { tag, attrs }
65
+ }
66
+
67
+ const attrOf = (node, name) => (name === 'class' ? node.class : node.attr[name])
68
+
69
+ const compoundMatches = (node, compound) => {
70
+ if (compound.tag !== '' && node.tag.toLowerCase() !== compound.tag.toLowerCase()) return false
71
+ for (const attr of compound.attrs) {
72
+ const raw = attrOf(node, attr.name)
73
+ if (raw === undefined) return false
74
+ if (attr.op === undefined) continue
75
+ const value = attr.insensitive ? raw.toLowerCase() : raw
76
+ const expected = attr.insensitive ? attr.value.toLowerCase() : attr.value
77
+ if (attr.op === '=' && value !== expected) return false
78
+ if (attr.op === '*=' && !value.includes(expected)) return false
79
+ }
80
+ return true
81
+ }
82
+
83
+ /** Collect descendants matching any compound of a comma-separated selector. */
84
+ const matchAll = (root, selector) => {
85
+ const compounds = selector.split(',')
86
+ .map((token) => parseCompound(token.trim()))
87
+ .filter((compound) => compound.tag !== '' || compound.attrs.length > 0)
88
+ const out = []
89
+ const walk = (node) => {
90
+ for (const child of node.children) {
91
+ if (compounds.some((compound) => compoundMatches(child, compound))) out.push(child)
92
+ walk(child)
93
+ }
94
+ }
95
+ walk(root)
96
+ return out
97
+ }
98
+
99
+ /**
100
+ * One element of the fake DOM. `class` follows DSH's CSS-module shape
101
+ * (`RotMhW_groupTitle`), which is what the harvest has to recognise.
102
+ */
103
+ const el = (tag, options = {}, children = []) => {
104
+ const node = {
105
+ tag,
106
+ tagName: tag.toUpperCase(),
107
+ class: options.class ?? '',
108
+ attr: options.attr ?? {},
109
+ text: options.text ?? '',
110
+ children: [],
111
+ parentElement: null,
112
+ get className() { return node.class },
113
+ get textContent() { return [node.text, ...node.children.map((child) => child.textContent)].filter(Boolean).join(' ') },
114
+ get childElementCount() { return node.children.length },
115
+ get firstElementChild() { return node.children[0] ?? null },
116
+ getAttribute(name) { return attrOf(node, name) ?? null },
117
+ querySelectorAll(selector) { return matchAll(node, selector) },
118
+ querySelector(selector) { return matchAll(node, selector)[0] ?? null },
119
+ }
120
+ node.children = children.map((child) => {
121
+ child.parentElement = node
122
+ return child
123
+ })
124
+ return node
125
+ }
126
+
127
+ /** A document stub over one tree, with the pieces `installStyles` and `panelRoot` need. */
128
+ const fakeDocument = (root) => ({
129
+ head: { appendChild() {} },
130
+ body: root,
131
+ createElement: () => ({ dataset: {}, textContent: '', remove() {} }),
132
+ querySelectorAll: (selector) => matchAll(root, selector),
133
+ querySelector: (selector) => matchAll(root, selector)[0] ?? null,
134
+ })
135
+
136
+ /**
137
+ * The DSH 0.2.1 Plugins settings page as the harvest sees it: a search row plus
138
+ * the 会话插件 / 全局插件 groups, whose titles live in `RotMhW_groupTitle`
139
+ * spans (camelCase, like every DSH CSS-module class).
140
+ */
141
+ const pluginsPageDom = () => {
142
+ const titleRow = (title) => el('div', { class: 'RotMhW_groupTitleRow' }, [
143
+ el('button', { class: 'RotMhW_groupToggle', attr: { 'aria-expanded': 'false' } }, [
144
+ el('span', { class: 'RotMhW_groupTitle', text: title }),
145
+ ]),
146
+ ])
147
+ const group = (scope, title, rows = 0) => el('section', { class: 'RotMhW_group', attr: { 'data-plugin-scope': scope } }, [
148
+ titleRow(title),
149
+ el('p', { class: 'RotMhW_groupSub', text: scope === 'preset' ? '由 Agent 预设按会话组成' : '系统与所有会话共用' }),
150
+ el('div', { class: 'RotMhW_pluginList' }, Array.from({ length: rows }, (_, i) =>
151
+ el('button', { class: 'RotMhW_pluginRow' }, [
152
+ el('strong', { class: 'RotMhW_cardTitle', text: `${scope}-plugin-${i}` }),
153
+ ]))),
154
+ ])
155
+ const page = el('div', { class: 'RotMhW_section' }, [
156
+ el('h2', { class: 'RotMhW_heading', text: '插件' }),
157
+ el('div', { class: 'RotMhW_catalog' }, [
158
+ el('label', { class: 'RotMhW_search' }, [
159
+ el('span', { class: 'RotMhW_visuallyHidden', text: '搜索插件' }),
160
+ el('input', { attr: { type: 'search' } }),
161
+ ]),
162
+ el('ul', { class: 'RotMhW_plainPluginList' }, [
163
+ el('li', {}, [
164
+ el('strong', { text: 'PowerShell 命令工具' }),
165
+ el('span', { text: 'tool-pwsh' }),
166
+ ]),
167
+ ]),
168
+ // More card titles than the 64-entry cap, so a DOM-order harvest would
169
+ // truncate the second group title before seeing it.
170
+ group('preset', '会话插件', 70),
171
+ group('global', '全局插件'),
172
+ ]),
173
+ ])
174
+ const navButton = el('button', { attr: { 'aria-current': 'true' }, text: '插件' })
175
+ const dialog = el('div', { attr: { role: 'dialog' } }, [
176
+ el('div', { class: 'RotMhW_nav' }, [navButton]),
177
+ el('div', { attr: { 'data-slot': 'settings.section' } }, [page]),
178
+ ])
179
+ return { dialog }
180
+ }
181
+
182
+ /** A ctx whose Plugins page is the active settings section. */
183
+ const pluginsPageCtx = () => ({
184
+ effect: (fn) => fn(),
185
+ get: () => null,
186
+ inject: () => {},
187
+ locale: { register: () => {}, bind: () => () => '', subscribe: () => () => {} },
188
+ slots: {
189
+ register: (name, id, options) => ({ name, id, options }),
190
+ inject: (_slot, registrar) => registrar(),
191
+ entries: (slot) => (slot === 'settings.section'
192
+ ? [{ options: { id: 'plugins', order: 15, label: '插件' } }]
193
+ : []),
194
+ subscribe: () => () => {},
195
+ },
196
+ })
197
+
45
198
  function load(fetchImpl = async () => json(reply('ollama'))) {
46
199
  let handoff
47
200
  const calls = []
@@ -52,6 +205,8 @@ function load(fetchImpl = async () => json(reply('ollama'))) {
52
205
  CustomEvent: class { constructor(type) { this.type = type } },
53
206
  URL,
54
207
  AbortController,
208
+ TextEncoder,
209
+ TextDecoder,
55
210
  setTimeout,
56
211
  clearTimeout,
57
212
  fetch: async (url, options) => {
@@ -75,8 +230,8 @@ test('persistent settings index reuses matching fingerprints and drops invalid r
75
230
  },
76
231
  }
77
232
  assert.equal(Object.keys(api.serializeHarvest(new Map())).length, 0)
78
- sandbox.localStorage.setItem('dsh-settings-search:index:v1', JSON.stringify({
79
- version: 1,
233
+ sandbox.localStorage.setItem('dsh-settings-search:index:v3', JSON.stringify({
234
+ version: 3,
80
235
  fingerprint: 'manager:test',
81
236
  updatedAt: 1,
82
237
  harvest: records,
@@ -114,14 +269,156 @@ test('persistent settings index reuses matching fingerprints and drops invalid r
114
269
  assert.equal(model.persistIndex('manager:changed'), false)
115
270
  })
116
271
 
117
- test('readIndexCache rejects malformed persisted index payloads', () => {
272
+ test('readIndexCache rejects malformed and legacy persisted index payloads', () => {
118
273
  const { api, sandbox } = load()
119
- for (const value of ['{', '{"version":1}', 'null']) {
120
- sandbox.localStorage.setItem('dsh-settings-search:index:v1', value)
274
+ const legacyV2 = JSON.stringify({ version: 2, fingerprint: 'manager:v2', updatedAt: 1, harvest: {} })
275
+ const legacyV1 = JSON.stringify({ version: 1, fingerprint: 'manager:old', updatedAt: 1, harvest: {} })
276
+ for (const value of ['{', '{"version":3}', 'null', legacyV2, legacyV1]) {
277
+ sandbox.localStorage.setItem('dsh-settings-search:index:v3', value)
121
278
  assert.equal(api.readIndexCache(), null)
122
279
  }
123
280
  })
124
281
 
282
+ test('writing the index uses the current schema and drops the legacy cache key', () => {
283
+ const { api, sandbox } = load()
284
+ const records = { 'settings.general.item': { row1: { title: 'Row', idx: 0, text: '', inner: [] } } }
285
+ sandbox.localStorage.setItem('dsh-settings-search:index:v1', JSON.stringify({ version: 1, fingerprint: 'manager:old', updatedAt: 1, harvest: records }))
286
+ sandbox.localStorage.setItem('dsh-settings-search:index:v2', JSON.stringify({ version: 2, fingerprint: 'manager:test', updatedAt: 1, harvest: records }))
287
+ sandbox.localStorage.setItem('dsh-settings-search:index:v3', JSON.stringify({ version: 3, fingerprint: 'manager:test', updatedAt: 1, harvest: records }))
288
+ api.apply({
289
+ effect: (fn) => fn(),
290
+ get: () => null,
291
+ inject: () => {},
292
+ locale: { register: () => {}, bind: () => () => '', subscribe: () => () => {} },
293
+ slots: {
294
+ register: (name, id, options) => ({ name, id, options }),
295
+ inject: (_slot, registrar) => registrar(),
296
+ entries: (slot) => (slot === 'settings.general.item' ? [{ options: { id: 'row1', order: 1 } }] : []),
297
+ subscribe: () => () => {},
298
+ },
299
+ })
300
+ const model = api.__indexTestModel
301
+ assert.equal(model.persistIndex('manager:next'), true)
302
+ assert.equal(sandbox.localStorage.getItem('dsh-settings-search:index:v1'), null)
303
+ assert.equal(sandbox.localStorage.getItem('dsh-settings-search:index:v2'), null)
304
+ const raw = JSON.parse(sandbox.localStorage.getItem('dsh-settings-search:index:v3'))
305
+ assert.equal(raw.version, 3)
306
+ assert.equal(raw.fingerprint, 'manager:next')
307
+ })
308
+
309
+ test('in-page group titles on the Plugins page are harvested and searchable', () => {
310
+ const { api, sandbox } = load()
311
+ sandbox.document = fakeDocument(pluginsPageDom().dialog)
312
+ api.apply(pluginsPageCtx())
313
+ const model = api.__indexTestModel
314
+ // The warm pass visits the active section and mines what it renders; the
315
+ // 会话插件 / 全局插件 groups are plain DOM, not slot registrations.
316
+ model.runHarvest()
317
+ const options = model.readDeep().filter((item) => item.parentLabel === '插件')
318
+ const labels = options.map((item) => item.label)
319
+ assert.ok(labels.includes('会话插件'), `会话插件 missing from ${JSON.stringify(labels)}`)
320
+ assert.ok(labels.includes('全局插件'), `全局插件 missing from ${JSON.stringify(labels)}`)
321
+ assert.ok(labels.includes('PowerShell 命令工具'), `plain plugin-list row missing from ${JSON.stringify(labels)}`)
322
+ for (const label of ['会话插件', '全局插件']) {
323
+ const option = options.find((item) => item.label === label)
324
+ assert.equal(option.kind, 'option')
325
+ assert.equal(option.pageLabel, '插件')
326
+ assert.equal(option.slot, 'settings.section')
327
+ }
328
+ // The page's own filter box is not a setting: its label text must not become
329
+ // an in-page option (the Plugins tab renders one).
330
+ assert.ok(!labels.includes('搜索插件'), `the page filter leaked into the index: ${JSON.stringify(labels)}`)
331
+ })
332
+
333
+ test('the settings-search page and its .sss-vr-label controls stay indexed', () => {
334
+ const { api, sandbox } = load()
335
+ const label = (text) => el('span', { class: 'sss-vr-label', text })
336
+ const page = el('div', { class: 'sss-vr' }, [
337
+ el('span', { class: 'sss-vr-name', text: '设置搜索' }),
338
+ el('div', { class: 'sss-vr-field' }, [label('搜索框样式')]),
339
+ ])
340
+ const dialog = el('div', { attr: { role: 'dialog' } }, [
341
+ el('div', { class: 'settings-nav' }, [
342
+ el('button', { attr: { 'aria-current': 'true' }, text: '设置搜索' }),
343
+ ]),
344
+ el('div', { attr: { 'data-slot': 'settings.section' } }, [page]),
345
+ ])
346
+ sandbox.document = fakeDocument(dialog)
347
+ const ctx = pluginsPageCtx()
348
+ ctx.slots.entries = (slot) => slot === 'settings.section'
349
+ ? [{ options: { id: 'settings-search', order: 1600, label: '设置搜索' } }]
350
+ : []
351
+ api.apply(ctx)
352
+ const model = api.__indexTestModel
353
+
354
+ assert.ok(model.readSections().some((section) => section.id === 'settings-search'))
355
+ model.runHarvest()
356
+ let options = model.readDeep().filter((item) => item.parentLabel === '设置搜索')
357
+ assert.deepEqual(plain(options.map((item) => item.label)), ['搜索框样式'])
358
+
359
+ // Expanding the page adds controls after the first non-empty harvest. They
360
+ // must merge into the existing record rather than being ignored.
361
+ for (const text of ['AI 联想搜索', '设置索引']) {
362
+ const field = el('div', { class: 'sss-vr-field' }, [label(text)])
363
+ field.parentElement = page
364
+ page.children.push(field)
365
+ }
366
+ model.runHarvest()
367
+ options = model.readDeep().filter((item) => item.parentLabel === '设置搜索')
368
+ const labels = options.map((item) => item.label)
369
+ for (const expected of ['搜索框样式', 'AI 联想搜索', '设置索引']) {
370
+ assert.ok(labels.includes(expected), `${expected} missing from ${JSON.stringify(labels)}`)
371
+ }
372
+ for (const option of options) {
373
+ assert.equal(option.slot, 'settings.section')
374
+ assert.equal(option.pageLabel, '设置搜索')
375
+ }
376
+ })
377
+
378
+ test('pinyin keeps known segments after an unknown character', () => {
379
+ const { api } = load()
380
+ const keys = api.pinyinKeys('知演功能')
381
+ assert.ok(keys, 'known pinyin segments must survive an unknown character')
382
+ assert.equal(keys.full, 'zhi\u0000gongneng')
383
+ assert.ok(keys.initialSegments.includes('gn'))
384
+ assert.ok(api.tokenMatch('知演功能', keys, 'gn'))
385
+ assert.equal(api.tokenMatch('知演功能', keys, 'zhign'), null, 'matching must not cross the unknown character')
386
+ })
387
+
388
+ test('intent expansion rejects one-character and internal substring noise', () => {
389
+ const { api } = load()
390
+ assert.deepEqual(plain(api.intentKeywords('文件')), [])
391
+ assert.deepEqual(plain(api.intentKeywords('文')), [])
392
+ const cancel = api.intentKeywords('取消')
393
+ assert.ok(cancel.includes('订阅'))
394
+ assert.ok(api.intentKeywords('我想取消订阅').includes('订阅'))
395
+ })
396
+
397
+ test('local intent suggestions require every query token', () => {
398
+ const { api } = load()
399
+ const language = {
400
+ keys: api.pinyinKeys('语言'),
401
+ hay: '语言',
402
+ }
403
+ const expanded = new Map([
404
+ ['中文', ['语言']],
405
+ ['外观', []],
406
+ ])
407
+ assert.equal(api.localRelatedKeyword(language, language.hay, ['中文'], expanded), '语言')
408
+ assert.equal(
409
+ api.localRelatedKeyword(language, language.hay, ['外观', '中文'], expanded),
410
+ null,
411
+ 'an unmatched token must not be ignored',
412
+ )
413
+ const theme = { keys: api.pinyinKeys('主题'), hay: '主题' }
414
+ const themeExpanded = new Map([
415
+ ['太亮', ['主题']],
416
+ ['主题', []],
417
+ ])
418
+ assert.equal(api.localRelatedKeyword(theme, theme.hay, ['太亮', '主题'], themeExpanded), '主题')
419
+ assert.equal(api.aiRelatedKeyword(language, language.hay, ['语言']), 'AI · 语言')
420
+ })
421
+
125
422
  test('Ollama configuration persists and requires a model but no key', () => {
126
423
  const { api, sandbox } = load()
127
424
  api.setAiConfig({ ...ollama, model: ' local-model:7b ' })
@@ -303,10 +600,52 @@ test('malformed responses and native error payloads cannot pass connectivity che
303
600
  const { api } = load(async () => json(data))
304
601
  assert.equal((await api.testAiService(ollama)).code, 'bad-response')
305
602
  }
306
- const { api } = load(async () => ({ ok: true, json: async () => { throw new SyntaxError('invalid JSON') } }))
603
+ const { api } = load(async () => ({ ok: true, status: 200, headers: { get: () => null }, body: null, text: async () => '{' }))
307
604
  assert.equal((await api.testAiService(ollama)).code, 'bad-response')
308
605
  })
309
606
 
607
+ test('oversized provider responses are rejected without an unbounded read', async () => {
608
+ let cancelled = false
609
+ const { api } = load(async () => ({
610
+ ok: true,
611
+ status: 200,
612
+ headers: { get: () => String(1024 * 1024 + 1) },
613
+ body: { cancel: async () => { cancelled = true } },
614
+ text: async () => { throw new Error('must not read declared-oversized body') },
615
+ }))
616
+ assert.equal((await api.fetchAiModels(openai)).code, 'bad-response')
617
+ assert.equal(cancelled, true)
618
+ api.setAiConfig(openai)
619
+ await assert.rejects(api.requestUserAi('intent', 'Language'), { name: 'AI_BAD_RESPONSE' })
620
+ })
621
+
622
+ test('streamed provider responses stop at the byte ceiling', async () => {
623
+ let cancelled = false
624
+ const chunk = new Uint8Array(1024 * 1024 + 1)
625
+ const { api } = load(async () => ({
626
+ ok: true,
627
+ status: 200,
628
+ headers: { get: () => null },
629
+ body: {
630
+ getReader: () => {
631
+ let sent = false
632
+ return {
633
+ read: async () => {
634
+ if (sent) return { done: true, value: undefined }
635
+ sent = true
636
+ return { done: false, value: chunk }
637
+ },
638
+ cancel: async () => { cancelled = true },
639
+ releaseLock: () => {},
640
+ }
641
+ },
642
+ },
643
+ text: async () => { throw new Error('stream must be used') },
644
+ }))
645
+ assert.equal((await api.fetchAiModels(openai)).code, 'bad-response')
646
+ assert.equal(cancelled, true)
647
+ })
648
+
310
649
  test('network failure is reported without claiming connection success', async () => {
311
650
  const { api } = load(async () => { throw new TypeError('Failed to fetch') })
312
651
  assert.equal((await api.testAiService(ollama)).code, 'network')
@@ -622,7 +961,7 @@ const logRouteCalls = (calls) => calls.filter((call) => String(call.url).include
622
961
 
623
962
  test('log entries are batched to the host log route as one formatted line each', () => {
624
963
  const { api, calls } = load()
625
- api.log('info', '应用装载', { version: '1.13.1' })
964
+ api.log('info', '应用装载', { version: '1.14.0' })
626
965
  api.log('error', '导入失败')
627
966
  api.flushLogs()
628
967
  const [call] = logRouteCalls(calls)
@@ -631,7 +970,7 @@ test('log entries are batched to the host log route as one formatted line each',
631
970
  assert.deepEqual(plain(call.headers), { 'content-type': 'application/json' })
632
971
  assert.equal(call.body.action, 'append')
633
972
  assert.equal(call.body.lines.length, 2)
634
- assert.match(call.body.lines[0], /^\d{4}-\d{2}-\d{2}T[\d:.]+Z \[info\] \[[0-9a-f]{8}:1\] 应用装载 \{"version":"1\.13\.1"\}$/)
973
+ assert.match(call.body.lines[0], /^\d{4}-\d{2}-\d{2}T[\d:.]+Z \[info\] \[[0-9a-f]{8}:1\] 应用装载 \{"version":"1\.14\.0"\}$/)
635
974
  assert.match(call.body.lines[1], /\[error\] \[[0-9a-f]{8}:2\] 导入失败$/)
636
975
  })
637
976