@objectivex666/dsh-settings-search 1.14.0 → 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 +3 -1
- package/README.md +3 -1
- package/lib/client.js +245 -69
- package/lib/index.js +44 -5
- package/package.json +1 -1
- package/scripts/ai.test.mjs +166 -15
- package/scripts/compat.mjs +16 -2
- package/scripts/log.test.mjs +52 -2
package/README.en.md
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
[](https://nodejs.org)
|
|
8
8
|
[](https://awesome-dsh-plugin.com)
|
|
9
9
|
[](https://dsh-plugin.org/plugins/objectivex666/dsh-settings-search)
|
|
10
|
+
[](https://dshbase.com/zh/plugins/dsh-settings-search-plugin/)
|
|
11
|
+
[](https://dshfind.com/zh/plugins/objectivex666/dsh-settings-search?ref=badge)
|
|
10
12
|
|
|
11
13
|
**English** · [中文](./README.md)
|
|
12
14
|
|
|
@@ -43,7 +45,7 @@ The commands below target a command-line profile. The desktop app manages its ow
|
|
|
43
45
|
|
|
44
46
|
### Prerequisites
|
|
45
47
|
|
|
46
|
-
- **DeepSeek Harness (DSH)** — DSH 0.2 stable (desktop and `dsh web`) and earlier 0.1 builds; the plugin picks the API the running release offers. Verified against **0.2.
|
|
48
|
+
- **DeepSeek Harness (DSH)** — DSH 0.2 stable (desktop and `dsh web`) and earlier 0.1 builds; the plugin picks the API the running release offers. Verified against **0.2.2-rc.3** item by item (`npm run compat`); behaviour on 0.2.1 and earlier is unchanged.
|
|
47
49
|
- **Node.js** >= 18 (LTS recommended).
|
|
48
50
|
|
|
49
51
|
### From npm (recommended)
|
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
[](https://nodejs.org)
|
|
8
8
|
[](https://awesome-dsh-plugin.com)
|
|
9
9
|
[](https://dsh-plugin.org/plugins/objectivex666/dsh-settings-search)
|
|
10
|
+
[](https://dshbase.com/zh/plugins/dsh-settings-search-plugin/)
|
|
11
|
+
[](https://dshfind.com/zh/plugins/objectivex666/dsh-settings-search?ref=badge)
|
|
10
12
|
|
|
11
13
|
**中文** · [English](./README.en.md)
|
|
12
14
|
|
|
@@ -45,7 +47,7 @@ DSH 的设置项散落在各个分区和插件页里,找一个开关往往要
|
|
|
45
47
|
|
|
46
48
|
### 前置要求
|
|
47
49
|
|
|
48
|
-
- **DeepSeek Harness (DSH)**。DSH 0.2 正式版(桌面版与 `dsh web`)和更早的 0.1 版本都可以,插件会按运行中的版本自动选择对应接口。当前已在 **0.2.
|
|
50
|
+
- **DeepSeek Harness (DSH)**。DSH 0.2 正式版(桌面版与 `dsh web`)和更早的 0.1 版本都可以,插件会按运行中的版本自动选择对应接口。当前已在 **0.2.2-rc.3** 上逐项核对过(`npm run compat`),0.2.1 及更早版本的行为保持不变。
|
|
49
51
|
- **Node.js**。18 或更高,推荐 LTS。
|
|
50
52
|
|
|
51
53
|
### 通过 npm 安装(推荐)
|
package/lib/client.js
CHANGED
|
@@ -30,7 +30,7 @@ window.__ModuleLoader__.load({
|
|
|
30
30
|
|
|
31
31
|
const NS = 'settings-search-v5'
|
|
32
32
|
/** Single source of truth is package.json — scripts/check.mjs asserts this matches. */
|
|
33
|
-
const PKG_VERSION = '1.14.
|
|
33
|
+
const PKG_VERSION = '1.14.1'
|
|
34
34
|
const NPM_NAME = '@objectivex666/dsh-settings-search'
|
|
35
35
|
const REPO_URL = 'https://github.com/objectivex666/dsh-settings-search'
|
|
36
36
|
const ISSUES_URL = 'https://github.com/objectivex666/dsh-settings-search/issues/new/choose'
|
|
@@ -384,11 +384,13 @@ window.__ModuleLoader__.load({
|
|
|
384
384
|
)
|
|
385
385
|
const CJK_RE = /[\u4e00-\u9fff]/
|
|
386
386
|
const ALNUM_RE = /[a-zA-Z0-9]/
|
|
387
|
+
const PINYIN_UNKNOWN = '\u0000'
|
|
387
388
|
|
|
388
389
|
/**
|
|
389
390
|
* Transliterate a label into full-pinyin and initial-letter keys for
|
|
390
|
-
* association search.
|
|
391
|
-
*
|
|
391
|
+
* association search. Characters outside the compact table become segment
|
|
392
|
+
* boundaries, so one unknown character does not disable every known pinyin
|
|
393
|
+
* segment in the same label.
|
|
392
394
|
*
|
|
393
395
|
* Results are memoized: the same labels and intent keywords are
|
|
394
396
|
* re-transliterated on every keystroke's re-filter, and this CJK loop
|
|
@@ -405,8 +407,9 @@ window.__ModuleLoader__.load({
|
|
|
405
407
|
if (CJK_RE.test(ch)) {
|
|
406
408
|
const py = PINYIN.get(ch)
|
|
407
409
|
if (py === undefined) {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
+
full += PINYIN_UNKNOWN
|
|
411
|
+
initials += PINYIN_UNKNOWN
|
|
412
|
+
continue
|
|
410
413
|
}
|
|
411
414
|
full += py
|
|
412
415
|
initials += py[0]
|
|
@@ -416,7 +419,11 @@ window.__ModuleLoader__.load({
|
|
|
416
419
|
initials += lower
|
|
417
420
|
}
|
|
418
421
|
}
|
|
419
|
-
const
|
|
422
|
+
const segments = full.split(PINYIN_UNKNOWN).filter(Boolean)
|
|
423
|
+
const initialSegments = initials.split(PINYIN_UNKNOWN).filter(Boolean)
|
|
424
|
+
const keys = segments.length === 0
|
|
425
|
+
? null
|
|
426
|
+
: { full, initials, segments, initialSegments }
|
|
420
427
|
PINYIN_KEY_CACHE.set(text, keys)
|
|
421
428
|
return keys
|
|
422
429
|
}
|
|
@@ -426,7 +433,7 @@ window.__ModuleLoader__.load({
|
|
|
426
433
|
if (haystackLower.includes(token)) return 'text'
|
|
427
434
|
if (keys !== null) {
|
|
428
435
|
if (keys.full.includes(token)) return 'pinyin'
|
|
429
|
-
if (keys.
|
|
436
|
+
if (keys.initialSegments.some((segment) => segment.startsWith(token))) return 'initials'
|
|
430
437
|
}
|
|
431
438
|
return null
|
|
432
439
|
}
|
|
@@ -500,9 +507,11 @@ window.__ModuleLoader__.load({
|
|
|
500
507
|
if (cached !== undefined) return cached
|
|
501
508
|
const hits = []
|
|
502
509
|
for (const intent of INTENTS) {
|
|
503
|
-
const phraseHit = intent.phrase
|
|
510
|
+
const phraseHit = intent.phrase === tok ||
|
|
511
|
+
(tok.length >= 2 && (intent.phrase.startsWith(tok) || tok.includes(intent.phrase))) ||
|
|
504
512
|
(intent.keys !== null &&
|
|
505
|
-
(intent.keys.
|
|
513
|
+
(intent.keys.initials === tok ||
|
|
514
|
+
(tok.length >= 2 && intent.keys.full.startsWith(tok))))
|
|
506
515
|
if (phraseHit) {
|
|
507
516
|
for (const kw of intent.keywords) {
|
|
508
517
|
if (!hits.includes(kw)) hits.push(kw)
|
|
@@ -513,6 +522,47 @@ window.__ModuleLoader__.load({
|
|
|
513
522
|
return hits
|
|
514
523
|
}
|
|
515
524
|
|
|
525
|
+
/** Match every whitespace-separated query token against one setting. */
|
|
526
|
+
const directMatch = (item, haystackLower, tokens) =>
|
|
527
|
+
tokens.every((tok) => tokenMatch(haystackLower, item.keys, tok) !== null)
|
|
528
|
+
|
|
529
|
+
const kwMatch = (_item, haystackLower, kw) => {
|
|
530
|
+
if (/^[\x00-\x7f]+$/.test(kw)) {
|
|
531
|
+
if (wordBoundaryRegex(kw).test(haystackLower)) return true
|
|
532
|
+
} else if (haystackLower.includes(kw)) return true
|
|
533
|
+
return false
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Local intent expansion keeps the same AND semantics as direct search:
|
|
538
|
+
* every query token must match literally or through that token's own intent
|
|
539
|
+
* keywords. This stops an unrelated extra token from being ignored.
|
|
540
|
+
*/
|
|
541
|
+
const localRelatedKeyword = (item, haystackLower, tokens, expandedKeywords) => {
|
|
542
|
+
if (directMatch(item, haystackLower, tokens)) return null
|
|
543
|
+
let via = null
|
|
544
|
+
for (const tok of tokens) {
|
|
545
|
+
if (tokenMatch(haystackLower, item.keys, tok) !== null) continue
|
|
546
|
+
let tokenVia = null
|
|
547
|
+
for (const kw of expandedKeywords.get(tok) ?? []) {
|
|
548
|
+
if (kwMatch(item, haystackLower, kw)) {
|
|
549
|
+
tokenVia = kw
|
|
550
|
+
break
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (tokenVia === null) return null
|
|
554
|
+
if (via === null) via = tokenVia
|
|
555
|
+
}
|
|
556
|
+
return via
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const aiRelatedKeyword = (item, haystackLower, aiKeywords) => {
|
|
560
|
+
for (const kw of aiKeywords) {
|
|
561
|
+
if (kwMatch(item, haystackLower, kw)) return `AI · ${kw}`
|
|
562
|
+
}
|
|
563
|
+
return null
|
|
564
|
+
}
|
|
565
|
+
|
|
516
566
|
const AI_TOGGLE_EVENT = 'dsh-settings-search:ai-toggle'
|
|
517
567
|
const AI_CONFIG_EVENT = 'dsh-settings-search:ai-config'
|
|
518
568
|
/**
|
|
@@ -713,6 +763,8 @@ window.__ModuleLoader__.load({
|
|
|
713
763
|
return lines.join('\n')
|
|
714
764
|
}
|
|
715
765
|
const AI_CACHE = new Map()
|
|
766
|
+
/** Bound untrusted provider bodies before decoding/parsing them. */
|
|
767
|
+
const AI_RESPONSE_MAX_BYTES = 1024 * 1024
|
|
716
768
|
const AI_STORAGE_KEY = 'dsh-settings-search:ai-enabled'
|
|
717
769
|
const AI_PROVIDER_KEY = 'dsh-settings-search:ai-provider'
|
|
718
770
|
const AI_BASE_URL_KEY = 'dsh-settings-search:ai-base-url'
|
|
@@ -732,6 +784,63 @@ window.__ModuleLoader__.load({
|
|
|
732
784
|
const { provider, baseUrl, model, apiKey } = normalizeAiConfig(cfg)
|
|
733
785
|
return Boolean(baseUrl && model && (provider === 'ollama' || apiKey))
|
|
734
786
|
}
|
|
787
|
+
const aiBadResponse = (message) => {
|
|
788
|
+
const error = new Error(message)
|
|
789
|
+
error.name = 'AI_BAD_RESPONSE'
|
|
790
|
+
return error
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Parse a provider response with a byte ceiling. A configured provider is
|
|
794
|
+
* still an untrusted network peer, so its response must not become an
|
|
795
|
+
* unbounded browser allocation merely because it is valid JSON.
|
|
796
|
+
*/
|
|
797
|
+
const readAiJson = async (response) => {
|
|
798
|
+
const declared = Number(response.headers?.get?.('content-length'))
|
|
799
|
+
if (Number.isFinite(declared) && declared > AI_RESPONSE_MAX_BYTES) {
|
|
800
|
+
try { await response.body?.cancel?.() } catch {}
|
|
801
|
+
throw aiBadResponse('AI response too large')
|
|
802
|
+
}
|
|
803
|
+
const parse = (text) => {
|
|
804
|
+
try { return JSON.parse(text) } catch {
|
|
805
|
+
throw aiBadResponse('Invalid AI JSON response')
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
if (response.body !== null && typeof response.body?.getReader === 'function') {
|
|
809
|
+
const reader = response.body.getReader()
|
|
810
|
+
const chunks = []
|
|
811
|
+
let bytes = 0
|
|
812
|
+
try {
|
|
813
|
+
while (true) {
|
|
814
|
+
const { done, value } = await reader.read()
|
|
815
|
+
if (done) break
|
|
816
|
+
if (!ArrayBuffer.isView(value) || value.BYTES_PER_ELEMENT !== 1) {
|
|
817
|
+
throw aiBadResponse('Invalid AI response body')
|
|
818
|
+
}
|
|
819
|
+
bytes += value.byteLength
|
|
820
|
+
if (bytes > AI_RESPONSE_MAX_BYTES) {
|
|
821
|
+
try { await reader.cancel() } catch {}
|
|
822
|
+
throw aiBadResponse('AI response too large')
|
|
823
|
+
}
|
|
824
|
+
chunks.push(value)
|
|
825
|
+
}
|
|
826
|
+
} finally {
|
|
827
|
+
try { reader.releaseLock() } catch {}
|
|
828
|
+
}
|
|
829
|
+
const merged = new Uint8Array(bytes)
|
|
830
|
+
let offset = 0
|
|
831
|
+
for (const chunk of chunks) {
|
|
832
|
+
merged.set(chunk, offset)
|
|
833
|
+
offset += chunk.byteLength
|
|
834
|
+
}
|
|
835
|
+
return parse(new TextDecoder().decode(merged))
|
|
836
|
+
}
|
|
837
|
+
if (typeof response.text !== 'function') throw aiBadResponse('Invalid AI response')
|
|
838
|
+
const text = await response.text()
|
|
839
|
+
if (new TextEncoder().encode(text).byteLength > AI_RESPONSE_MAX_BYTES) {
|
|
840
|
+
throw aiBadResponse('AI response too large')
|
|
841
|
+
}
|
|
842
|
+
return parse(text)
|
|
843
|
+
}
|
|
735
844
|
/** Session-only API key storage avoids durable browser storage exposure. */
|
|
736
845
|
const readApiKey = () => {
|
|
737
846
|
const legacy = typeof localStorage !== 'undefined' ? (localStorage.getItem(AI_API_KEY_KEY) ?? '').trim() : ''
|
|
@@ -788,13 +897,14 @@ window.__ModuleLoader__.load({
|
|
|
788
897
|
const SEARCH_SHORTCUT_EVENT = 'dsh-settings-search:shortcut-change'
|
|
789
898
|
/**
|
|
790
899
|
* Persisted index schema. Bumped whenever the harvest gains entries an old
|
|
791
|
-
* cache cannot contain (
|
|
792
|
-
* so an upgrade rebuilds instead of
|
|
900
|
+
* cache cannot contain (v3: the settings-search page, merged inline options,
|
|
901
|
+
* and controls revealed by plugin cards), so an upgrade rebuilds instead of
|
|
902
|
+
* serving a stale index.
|
|
793
903
|
*/
|
|
794
|
-
const INDEX_STORAGE_VERSION =
|
|
795
|
-
const INDEX_STORAGE_KEY = 'dsh-settings-search:index:
|
|
904
|
+
const INDEX_STORAGE_VERSION = 3
|
|
905
|
+
const INDEX_STORAGE_KEY = 'dsh-settings-search:index:v3'
|
|
796
906
|
/** Older schema keys, removed when the index is written again. */
|
|
797
|
-
const LEGACY_INDEX_STORAGE_KEYS = ['dsh-settings-search:index:v1']
|
|
907
|
+
const LEGACY_INDEX_STORAGE_KEYS = ['dsh-settings-search:index:v2', 'dsh-settings-search:index:v1']
|
|
798
908
|
const INDEX_INVALID_EVENT = 'dsh-settings-search:index-invalid'
|
|
799
909
|
const INDEX_REBUILD_EVENT = 'dsh-settings-search:index-rebuild'
|
|
800
910
|
const INDEX_REBUILD_DONE_EVENT = 'dsh-settings-search:index-rebuild-done'
|
|
@@ -958,10 +1068,7 @@ window.__ModuleLoader__.load({
|
|
|
958
1068
|
error.status = response.status
|
|
959
1069
|
throw error
|
|
960
1070
|
}
|
|
961
|
-
const data = await response
|
|
962
|
-
if (error.name === 'AbortError') throw error
|
|
963
|
-
return null
|
|
964
|
-
})
|
|
1071
|
+
const data = await readAiJson(response)
|
|
965
1072
|
if (data?.error) {
|
|
966
1073
|
const error = new Error('Unexpected model list response')
|
|
967
1074
|
error.name = 'AI_BAD_RESPONSE'
|
|
@@ -1048,10 +1155,7 @@ window.__ModuleLoader__.load({
|
|
|
1048
1155
|
error.status = response.status
|
|
1049
1156
|
throw error
|
|
1050
1157
|
}
|
|
1051
|
-
const data = await response
|
|
1052
|
-
if (error.name === 'AbortError') throw error
|
|
1053
|
-
return null
|
|
1054
|
-
})
|
|
1158
|
+
const data = await readAiJson(response)
|
|
1055
1159
|
if (data?.error || typeof aiResponseContent(provider, data) !== 'string') {
|
|
1056
1160
|
const error = new Error('Unexpected AI response')
|
|
1057
1161
|
error.name = 'AI_BAD_RESPONSE'
|
|
@@ -1479,7 +1583,7 @@ window.__ModuleLoader__.load({
|
|
|
1479
1583
|
// Structural headings go first: the Plugins page can render dozens
|
|
1480
1584
|
// of card titles before its next group title, and `out` is capped
|
|
1481
1585
|
// below. Shallower matches are the page/group headings.
|
|
1482
|
-
const heads = [...node.querySelectorAll('h1,h2,h3,h4,h5,label,[class*="title" i],[class*="heading" i],[class*="label" i]')]
|
|
1586
|
+
const heads = [...node.querySelectorAll('h1,h2,h3,h4,h5,label,li,[role="listitem"],[class*="title" i],[class*="heading" i],[class*="label" i]')]
|
|
1483
1587
|
.filter((el) => !isPageFilter(el))
|
|
1484
1588
|
.sort((a, b) => depthOf(a) - depthOf(b))
|
|
1485
1589
|
for (const h of heads) {
|
|
@@ -1497,12 +1601,28 @@ window.__ModuleLoader__.load({
|
|
|
1497
1601
|
push(bestTitle(row), row)
|
|
1498
1602
|
}
|
|
1499
1603
|
for (const h of node.querySelectorAll('[class*="name" i]')) push(bestTitle(h) || titleOf(h), h)
|
|
1500
|
-
return out.slice(0,
|
|
1604
|
+
return out.slice(0, 64)
|
|
1501
1605
|
} catch {
|
|
1502
1606
|
return []
|
|
1503
1607
|
}
|
|
1504
1608
|
}
|
|
1505
1609
|
|
|
1610
|
+
/**
|
|
1611
|
+
* Keep options discovered while visiting other tabs/cards and merge any
|
|
1612
|
+
* controls revealed by a later DOM pass. Replacing the list would either
|
|
1613
|
+
* forget previously visited tabs or fail to add a freshly expanded card.
|
|
1614
|
+
*/
|
|
1615
|
+
const mergeInnerOptions = (previous, incoming) => {
|
|
1616
|
+
const merged = []
|
|
1617
|
+
const seen = new Set()
|
|
1618
|
+
for (const option of [...(Array.isArray(incoming) ? incoming : []), ...(Array.isArray(previous) ? previous : [])]) {
|
|
1619
|
+
if (!option || typeof option.label !== 'string' || option.label === '' || seen.has(option.label)) continue
|
|
1620
|
+
seen.add(option.label)
|
|
1621
|
+
merged.push(option)
|
|
1622
|
+
}
|
|
1623
|
+
return merged
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1506
1626
|
|
|
1507
1627
|
const runHarvest = () => {
|
|
1508
1628
|
let grew = false
|
|
@@ -1566,10 +1686,17 @@ window.__ModuleLoader__.load({
|
|
|
1566
1686
|
if (!rec) {
|
|
1567
1687
|
map.set(active.id, { title: active.label, idx: 0, text, inner })
|
|
1568
1688
|
grew = true
|
|
1569
|
-
} else
|
|
1570
|
-
rec.inner
|
|
1571
|
-
rec.text
|
|
1572
|
-
|
|
1689
|
+
} else {
|
|
1690
|
+
const mergedInner = mergeInnerOptions(rec.inner, inner)
|
|
1691
|
+
const nextText = text.length > (rec.text?.length ?? 0) ? text : rec.text
|
|
1692
|
+
if (JSON.stringify(mergedInner) !== JSON.stringify(rec.inner ?? []) ||
|
|
1693
|
+
nextText !== rec.text ||
|
|
1694
|
+
rec.title !== active.label) {
|
|
1695
|
+
rec.title = active.label
|
|
1696
|
+
rec.inner = mergedInner
|
|
1697
|
+
rec.text = nextText
|
|
1698
|
+
grew = true
|
|
1699
|
+
}
|
|
1573
1700
|
}
|
|
1574
1701
|
}
|
|
1575
1702
|
}
|
|
@@ -1596,7 +1723,6 @@ window.__ModuleLoader__.load({
|
|
|
1596
1723
|
}
|
|
1597
1724
|
|
|
1598
1725
|
const readSections = () => sortEntries(entriesOf('settings.section'))
|
|
1599
|
-
.filter((e) => e.options?.id !== 'settings-search')
|
|
1600
1726
|
.map((e) => ({
|
|
1601
1727
|
kind: 'page',
|
|
1602
1728
|
id: String(e.options?.id ?? ''),
|
|
@@ -1801,6 +1927,40 @@ window.__ModuleLoader__.load({
|
|
|
1801
1927
|
return true
|
|
1802
1928
|
}
|
|
1803
1929
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
1930
|
+
const settingSectionRoot = () => {
|
|
1931
|
+
const root = panelRoot()
|
|
1932
|
+
if (root === null) return null
|
|
1933
|
+
return root.querySelector('[data-slot="settings.section"]')
|
|
1934
|
+
}
|
|
1935
|
+
/**
|
|
1936
|
+
* DSH plugin settings are often rendered inside collapsed disclosure cards.
|
|
1937
|
+
* Their controls do not exist in the DOM until the card is expanded, so the
|
|
1938
|
+
* indexer must reveal them before harvesting.
|
|
1939
|
+
*/
|
|
1940
|
+
const collapsedSettingCards = () => {
|
|
1941
|
+
const section = settingSectionRoot()
|
|
1942
|
+
if (section === null) return []
|
|
1943
|
+
return [...section.querySelectorAll('button[aria-expanded="false"]')].filter((button) => {
|
|
1944
|
+
const label = `${button.getAttribute?.('aria-label') ?? ''} ${button.textContent ?? ''}`
|
|
1945
|
+
return /展开|expand/i.test(label)
|
|
1946
|
+
})
|
|
1947
|
+
}
|
|
1948
|
+
const expandSettingCards = async () => {
|
|
1949
|
+
const buttons = collapsedSettingCards()
|
|
1950
|
+
for (const button of buttons) {
|
|
1951
|
+
button.click()
|
|
1952
|
+
await delay(90)
|
|
1953
|
+
}
|
|
1954
|
+
return buttons
|
|
1955
|
+
}
|
|
1956
|
+
const restoreSettingCards = async (buttons) => {
|
|
1957
|
+
for (const button of [...buttons].reverse()) {
|
|
1958
|
+
if (button?.isConnected !== false && button?.getAttribute?.('aria-expanded') === 'true') {
|
|
1959
|
+
button.click()
|
|
1960
|
+
await delay(60)
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1804
1964
|
/** Label of the currently-active first-level settings nav item. */
|
|
1805
1965
|
const activeSectionLabel = () => {
|
|
1806
1966
|
const root = panelRoot()
|
|
@@ -1831,7 +1991,9 @@ window.__ModuleLoader__.load({
|
|
|
1831
1991
|
for (const sec of sections.slice(0, 40)) {
|
|
1832
1992
|
if (!clickNavButton(sec.label)) continue
|
|
1833
1993
|
await delay(180)
|
|
1994
|
+
const expanded = await expandSettingCards()
|
|
1834
1995
|
model.runHarvest()
|
|
1996
|
+
await restoreSettingCards(expanded)
|
|
1835
1997
|
visited += 1
|
|
1836
1998
|
}
|
|
1837
1999
|
if (clickNavButton(original)) {
|
|
@@ -1848,20 +2010,44 @@ window.__ModuleLoader__.load({
|
|
|
1848
2010
|
}
|
|
1849
2011
|
|
|
1850
2012
|
function createNavigator(ctx) {
|
|
2013
|
+
const waitForActiveSection = async (label) => {
|
|
2014
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
2015
|
+
if (activeSectionLabel() === label) return true
|
|
2016
|
+
await delay(80)
|
|
2017
|
+
}
|
|
2018
|
+
return false
|
|
2019
|
+
}
|
|
1851
2020
|
/** Jump to a page result; falls back to showing a manual hint. */
|
|
1852
|
-
const jumpToPage = async (item) =>
|
|
2021
|
+
const jumpToPage = async (item) => {
|
|
2022
|
+
if (!clickNavButton(item.label)) return false
|
|
2023
|
+
return waitForActiveSection(String(item.label ?? '').trim())
|
|
2024
|
+
}
|
|
1853
2025
|
/** Jump to an in-page option: open its page, select its tab, flash its row. */
|
|
1854
2026
|
const jumpToOption = async (item) => {
|
|
1855
|
-
const
|
|
1856
|
-
if (
|
|
1857
|
-
await
|
|
2027
|
+
const pageLabel = String(item.pageLabel ?? item.parentLabel ?? '').trim()
|
|
2028
|
+
if (!clickNavButton(pageLabel)) return false
|
|
2029
|
+
if (!await waitForActiveSection(pageLabel)) return false
|
|
1858
2030
|
const label = item.label
|
|
1859
2031
|
const buttons = panelButtons()
|
|
1860
2032
|
const tab = buttons.find((b) =>
|
|
1861
2033
|
b.getAttribute('role') === 'tab' && (b.textContent ?? '').trim() === label)
|
|
1862
2034
|
if (tab) { tab.click(); await delay(120) }
|
|
1863
|
-
|
|
1864
|
-
|
|
2035
|
+
const waitForRow = async () => {
|
|
2036
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
2037
|
+
if (flashOptionRow(item)) return true
|
|
2038
|
+
await delay(80)
|
|
2039
|
+
}
|
|
2040
|
+
return false
|
|
2041
|
+
}
|
|
2042
|
+
if (await waitForRow()) return true
|
|
2043
|
+
// Plugin settings can live inside a collapsed disclosure card. Reveal
|
|
2044
|
+
// cards one at a time until the indexed row exists, then flash it.
|
|
2045
|
+
for (const button of collapsedSettingCards()) {
|
|
2046
|
+
button.click()
|
|
2047
|
+
await delay(110)
|
|
2048
|
+
if (await waitForRow()) return true
|
|
2049
|
+
}
|
|
2050
|
+
return false
|
|
1865
2051
|
}
|
|
1866
2052
|
/** Highlight the rendered row card for one indexed option. */
|
|
1867
2053
|
const flashOptionRow = (item) => {
|
|
@@ -1896,13 +2082,16 @@ window.__ModuleLoader__.load({
|
|
|
1896
2082
|
target = card
|
|
1897
2083
|
}
|
|
1898
2084
|
}
|
|
1899
|
-
if (!target) return
|
|
2085
|
+
if (!target) return false
|
|
1900
2086
|
target.scrollIntoView({ block: 'nearest' })
|
|
1901
2087
|
target.classList.remove('sss-flash')
|
|
1902
2088
|
void target.offsetWidth // restart animation if re-flashed
|
|
1903
2089
|
target.classList.add('sss-flash')
|
|
1904
2090
|
setTimeout(() => target.classList.remove('sss-flash'), 1900)
|
|
1905
|
-
|
|
2091
|
+
return true
|
|
2092
|
+
} catch {
|
|
2093
|
+
return false
|
|
2094
|
+
}
|
|
1906
2095
|
}
|
|
1907
2096
|
void ctx
|
|
1908
2097
|
return { jumpToPage, jumpToOption }
|
|
@@ -2388,29 +2577,6 @@ window.__ModuleLoader__.load({
|
|
|
2388
2577
|
const sections = model.readSections()
|
|
2389
2578
|
const deeps = q !== '' ? model.readDeep() : []
|
|
2390
2579
|
const expandedKeywords = new Map(tokens.map((tok) => [tok, intentKeywords(tok)]))
|
|
2391
|
-
const directMatch = (item, haystackLower) =>
|
|
2392
|
-
tokens.every((tok) => tokenMatch(haystackLower, item.keys, tok) !== null)
|
|
2393
|
-
const kwMatch = (item, haystackLower, kw) => {
|
|
2394
|
-
if (/^[\x00-\x7f]+$/.test(kw)) {
|
|
2395
|
-
if (wordBoundaryRegex(kw).test(haystackLower)) return true
|
|
2396
|
-
} else if (haystackLower.includes(kw)) return true
|
|
2397
|
-
if (item.keys === null) return false
|
|
2398
|
-
const kwKeys = pinyinKeys(kw)
|
|
2399
|
-
if (kwKeys === null) return false
|
|
2400
|
-
return item.keys.full.includes(kwKeys.full) || item.keys.initials.startsWith(kwKeys.initials)
|
|
2401
|
-
}
|
|
2402
|
-
const relatedKeyword = (item, haystackLower) => {
|
|
2403
|
-
if (directMatch(item, haystackLower)) return null
|
|
2404
|
-
for (const tok of tokens) {
|
|
2405
|
-
for (const kw of expandedKeywords.get(tok) ?? []) {
|
|
2406
|
-
if (kwMatch(item, haystackLower, kw)) return kw
|
|
2407
|
-
}
|
|
2408
|
-
}
|
|
2409
|
-
for (const kw of aiKeywords) {
|
|
2410
|
-
if (kwMatch(item, haystackLower, kw)) return `AI · ${kw}`
|
|
2411
|
-
}
|
|
2412
|
-
return null
|
|
2413
|
-
}
|
|
2414
2580
|
const mappedSections = q === '' ? [] : sections.map((s) => ({
|
|
2415
2581
|
...s,
|
|
2416
2582
|
keys: pinyinKeys(s.label),
|
|
@@ -2421,24 +2587,34 @@ window.__ModuleLoader__.load({
|
|
|
2421
2587
|
keys: pinyinKeys(`${o.label} ${o.parentLabel}`),
|
|
2422
2588
|
hay: `${o.hay} ${o.parentLabel} ${o.id} ${o.slot}`.toLowerCase(),
|
|
2423
2589
|
}))
|
|
2424
|
-
const shownPages = mappedSections.filter((s) => directMatch(s, s.hay))
|
|
2425
|
-
const shownOptions = mappedOptions.filter((o) => directMatch(o, o.hay))
|
|
2426
|
-
const
|
|
2427
|
-
tokens.every((tok) => (expandedKeywords.get(tok) ?? []).length === 0)
|
|
2428
|
-
const related = [
|
|
2590
|
+
const shownPages = mappedSections.filter((s) => directMatch(s, s.hay, tokens))
|
|
2591
|
+
const shownOptions = mappedOptions.filter((o) => directMatch(o, o.hay, tokens))
|
|
2592
|
+
const intentRelated = [
|
|
2429
2593
|
...mappedSections
|
|
2430
|
-
.map((s) => ({ item: s, kind: 'page', via:
|
|
2594
|
+
.map((s) => ({ item: s, kind: 'page', via: localRelatedKeyword(s, s.hay, tokens, expandedKeywords) }))
|
|
2431
2595
|
.filter((r) => r.via !== null),
|
|
2432
2596
|
...mappedOptions
|
|
2433
|
-
.map((o) => ({ item: o, kind: 'option', via:
|
|
2597
|
+
.map((o) => ({ item: o, kind: 'option', via: localRelatedKeyword(o, o.hay, tokens, expandedKeywords) }))
|
|
2434
2598
|
.filter((r) => r.via !== null),
|
|
2435
|
-
]
|
|
2599
|
+
]
|
|
2600
|
+
const localResultsEmpty = shownPages.length === 0 && shownOptions.length === 0 && intentRelated.length === 0
|
|
2601
|
+
const aiRelated = localResultsEmpty
|
|
2602
|
+
? [
|
|
2603
|
+
...mappedSections
|
|
2604
|
+
.map((s) => ({ item: s, kind: 'page', via: aiRelatedKeyword(s, s.hay, aiKeywords) }))
|
|
2605
|
+
.filter((r) => r.via !== null),
|
|
2606
|
+
...mappedOptions
|
|
2607
|
+
.map((o) => ({ item: o, kind: 'option', via: aiRelatedKeyword(o, o.hay, aiKeywords) }))
|
|
2608
|
+
.filter((r) => r.via !== null),
|
|
2609
|
+
]
|
|
2610
|
+
: []
|
|
2611
|
+
const related = [...intentRelated, ...aiRelated].slice(0, 8)
|
|
2436
2612
|
const none = q !== '' && shownPages.length === 0 && shownOptions.length === 0 && related.length === 0
|
|
2437
2613
|
const results = [...shownPages, ...shownOptions, ...related.map((r) => r.item)]
|
|
2438
2614
|
|
|
2439
2615
|
const cfg = aiConfig()
|
|
2440
2616
|
const aiConfigured = aiConfigReady(cfg)
|
|
2441
|
-
const aiReady = aiEnabled && aiConfigured && q.length >= 2 &&
|
|
2617
|
+
const aiReady = aiEnabled && aiConfigured && q.length >= 2 && localResultsEmpty
|
|
2442
2618
|
React.useEffect(() => {
|
|
2443
2619
|
setAiKeywords([])
|
|
2444
2620
|
setAiStatus('idle')
|
package/lib/index.js
CHANGED
|
@@ -60,6 +60,7 @@ const TAIL_BYTES = 256 * 1024
|
|
|
60
60
|
const MAX_LINE_LENGTH = 1000
|
|
61
61
|
const MAX_LINES_PER_REQUEST = 200
|
|
62
62
|
const MAX_REQUEST_BYTES = 256 * 1024
|
|
63
|
+
const COUNT_CHUNK_BYTES = 64 * 1024
|
|
63
64
|
/** How long a folder launcher may run before it counts as launched. */
|
|
64
65
|
const LAUNCH_WATCH_MS = 1500
|
|
65
66
|
|
|
@@ -262,14 +263,27 @@ export function createLogStore(options = {}) {
|
|
|
262
263
|
}
|
|
263
264
|
}
|
|
264
265
|
const countLines = (path) => {
|
|
266
|
+
let fd
|
|
265
267
|
try {
|
|
266
268
|
if (!existsSync(path)) return 0
|
|
267
|
-
|
|
269
|
+
fd = io.openSync(path, 'r')
|
|
270
|
+
const buffer = Buffer.alloc(COUNT_CHUNK_BYTES)
|
|
268
271
|
let lines = 0
|
|
269
|
-
|
|
272
|
+
let position = 0
|
|
273
|
+
while (true) {
|
|
274
|
+
const readBytes = io.readSync(fd, buffer, 0, buffer.length, position)
|
|
275
|
+
if (!Number.isFinite(readBytes) || readBytes <= 0) break
|
|
276
|
+
const filled = Math.min(readBytes, buffer.length)
|
|
277
|
+
for (let i = 0; i < filled; i += 1) if (buffer[i] === 10) lines += 1
|
|
278
|
+
position += filled
|
|
279
|
+
}
|
|
270
280
|
return lines
|
|
271
281
|
} catch {
|
|
272
282
|
return 0
|
|
283
|
+
} finally {
|
|
284
|
+
if (fd !== undefined) {
|
|
285
|
+
try { io.closeSync(fd) } catch {}
|
|
286
|
+
}
|
|
273
287
|
}
|
|
274
288
|
}
|
|
275
289
|
// The host is a file's only writer, so its entry count is tracked in memory
|
|
@@ -379,11 +393,36 @@ export function createLogStore(options = {}) {
|
|
|
379
393
|
}
|
|
380
394
|
}
|
|
381
395
|
|
|
396
|
+
/** Read one request body without buffering past the route's size budget. */
|
|
397
|
+
const readLimitedBody = async (request) => {
|
|
398
|
+
if (request.body === null) return ''
|
|
399
|
+
const reader = request.body.getReader()
|
|
400
|
+
const chunks = []
|
|
401
|
+
let bytes = 0
|
|
402
|
+
try {
|
|
403
|
+
while (true) {
|
|
404
|
+
const { done, value } = await reader.read()
|
|
405
|
+
if (done) return Buffer.concat(chunks, bytes).toString('utf8')
|
|
406
|
+
if (!(value instanceof Uint8Array)) return null
|
|
407
|
+
bytes += value.byteLength
|
|
408
|
+
if (bytes > MAX_REQUEST_BYTES) {
|
|
409
|
+
try { await reader.cancel() } catch {}
|
|
410
|
+
return null
|
|
411
|
+
}
|
|
412
|
+
chunks.push(value)
|
|
413
|
+
}
|
|
414
|
+
} catch {
|
|
415
|
+
return null
|
|
416
|
+
} finally {
|
|
417
|
+
try { reader.releaseLock() } catch {}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
382
421
|
/** Parse one JSON request body within the route's own size budget. */
|
|
383
422
|
const readJsonBody = async (request) => {
|
|
384
423
|
try {
|
|
385
|
-
const text = await request
|
|
386
|
-
if (text ===
|
|
424
|
+
const text = await readLimitedBody(request)
|
|
425
|
+
if (text === null || text === '') return null
|
|
387
426
|
const parsed = JSON.parse(text)
|
|
388
427
|
return parsed !== null && typeof parsed === 'object' ? parsed : null
|
|
389
428
|
} catch {
|
|
@@ -405,7 +444,7 @@ export function createLogRoute(store) {
|
|
|
405
444
|
return {
|
|
406
445
|
path: ROUTE_PATH,
|
|
407
446
|
methods: ['GET', 'POST'],
|
|
408
|
-
requestBody: '
|
|
447
|
+
requestBody: 'streaming',
|
|
409
448
|
fetch: async (request) => {
|
|
410
449
|
try {
|
|
411
450
|
if (request.method === 'GET') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectivex666/dsh-settings-search",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.1",
|
|
4
4
|
"description": "DSH settings panel search plugin – instantly filter setting sections and general items with navigation guidance. Installable via `dsh plugin add`.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
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' }
|
|
@@ -151,9 +159,15 @@ const pluginsPageDom = () => {
|
|
|
151
159
|
el('span', { class: 'RotMhW_visuallyHidden', text: '搜索插件' }),
|
|
152
160
|
el('input', { attr: { type: 'search' } }),
|
|
153
161
|
]),
|
|
154
|
-
|
|
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
|
|
155
169
|
// truncate the second group title before seeing it.
|
|
156
|
-
group('preset', '会话插件',
|
|
170
|
+
group('preset', '会话插件', 70),
|
|
157
171
|
group('global', '全局插件'),
|
|
158
172
|
]),
|
|
159
173
|
])
|
|
@@ -191,6 +205,8 @@ function load(fetchImpl = async () => json(reply('ollama'))) {
|
|
|
191
205
|
CustomEvent: class { constructor(type) { this.type = type } },
|
|
192
206
|
URL,
|
|
193
207
|
AbortController,
|
|
208
|
+
TextEncoder,
|
|
209
|
+
TextDecoder,
|
|
194
210
|
setTimeout,
|
|
195
211
|
clearTimeout,
|
|
196
212
|
fetch: async (url, options) => {
|
|
@@ -214,8 +230,8 @@ test('persistent settings index reuses matching fingerprints and drops invalid r
|
|
|
214
230
|
},
|
|
215
231
|
}
|
|
216
232
|
assert.equal(Object.keys(api.serializeHarvest(new Map())).length, 0)
|
|
217
|
-
sandbox.localStorage.setItem('dsh-settings-search:index:
|
|
218
|
-
version:
|
|
233
|
+
sandbox.localStorage.setItem('dsh-settings-search:index:v3', JSON.stringify({
|
|
234
|
+
version: 3,
|
|
219
235
|
fingerprint: 'manager:test',
|
|
220
236
|
updatedAt: 1,
|
|
221
237
|
harvest: records,
|
|
@@ -255,9 +271,10 @@ test('persistent settings index reuses matching fingerprints and drops invalid r
|
|
|
255
271
|
|
|
256
272
|
test('readIndexCache rejects malformed and legacy persisted index payloads', () => {
|
|
257
273
|
const { api, sandbox } = load()
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
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)
|
|
261
278
|
assert.equal(api.readIndexCache(), null)
|
|
262
279
|
}
|
|
263
280
|
})
|
|
@@ -267,6 +284,7 @@ test('writing the index uses the current schema and drops the legacy cache key',
|
|
|
267
284
|
const records = { 'settings.general.item': { row1: { title: 'Row', idx: 0, text: '', inner: [] } } }
|
|
268
285
|
sandbox.localStorage.setItem('dsh-settings-search:index:v1', JSON.stringify({ version: 1, fingerprint: 'manager:old', updatedAt: 1, harvest: records }))
|
|
269
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 }))
|
|
270
288
|
api.apply({
|
|
271
289
|
effect: (fn) => fn(),
|
|
272
290
|
get: () => null,
|
|
@@ -282,8 +300,9 @@ test('writing the index uses the current schema and drops the legacy cache key',
|
|
|
282
300
|
const model = api.__indexTestModel
|
|
283
301
|
assert.equal(model.persistIndex('manager:next'), true)
|
|
284
302
|
assert.equal(sandbox.localStorage.getItem('dsh-settings-search:index:v1'), null)
|
|
285
|
-
|
|
286
|
-
|
|
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)
|
|
287
306
|
assert.equal(raw.fingerprint, 'manager:next')
|
|
288
307
|
})
|
|
289
308
|
|
|
@@ -299,6 +318,7 @@ test('in-page group titles on the Plugins page are harvested and searchable', ()
|
|
|
299
318
|
const labels = options.map((item) => item.label)
|
|
300
319
|
assert.ok(labels.includes('会话插件'), `会话插件 missing from ${JSON.stringify(labels)}`)
|
|
301
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)}`)
|
|
302
322
|
for (const label of ['会话插件', '全局插件']) {
|
|
303
323
|
const option = options.find((item) => item.label === label)
|
|
304
324
|
assert.equal(option.kind, 'option')
|
|
@@ -310,6 +330,95 @@ test('in-page group titles on the Plugins page are harvested and searchable', ()
|
|
|
310
330
|
assert.ok(!labels.includes('搜索插件'), `the page filter leaked into the index: ${JSON.stringify(labels)}`)
|
|
311
331
|
})
|
|
312
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
|
+
|
|
313
422
|
test('Ollama configuration persists and requires a model but no key', () => {
|
|
314
423
|
const { api, sandbox } = load()
|
|
315
424
|
api.setAiConfig({ ...ollama, model: ' local-model:7b ' })
|
|
@@ -491,10 +600,52 @@ test('malformed responses and native error payloads cannot pass connectivity che
|
|
|
491
600
|
const { api } = load(async () => json(data))
|
|
492
601
|
assert.equal((await api.testAiService(ollama)).code, 'bad-response')
|
|
493
602
|
}
|
|
494
|
-
const { api } = load(async () => ({ ok: true,
|
|
603
|
+
const { api } = load(async () => ({ ok: true, status: 200, headers: { get: () => null }, body: null, text: async () => '{' }))
|
|
495
604
|
assert.equal((await api.testAiService(ollama)).code, 'bad-response')
|
|
496
605
|
})
|
|
497
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
|
+
|
|
498
649
|
test('network failure is reported without claiming connection success', async () => {
|
|
499
650
|
const { api } = load(async () => { throw new TypeError('Failed to fetch') })
|
|
500
651
|
assert.equal((await api.testAiService(ollama)).code, 'network')
|
package/scripts/compat.mjs
CHANGED
|
@@ -122,6 +122,15 @@ const mustInclude = (text, needle, message) => {
|
|
|
122
122
|
if (!text.includes(needle)) throw new Error(message ?? `missing ${JSON.stringify(needle)}`)
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Assert one pattern. Bundled client code is minified and re-emitted by every
|
|
127
|
+
* release, so it may quote a string with `"`, `'`, or a template literal
|
|
128
|
+
* without any behavioural change — match the code, not the quoting.
|
|
129
|
+
*/
|
|
130
|
+
const mustMatch = (text, pattern, message) => {
|
|
131
|
+
if (!pattern.test(text)) throw new Error(message ?? `missing ${pattern}`)
|
|
132
|
+
}
|
|
133
|
+
|
|
125
134
|
const main = () => {
|
|
126
135
|
const archivePath = resolveAsar()
|
|
127
136
|
const archive = openAsar(archivePath)
|
|
@@ -133,13 +142,17 @@ const main = () => {
|
|
|
133
142
|
console.log(`Version: ${connectionManifest.version}\n`)
|
|
134
143
|
|
|
135
144
|
check('client connection service is provided', () => {
|
|
136
|
-
|
|
145
|
+
mustMatch(
|
|
146
|
+
clientSourceOf(archive, 'dsh-client-connection', cache),
|
|
147
|
+
/provide\(\s*(['"`])connection\1/,
|
|
148
|
+
'the connection handle is gone',
|
|
149
|
+
)
|
|
137
150
|
return 'ctx.connection'
|
|
138
151
|
})
|
|
139
152
|
|
|
140
153
|
check('Remote namespaces live at remote.<namespace>', () => {
|
|
141
154
|
const gateway = clientSourceOf(archive, 'dsh-api-gateway', cache)
|
|
142
|
-
|
|
155
|
+
mustMatch(gateway, /super\(\s*ctx\s*,\s*(['"`])remote\1\s*\)/, 'the `remote` service is gone')
|
|
143
156
|
mustInclude(gateway, 'remote.${namespace}', 'namespace service keys changed')
|
|
144
157
|
return 'remote.<ns>'
|
|
145
158
|
})
|
|
@@ -181,6 +194,7 @@ const main = () => {
|
|
|
181
194
|
check('host connection still owns the exact-Fetch route registry', () => {
|
|
182
195
|
const host = archive.read('dsh/node_modules/@deepseek-ai/dsh-client-connection/lib/index.js')
|
|
183
196
|
mustInclude(host, 'registerFetchRoute', 'the host fetch registry is gone')
|
|
197
|
+
mustInclude(host, 'bodyMode === "streaming"', 'streaming exact routes are no longer supported')
|
|
184
198
|
const segment = /const ENDPOINT_SEGMENT_PATTERN = \/\^\[([^\]]+)\]/.exec(host)
|
|
185
199
|
if (segment === null) throw new Error('endpoint validation pattern not found')
|
|
186
200
|
const sample = 'settings-search-log'
|
package/scripts/log.test.mjs
CHANGED
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
* that never deletes the active file, and a tail that never starts mid-line.
|
|
9
9
|
*/
|
|
10
10
|
import assert from 'node:assert/strict'
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
closeSync, existsSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync,
|
|
13
|
+
rmSync, statSync, writeFileSync,
|
|
14
|
+
} from 'node:fs'
|
|
12
15
|
import { basename, join } from 'node:path'
|
|
13
16
|
import { tmpdir } from 'node:os'
|
|
14
17
|
import test from 'node:test'
|
|
@@ -228,6 +231,31 @@ test('a fresh store reads the count of a file written by an earlier run', () =>
|
|
|
228
231
|
}
|
|
229
232
|
})
|
|
230
233
|
|
|
234
|
+
test('a persisted log is counted in bounded chunks instead of one large allocation', () => {
|
|
235
|
+
const { dir, cleanup } = withStore({ now: () => day('2026-10-06') })
|
|
236
|
+
try {
|
|
237
|
+
const file = join(dir, 'settings-search-2026-10-06.log')
|
|
238
|
+
writeFileSync(file, 'entry\n'.repeat(20000), 'utf8')
|
|
239
|
+
let largestRequest = 0
|
|
240
|
+
let reads = 0
|
|
241
|
+
const io = {
|
|
242
|
+
openSync,
|
|
243
|
+
closeSync,
|
|
244
|
+
readSync(fd, buffer, offset, length, position) {
|
|
245
|
+
largestRequest = Math.max(largestRequest, length)
|
|
246
|
+
reads += 1
|
|
247
|
+
return readSync(fd, buffer, offset, length, position)
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
const reopened = createLogStore({ dir, now: () => day('2026-10-06'), io })
|
|
251
|
+
assert.equal(reopened.count(), 20000)
|
|
252
|
+
assert.ok(largestRequest <= 64 * 1024, `requested ${largestRequest} bytes`)
|
|
253
|
+
assert.ok(reads > 1, 'the file must be streamed across multiple reads')
|
|
254
|
+
} finally {
|
|
255
|
+
cleanup()
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
|
|
231
259
|
const send = async (route, method, body, query = '') => {
|
|
232
260
|
const request = new Request(`http://dsh.internal${route.path}${query}`, {
|
|
233
261
|
method,
|
|
@@ -268,7 +296,7 @@ test('the log route serves the tail and accepts append/clear only', async () =>
|
|
|
268
296
|
try {
|
|
269
297
|
assert.equal(route.path, '/api/settings-search-log')
|
|
270
298
|
assert.deepEqual(route.methods, ['GET', 'POST'])
|
|
271
|
-
assert.equal(route.requestBody, '
|
|
299
|
+
assert.equal(route.requestBody, 'streaming')
|
|
272
300
|
|
|
273
301
|
const appended = await send(route, 'POST', { action: 'append', lines: ['hello'] })
|
|
274
302
|
assert.equal(appended.status, 200)
|
|
@@ -290,6 +318,28 @@ test('the log route serves the tail and accepts append/clear only', async () =>
|
|
|
290
318
|
}
|
|
291
319
|
})
|
|
292
320
|
|
|
321
|
+
test('an over-budget request body is rejected before any action runs', async () => {
|
|
322
|
+
let actions = 0
|
|
323
|
+
const route = createLogRoute({
|
|
324
|
+
dir: 'unused',
|
|
325
|
+
file: 'unused',
|
|
326
|
+
ensure() {},
|
|
327
|
+
append() { actions += 1; return 0 },
|
|
328
|
+
count() { actions += 1; return 0 },
|
|
329
|
+
clear() { actions += 1; return true },
|
|
330
|
+
open() { actions += 1; return { failure: undefined, command: 'test' } },
|
|
331
|
+
read() { actions += 1; return { text: '', size: 0, truncated: false } },
|
|
332
|
+
})
|
|
333
|
+
const request = new Request('http://dsh.internal/api/settings-search-log', {
|
|
334
|
+
method: 'POST',
|
|
335
|
+
headers: { 'content-type': 'application/json' },
|
|
336
|
+
body: JSON.stringify({ action: 'append', lines: ['x'.repeat(257 * 1024)] }),
|
|
337
|
+
})
|
|
338
|
+
const response = await route.fetch(request)
|
|
339
|
+
assert.equal(response.status, 400)
|
|
340
|
+
assert.equal(actions, 0)
|
|
341
|
+
})
|
|
342
|
+
|
|
293
343
|
test('the folder launcher uses the OS shell open verb, never a hidden file manager', () => {
|
|
294
344
|
const win = folderLaunchCommand('win32', 'C:\\logs\\dsh-settings-search')
|
|
295
345
|
assert.equal(win.command, 'cmd.exe')
|