@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.
- package/README.en.md +17 -10
- package/README.md +66 -56
- package/lib/client.js +280 -70
- package/lib/index.js +44 -5
- package/package.json +3 -2
- package/scripts/ai.test.mjs +352 -13
- package/scripts/compat.mjs +269 -0
- package/scripts/log.test.mjs +52 -2
package/scripts/ai.test.mjs
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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:
|
|
79
|
-
version:
|
|
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
|
-
|
|
120
|
-
|
|
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,
|
|
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.
|
|
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\.
|
|
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
|
|