@objectivex666/dsh-settings-search 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -13,8 +13,10 @@
13
13
 
14
14
  ## Features
15
15
 
16
- - 🔍 **Live search** — a search box at the top of the settings panel's left navigation filters every registered settings page (`settings.section`) and general item (`settings.general.item`) as you type.
17
- - 🎯 **First-run onboarding (v1.8.0)** — a welcome modal introduces live search, pinyin/intent/AI hints, click-to-jump, and auto-indexing when the settings panel first opens; reopen it anytime from the "Settings Search" page.
16
+ - 🔍 **Live search** — a search box in the settings panel filters every registered settings page (`settings.section`) and general item (`settings.general.item`) as you type.
17
+ - 🧩 **Four search styles (v1.9.0)** — choose the classic sidebar, window top bar, popup search page, or a mixed window top bar plus popup page layout. The choice is saved in the current browser.
18
+ - 🤝 **Import DSH model config (v1.9.0)** — import the Base URL and model name from DSH's current default model in the AI config section. For credential safety, the API key is never imported.
19
+ - 🎯 **First-run onboarding (v1.9.0)** — a welcome modal appears only the first time the settings panel opens after installation. It introduces the four search styles, pinyin/intent/AI hints, click-to-jump, and auto-indexing; there are no "Don't show again" or manual reopen buttons.
18
20
  - 📂 **In-page option search (v1.2.0)** — search beyond pages and reach the specific options inside them: Plugins-page tabs (`settings.plugins.tab`), Web UI plugin cards (`web-ui.plugin.item`), general rows, and more, each shown with a "page › option" breadcrumb.
19
21
  - 🔤 **Pinyin association search (v1.5.0)** — type initials (`sz`) or full pinyin (`shezhi`) to surface Chinese settings items; navigate with `↑`/`↓`, confirm with `Enter`, dismiss with `Esc`, and matched text is highlighted.
20
22
  - 🧠 **Intent search (v1.6.0)** — describe the goal in Chinese (e.g. 「太亮了」 "too bright", 「字太小」 "text too small") to surface matching settings without knowing their names, shown in a "You may want" group.
package/README.md CHANGED
@@ -13,8 +13,10 @@
13
13
 
14
14
  ## 功能特性
15
15
 
16
- - 🔍 **实时搜索**:在设置面板左侧导航顶部嵌入搜索框,输入关键词立即过滤所有已注册的设置页(`settings.section`)与通用设置项(`settings.general.item`)。
17
- - 🎯 **新用户引导(v1.8.0)**:首次打开设置面板时弹出引导弹窗,介绍实时搜索、拼音/意图/AI 联想、点击即达与自动导入索引等核心功能;可在「设置搜索」页随时重新打开。
16
+ - 🔍 **实时搜索**:在设置面板中嵌入搜索框,输入关键词立即过滤所有已注册的设置页(`settings.section`)与通用设置项(`settings.general.item`)。
17
+ - 🧩 **四种搜索样式(v1.9.0)**:可选择经典侧栏、窗口顶栏、弹出搜索页,或“窗口顶栏 + 弹出搜索页”混合模式;选择会保存在当前浏览器。
18
+ - 🤝 **导入 DSH 模型配置(v1.9.0)**:在 AI 配置区一键读取 DSH 当前默认模型的 Base URL 与模型名;为避免凭据扩散,导入始终不包含 API Key。
19
+ - 🎯 **新用户引导(v1.9.0)**:仅在安装后首次打开设置面板时弹出引导弹窗,介绍四种搜索样式、拼音/意图/AI 联想、点击即达与自动导入索引等核心功能;不再提供“不再显示”和手动重开按钮。
18
20
  - 📂 **页内选项搜索(v1.2.0)**:不只搜页面,还能直达每个设置页内部的具体选项——插件页的标签页(`settings.plugins.tab`)、Web UI 插件的卡片(`web-ui.plugin.item`)、通用设置行等,结果以「页面 › 选项」面包屑展示。
19
21
  - 🔤 **拼音联想搜索(v1.5.0)**:输入拼音首字母(如 `sz`)或全拼(如 `shezhi`)即可联想中文设置项;支持 `↑`/`↓` 选择、`Enter` 跳转、`Esc` 关闭,命中的文字自动高亮。
20
22
  - 🧠 **意图联想搜索(v1.6.0)**:直接描述想做的事(如「太亮了」「字太小」「忘记密码」),无需知道选项名即可联想真实设置项,结果在「猜你想找」分组展示。
package/lib/client.js CHANGED
@@ -30,12 +30,19 @@ 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.8.0'
33
+ const PKG_VERSION = '1.9.0'
34
34
  const NPM_NAME = '@objectivex666/dsh-settings-search'
35
35
  const zh = {
36
36
  title: '设置',
37
37
  placeholder: '搜索设置…',
38
38
  searchBtn: '搜索',
39
+ styleTitle: '搜索框样式',
40
+ styleHint: '选择搜索入口的展示方式;设置会保存在本机浏览器。',
41
+ styleClassic: '经典侧栏',
42
+ styleTop: '窗口顶栏',
43
+ stylePage: '弹出搜索页',
44
+ styleMixed: '顶栏 + 搜索页',
45
+ openSearch: '打开搜索',
39
46
  warming: '正在导入各分区选项…',
40
47
  empty: '没有匹配的设置',
41
48
  pages: '设置页',
@@ -43,6 +50,7 @@ window.__ModuleLoader__.load({
43
50
  guess: '猜你想找',
44
51
  page: '页面',
45
52
  option: '选项',
53
+ pageSearchTitle: '搜索结果',
46
54
  jumpFail: '未能自动跳转,请手动打开:{path}',
47
55
  updn: '设置搜索',
48
56
  updDesc: '设置面板顶部搜索框由本插件提供。在此检查插件的版本与更新。',
@@ -77,6 +85,11 @@ window.__ModuleLoader__.load({
77
85
  aiBaseUrlHint: '仅支持 http(s) 地址;非本机地址必须使用 https,以免泄露 API Key。',
78
86
  aiModel: '模型名',
79
87
  aiApiKey: 'API Key',
88
+ aiDshImport: '导入 DSH 模型配置',
89
+ aiDshImporting: '正在导入…',
90
+ aiDshImportOk: '已导入 Base URL 与模型名;API Key 未改动 ✓',
91
+ aiDshImportFail: '导入失败:{reason}',
92
+ aiDshImportEmpty: '未找到 DSH 当前默认模型配置',
80
93
  aiConfigSave: '保存配置',
81
94
  aiConfigSaved: '已保存 ✓',
82
95
  aiClear: '清除配置',
@@ -115,7 +128,7 @@ window.__ModuleLoader__.load({
115
128
  wlTitle: '欢迎使用设置搜索',
116
129
  wlSubtitle: '为 DSH 设置面板提供即时搜索与导航,几秒钟就能找到任何设置。',
117
130
  wlFeature1Title: '实时搜索',
118
- wlFeature1Desc: '在顶部搜索框输入关键词,即时过滤所有设置页与页内选项。',
131
+ wlFeature1Desc: '支持经典侧栏、窗口顶栏、弹出搜索页与顶栏加搜索页混合模式;输入关键词即可过滤设置。',
119
132
  wlFeature2Title: '拼音与意图联想',
120
133
  wlFeature2Desc: '输入拼音首字母(如 sz)或直接描述需求(如「太亮」),也能联想设置项。',
121
134
  wlFeature3Title: 'AI 联想(可选)',
@@ -125,9 +138,7 @@ window.__ModuleLoader__.load({
125
138
  wlFeature5Title: '自带设置页',
126
139
  wlFeature5Desc: '左侧导航的「设置搜索」页可检查更新、查看说明与导出日志。',
127
140
  wlStart: '开始使用',
128
- wlSkip: '不再显示',
129
141
  wlOpenPanel: '打开设置搜索页',
130
- wlReopen: '新用户引导',
131
142
  wlFeature6Title: '自动导入索引',
132
143
  wlFeature6Desc: '首次打开时自动遍历一遍各设置分区,把分区内选项一并纳入搜索,无需逐页手动打开即可直接搜到。',
133
144
  }
@@ -135,6 +146,13 @@ window.__ModuleLoader__.load({
135
146
  title: 'Settings',
136
147
  placeholder: 'Search settings…',
137
148
  searchBtn: 'Search',
149
+ styleTitle: 'Search style',
150
+ styleHint: 'Choose how the search entry is displayed; saved in this browser.',
151
+ styleClassic: 'Classic sidebar',
152
+ styleTop: 'Top bar',
153
+ stylePage: 'Popup page',
154
+ styleMixed: 'Top bar + popup',
155
+ openSearch: 'Open search',
138
156
  warming: 'Importing section options…',
139
157
  empty: 'No matching settings',
140
158
  pages: 'Settings pages',
@@ -142,6 +160,7 @@ window.__ModuleLoader__.load({
142
160
  guess: 'You may want',
143
161
  page: 'Page',
144
162
  option: 'Option',
163
+ pageSearchTitle: 'Search results',
145
164
  jumpFail: 'Could not navigate automatically — open it here: {path}',
146
165
  updn: 'Settings Search',
147
166
  updDesc: "The search box atop the settings panel comes from this plugin. Check its version and updates here.",
@@ -176,6 +195,11 @@ window.__ModuleLoader__.load({
176
195
  aiBaseUrlHint: 'Only http(s) URLs are allowed; remote hosts must use https to avoid exposing your API key.',
177
196
  aiModel: 'Model',
178
197
  aiApiKey: 'API Key',
198
+ aiDshImport: 'Import DSH model config',
199
+ aiDshImporting: 'Importing…',
200
+ aiDshImportOk: 'Imported Base URL and model; API key unchanged ✓',
201
+ aiDshImportFail: 'Import failed: {reason}',
202
+ aiDshImportEmpty: 'No current DSH default model config found',
179
203
  aiConfigSave: 'Save config',
180
204
  aiConfigSaved: 'Saved ✓',
181
205
  aiClear: 'Clear config',
@@ -214,7 +238,7 @@ window.__ModuleLoader__.load({
214
238
  wlTitle: 'Welcome to Settings Search',
215
239
  wlSubtitle: 'Instant search and navigation for the DSH settings panel — find any setting in seconds.',
216
240
  wlFeature1Title: 'Instant search',
217
- wlFeature1Desc: 'Type in the top search box to instantly filter settings pages and in-page options.',
241
+ wlFeature1Desc: 'Choose classic sidebar, top bar, popup page, or mixed top bar plus popup search; typing filters settings.',
218
242
  wlFeature2Title: 'Pinyin & intent hints',
219
243
  wlFeature2Desc: 'Type pinyin initials (e.g. sz) or describe what you want to do to find settings.',
220
244
  wlFeature3Title: 'AI hints (optional)',
@@ -224,9 +248,7 @@ window.__ModuleLoader__.load({
224
248
  wlFeature5Title: 'Built-in panel',
225
249
  wlFeature5Desc: 'The Settings Search page in the sidebar checks updates, shows notes, and exports logs.',
226
250
  wlStart: 'Get started',
227
- wlSkip: "Don't show again",
228
251
  wlOpenPanel: 'Open Settings Search panel',
229
- wlReopen: 'Onboarding',
230
252
  wlFeature6Title: 'Auto-index',
231
253
  wlFeature6Desc: 'On first open it automatically passes through every settings section, so in-page options are searchable without opening each page manually.',
232
254
  }
@@ -278,28 +300,42 @@ window.__ModuleLoader__.load({
278
300
  PINYIN_TABLE.split(/\s+/).filter(Boolean).map((pair) => [pair[0], pair.slice(1)])
279
301
  )
280
302
  const CJK_RE = /[\u4e00-\u9fff]/
303
+ const ALNUM_RE = /[a-zA-Z0-9]/
281
304
 
282
305
  /**
283
306
  * Transliterate a label into full-pinyin and initial-letter keys for
284
307
  * association search. Returns null when any CJK character is outside
285
308
  * the built-in table (then only substring matching applies).
309
+ *
310
+ * Results are memoized: the same labels and intent keywords are
311
+ * re-transliterated on every keystroke's re-filter, and this CJK loop
312
+ * is the hottest code in search. Cached objects are shared — callers
313
+ * must treat them as read-only.
286
314
  */
315
+ const PINYIN_KEY_CACHE = new Map()
287
316
  const pinyinKeys = (text) => {
317
+ const cached = PINYIN_KEY_CACHE.get(text)
318
+ if (cached !== undefined) return cached
288
319
  let full = ''
289
320
  let initials = ''
290
321
  for (const ch of text) {
291
322
  if (CJK_RE.test(ch)) {
292
323
  const py = PINYIN.get(ch)
293
- if (py === undefined) return null
324
+ if (py === undefined) {
325
+ PINYIN_KEY_CACHE.set(text, null)
326
+ return null
327
+ }
294
328
  full += py
295
329
  initials += py[0]
296
- } else if (/[a-zA-Z0-9]/.test(ch)) {
330
+ } else if (ALNUM_RE.test(ch)) {
297
331
  const lower = ch.toLowerCase()
298
332
  full += lower
299
333
  initials += lower
300
334
  }
301
335
  }
302
- return full === '' ? null : { full, initials }
336
+ const keys = full === '' ? null : { full, initials }
337
+ PINYIN_KEY_CACHE.set(text, keys)
338
+ return keys
303
339
  }
304
340
 
305
341
  /** Match one query token: raw substring, full pinyin, or initial letters. */
@@ -312,6 +348,17 @@ window.__ModuleLoader__.load({
312
348
  return null
313
349
  }
314
350
 
351
+ /** Cache the \b-wrapped keyword regexes used by related-keyword matching. */
352
+ const WORD_BOUNDARY_CACHE = new Map()
353
+ const wordBoundaryRegex = (kw) => {
354
+ let re = WORD_BOUNDARY_CACHE.get(kw)
355
+ if (re === undefined) {
356
+ re = new RegExp(`\\b${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`)
357
+ WORD_BOUNDARY_CACHE.set(kw, re)
358
+ }
359
+ return re
360
+ }
361
+
315
362
  /** Character count of the label consumed by a full-pinyin prefix. */
316
363
  const pinyinPrefixChars = (label, pinyinLength) => {
317
364
  let consumed = 0
@@ -360,8 +407,14 @@ window.__ModuleLoader__.load({
360
407
  return { phrase, keywords: entry.slice(at + 1).split(','), keys: pinyinKeys(phrase) }
361
408
  })
362
409
 
363
- /** Keywords a query token should additionally search after an intent hit. */
410
+ /**
411
+ * Keywords a query token should additionally search after an intent hit.
412
+ * Memoized per token — tokens repeat on every keystroke's re-filter.
413
+ */
414
+ const INTENT_KEYWORD_CACHE = new Map()
364
415
  const intentKeywords = (tok) => {
416
+ const cached = INTENT_KEYWORD_CACHE.get(tok)
417
+ if (cached !== undefined) return cached
365
418
  const hits = []
366
419
  for (const intent of INTENTS) {
367
420
  const phraseHit = intent.phrase.includes(tok) || tok.includes(intent.phrase) ||
@@ -373,6 +426,7 @@ window.__ModuleLoader__.load({
373
426
  }
374
427
  }
375
428
  }
429
+ INTENT_KEYWORD_CACHE.set(tok, hits)
376
430
  return hits
377
431
  }
378
432
 
@@ -407,6 +461,24 @@ window.__ModuleLoader__.load({
407
461
  const AI_BASE_URL_KEY = 'dsh-settings-search:ai-base-url'
408
462
  const AI_MODEL_KEY = 'dsh-settings-search:ai-model'
409
463
  const AI_API_KEY_KEY = 'dsh-settings-search:ai-api-key'
464
+ /** Session-only API key storage avoids durable browser storage exposure. */
465
+ const readApiKey = () => {
466
+ const legacy = typeof localStorage !== 'undefined' ? (localStorage.getItem(AI_API_KEY_KEY) ?? '').trim() : ''
467
+ if (legacy !== '') {
468
+ if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(AI_API_KEY_KEY, legacy)
469
+ if (typeof localStorage !== 'undefined') localStorage.removeItem(AI_API_KEY_KEY)
470
+ return legacy
471
+ }
472
+ return typeof sessionStorage !== 'undefined' ? (sessionStorage.getItem(AI_API_KEY_KEY) ?? '').trim() : ''
473
+ }
474
+ const writeApiKey = (value) => {
475
+ const apiKey = String(value ?? '').trim()
476
+ if (typeof sessionStorage !== 'undefined') {
477
+ if (apiKey === '') sessionStorage.removeItem(AI_API_KEY_KEY)
478
+ else sessionStorage.setItem(AI_API_KEY_KEY, apiKey)
479
+ }
480
+ if (typeof localStorage !== 'undefined') localStorage.removeItem(AI_API_KEY_KEY)
481
+ }
410
482
  const aiSearchEnabled = () => {
411
483
  if (typeof localStorage === 'undefined') return false
412
484
  return localStorage.getItem(AI_STORAGE_KEY) === '1'
@@ -417,26 +489,42 @@ window.__ModuleLoader__.load({
417
489
  window.dispatchEvent(new CustomEvent(AI_TOGGLE_EVENT, { detail: enabled }))
418
490
  }
419
491
  const aiConfig = () => {
420
- if (typeof localStorage === 'undefined') return { provider: 'openai', baseUrl: '', model: '', apiKey: '' }
421
- const providerRaw = (localStorage.getItem(AI_PROVIDER_KEY) ?? 'openai').trim()
492
+ if (typeof localStorage === 'undefined' && typeof sessionStorage === 'undefined') {
493
+ return { provider: 'openai', baseUrl: '', model: '', apiKey: '' }
494
+ }
495
+ const providerRaw = (localStorage?.getItem(AI_PROVIDER_KEY) ?? 'openai').trim()
422
496
  return {
423
497
  provider: providerRaw === 'anthropic' ? 'anthropic' : 'openai',
424
- baseUrl: (localStorage.getItem(AI_BASE_URL_KEY) ?? '').trim(),
425
- model: (localStorage.getItem(AI_MODEL_KEY) ?? '').trim(),
426
- apiKey: (localStorage.getItem(AI_API_KEY_KEY) ?? '').trim(),
498
+ baseUrl: (localStorage?.getItem(AI_BASE_URL_KEY) ?? '').trim(),
499
+ model: (localStorage?.getItem(AI_MODEL_KEY) ?? '').trim(),
500
+ apiKey: readApiKey(),
427
501
  }
428
502
  }
429
503
  const setAiConfig = (cfg) => {
504
+ if (typeof localStorage === 'undefined' && typeof sessionStorage === 'undefined') return
505
+ writeApiKey(cfg.apiKey)
430
506
  if (typeof localStorage === 'undefined') return
431
507
  localStorage.setItem(AI_PROVIDER_KEY, cfg.provider === 'anthropic' ? 'anthropic' : 'openai')
432
508
  localStorage.setItem(AI_BASE_URL_KEY, cfg.baseUrl ?? '')
433
509
  localStorage.setItem(AI_MODEL_KEY, cfg.model ?? '')
434
- localStorage.setItem(AI_API_KEY_KEY, cfg.apiKey ?? '')
435
510
  window.dispatchEvent(new CustomEvent(AI_CONFIG_EVENT))
436
511
  }
437
512
  /** First-run onboarding: remembered via localStorage so it shows once. */
438
513
  const ONBOARD_KEY = 'dsh-settings-search:onboarded'
439
514
  const WELCOME_EVENT = 'dsh-settings-search:welcome'
515
+ const SEARCH_STYLE_KEY = 'dsh-settings-search:style'
516
+ const SEARCH_STYLE_EVENT = 'dsh-settings-search:style-change'
517
+ const SEARCH_STYLES = ['sidebar', 'top', 'page', 'mixed']
518
+ const readSearchStyle = () => {
519
+ if (typeof localStorage === 'undefined') return 'sidebar'
520
+ const style = localStorage.getItem(SEARCH_STYLE_KEY)
521
+ return SEARCH_STYLES.includes(style) ? style : 'sidebar'
522
+ }
523
+ const setStoredSearchStyle = (style) => {
524
+ if (typeof localStorage === 'undefined' || !SEARCH_STYLES.includes(style)) return
525
+ localStorage.setItem(SEARCH_STYLE_KEY, style)
526
+ window.dispatchEvent(new CustomEvent(SEARCH_STYLE_EVENT, { detail: style }))
527
+ }
440
528
  const welcomed = () => {
441
529
  if (typeof localStorage === 'undefined') return true
442
530
  return localStorage.getItem(ONBOARD_KEY) === '1'
@@ -446,9 +534,6 @@ window.__ModuleLoader__.load({
446
534
  localStorage.setItem(ONBOARD_KEY, '1')
447
535
  window.dispatchEvent(new CustomEvent(WELCOME_EVENT, { detail: false }))
448
536
  }
449
- const openWelcome = () => {
450
- window.dispatchEvent(new CustomEvent(WELCOME_EVENT, { detail: true }))
451
- }
452
537
  const extractAiKeywords = (raw) => {
453
538
  const text = String(raw ?? '').replace(/^```(?:json)?/i, '').replace(/```$/, '').trim()
454
539
  const start = text.indexOf('{')
@@ -489,17 +574,17 @@ window.__ModuleLoader__.load({
489
574
  return url.origin + path.replace(/\/+$/, '')
490
575
  }
491
576
  const chatCompletionsUrl = (baseUrl) => {
577
+ // safeBaseUrl already strips trailing slashes
492
578
  const safe = safeBaseUrl(baseUrl)
493
579
  if (!safe) return ''
494
- const trimmed = safe.replace(/\/+$/, '')
495
- return /\/chat\/completions$/i.test(trimmed) ? trimmed : `${trimmed}/chat/completions`
580
+ return /\/chat\/completions$/i.test(safe) ? safe : `${safe}/chat/completions`
496
581
  }
497
582
  const anthropicMessagesUrl = (baseUrl) => {
583
+ // safeBaseUrl already strips trailing slashes
498
584
  const safe = safeBaseUrl(baseUrl)
499
585
  if (!safe) return ''
500
- const trimmed = safe.replace(/\/+$/, '')
501
- if (/\/v1\/messages$/i.test(trimmed)) return trimmed
502
- return /\/v1$/i.test(trimmed) ? `${trimmed}/messages` : `${trimmed}/v1/messages`
586
+ if (/\/v1\/messages$/i.test(safe)) return safe
587
+ return /\/v1$/i.test(safe) ? `${safe}/messages` : `${safe}/v1/messages`
503
588
  }
504
589
  const requestUserAi = async (query, labels, signal) => {
505
590
  const { provider, baseUrl, model, apiKey } = aiConfig()
@@ -573,7 +658,11 @@ window.__ModuleLoader__.load({
573
658
  const cacheKey = query.toLowerCase()
574
659
  if (AI_CACHE.has(cacheKey)) return AI_CACHE.get(cacheKey)
575
660
  const keywords = await requestUserAi(query, labels, signal)
576
- if (keywords.length > 0) AI_CACHE.set(cacheKey, keywords)
661
+ if (keywords.length > 0) {
662
+ // bound the cache so a long session cannot grow it without limit
663
+ if (AI_CACHE.size >= 100) AI_CACHE.clear()
664
+ AI_CACHE.set(cacheKey, keywords)
665
+ }
577
666
  return keywords
578
667
  }
579
668
 
@@ -583,16 +672,17 @@ window.__ModuleLoader__.load({
583
672
  * Returns { ok, code, detail } where code is a stable error kind used to
584
673
  * look up a localized hint in the UI.
585
674
  */
675
+ const HTTP_TEST_CODES = {
676
+ 400: 'bad-request',
677
+ 401: 'auth',
678
+ 403: 'forbidden',
679
+ 404: 'not-found',
680
+ 408: 'timeout',
681
+ 429: 'rate-limit',
682
+ }
586
683
  const testHttpCode = (status) => {
587
684
  const code = Number(status)
588
- if (code === 401) return 'auth'
589
- if (code === 403) return 'forbidden'
590
- if (code === 404) return 'not-found'
591
- if (code === 408) return 'timeout'
592
- if (code === 429) return 'rate-limit'
593
- if (code === 400) return 'bad-request'
594
- if (code >= 500 && code <= 599) return 'server'
595
- return 'http'
685
+ return HTTP_TEST_CODES[code] ?? (code >= 500 && code <= 599 ? 'server' : 'http')
596
686
  }
597
687
  const testAiService = async (cfg) => {
598
688
  const provider = cfg.provider === 'anthropic' ? 'anthropic' : 'openai'
@@ -687,6 +777,25 @@ window.__ModuleLoader__.load({
687
777
  .sss-ai-note { margin: 0; padding: 6px 8px; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; }
688
778
  .sss-empty { margin: 0; padding: 10px 8px; color: var(--dsw-alias-label-secondary); font-size: 13px; }
689
779
  .sss-guide { padding: 7px 10px; border-radius: 8px; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 18px; }
780
+ .sss-wrap.sss-top { position: absolute; top: 20px; left: 198px; right: 152px; z-index: 950; width: auto; height: 28px; gap: 8px; }
781
+ .sss-top .sss-title { display: none; }
782
+ .sss-top .sss-box { height: 28px; padding: 0 8px; border-radius: 8px; border-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-shadow-lv2); }
783
+ .sss-top .sss-input { font-size: 15px; }
784
+ .sss-top .sss-top-trigger { height: 28px; padding: 0 8px; border-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-shadow-lv2); font-size: 15px; }
785
+ .sss-trigger { display: flex; align-items: center; gap: 8px; width: 100%; box-sizing: border-box; height: 32px; padding: 0 9px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-dimmed); font: inherit; font-size: 14px; line-height: 22px; cursor: pointer; text-align: left; }
786
+ .sss-trigger:hover { border-color: var(--dsw-alias-label-dimmed); color: var(--dsw-alias-label-secondary); }
787
+ .sss-trigger:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; }
788
+ .sss-page-overlay { position: fixed; inset: 0; z-index: 900; display: flex; align-items: flex-start; justify-content: center; padding: 72px 20px 32px; background: var(--dsw-alias-bg-mask, rgb(0 0 0 / 36%)); }
789
+ .sss-page { display: flex; flex-direction: column; width: min(760px, 100%); max-height: min(680px, calc(100vh - 104px)); overflow: hidden; background: var(--dsw-alias-bg-layer-3); border: 1px solid var(--dsw-alias-border-l1); border-radius: 14px; box-shadow: var(--dsw-shadow-lv3); }
790
+ .sss-page-head { flex: none; display: flex; align-items: center; gap: 8px; height: 52px; padding: 0 10px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
791
+ .sss-page-input { flex: 1; min-width: 0; height: 38px; padding: 0 6px; border: none; outline: none; background: transparent; font-size: 16px; line-height: 24px; color: var(--dsw-alias-label-primary); }
792
+ .sss-page-input::placeholder { color: var(--dsw-alias-label-dimmed); }
793
+ .sss-page-close { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border: none; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; }
794
+ .sss-page-close:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
795
+ .sss-page-results { flex: 1; min-height: 0; overflow: auto; padding: 8px; }
796
+ .sss-page-results .sss-group { padding: 2px; }
797
+ .sss-page-results .sss-item { padding: 10px 8px; border-radius: 8px; }
798
+ .sss-mixed-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-primary); font-size: 16px; line-height: 24px; }
690
799
  @keyframes sssFlashBg { 0% { background-color: var(--dsw-alias-interactive-bg-hover); } 100% { background-color: transparent; } }
691
800
  .sss-flash { animation: sssFlashBg 1.8s ease-out; border-radius: 8px; }
692
801
  .sss-vr { list-style: none; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-3); border-radius: 12px; max-width: 780px; width: 100%; box-sizing: border-box; transition: border-color .16s, background .16s; }
@@ -717,6 +826,10 @@ window.__ModuleLoader__.load({
717
826
  .sss-vr-btn:hover:not(:disabled) { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
718
827
  .sss-vr-btn:disabled { opacity: .4; cursor: default; }
719
828
  .sss-vr-btn:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
829
+ .sss-vr-segment { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
830
+ .sss-vr-seg { appearance: none; font: inherit; cursor: pointer; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; padding: 6px 10px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); background: 0 0; }
831
+ .sss-vr-seg:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
832
+ .sss-vr-seg.is-active { color: var(--dsw-alias-bg-layer-3); background: var(--dsw-alias-label-primary); border-color: transparent; }
720
833
  .sss-vr-primary { background: var(--dsw-alias-label-primary); color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
721
834
  .sss-vr-primary:hover:not(:disabled) { color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
722
835
  .sss-vr-badge { white-space: nowrap; background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-secondary); border-radius: 999px; padding: 1px 8px; font-size: 11px; font-weight: 500; line-height: 17px; }
@@ -797,7 +910,6 @@ window.__ModuleLoader__.load({
797
910
  function createModel(ctx) {
798
911
  /** id → harvested visible title, per slot. */
799
912
  const harvest = new Map()
800
- let harvestSeq = 0
801
913
 
802
914
  const entriesOf = (slot) => {
803
915
  try { return ctx.slots.entries(slot) ?? [] } catch { return [] }
@@ -834,7 +946,7 @@ window.__ModuleLoader__.load({
834
946
  const trimDescription = (text) => {
835
947
  const t = (text ?? '').replace(/\s+/g, ' ').trim()
836
948
  if (t === '') return ''
837
- const re = /[\s,,。;;](关闭后|开启后|启用后|禁用后|创建后|使用时|点击后|勾选后|开启时|关闭时|完成后|启用时|禁用时|开始后|停止后)/
949
+ const re = /(?:[\s,,。;;])?(关闭后|开启后|启用后|禁用后|创建后|使用时|点击后|勾选后|开启时|关闭时|完成后|启用时|禁用时|开始后|停止后)/
838
950
  const m = re.exec(t)
839
951
  if (m) {
840
952
  const head = t.slice(0, m.index).trim()
@@ -914,7 +1026,7 @@ window.__ModuleLoader__.load({
914
1026
  if (containers.length === 1 && containers[0].childElementCount > 0) {
915
1027
  nodes = [...containers[0].children]
916
1028
  } else {
917
- nodes = containers.filter((n) => n.childElementCount >= 0)
1029
+ nodes = containers
918
1030
  }
919
1031
  if (nodes.length === 0) continue
920
1032
  map = harvest.get(cfg.slot)
@@ -972,7 +1084,6 @@ window.__ModuleLoader__.load({
972
1084
  }
973
1085
  }
974
1086
  } catch { /* section mining is best-effort */ }
975
- if (grew) harvestSeq += 1
976
1087
  return grew
977
1088
  }
978
1089
 
@@ -1008,6 +1119,10 @@ window.__ModuleLoader__.load({
1008
1119
  const parentLabel = (parentId) =>
1009
1120
  sections.find((s) => s.id === parentId)?.label ?? parentId
1010
1121
  const out = []
1122
+ // Set-based dedup: keys and "parentLabel\u0000label" pairs replace the
1123
+ // former out.some(...) scans, which were O(n²) on every keystroke.
1124
+ const seenKeys = new Set()
1125
+ const seenPairs = new Set()
1011
1126
  for (const cfg of DEEP_SLOTS) {
1012
1127
  const mapped = harvest.get(cfg.slot)
1013
1128
  sortEntries(entriesOf(cfg.slot)).forEach((entry, i) => {
@@ -1020,8 +1135,10 @@ window.__ModuleLoader__.load({
1020
1135
  const label = staticLabel(entry, cfg) || hitObj?.title || ''
1021
1136
  if (label === '') return // unmapped, unharvested — invisible until it renders once
1022
1137
  const key = cfg.slot + '#' + id
1023
- if (out.some((e) => e.key === key)) return
1138
+ if (seenKeys.has(key)) return
1024
1139
  const pageLabel = parentLabel(cfg.parent)
1140
+ seenKeys.add(key)
1141
+ seenPairs.add(pageLabel + '\u0000' + label)
1025
1142
  out.push({
1026
1143
  kind: 'option',
1027
1144
  key,
@@ -1038,7 +1155,9 @@ window.__ModuleLoader__.load({
1038
1155
  hitObj.inner.forEach((op, oi) => {
1039
1156
  if (op.label === '' || op.label === label) return
1040
1157
  const innerKey = key + '#o' + oi
1041
- if (out.some((e) => e.key === innerKey)) return
1158
+ if (seenKeys.has(innerKey)) return
1159
+ seenKeys.add(innerKey)
1160
+ seenPairs.add(label + '\u0000' + op.label)
1042
1161
  out.push({
1043
1162
  kind: 'option',
1044
1163
  key: innerKey,
@@ -1072,9 +1191,11 @@ window.__ModuleLoader__.load({
1072
1191
  rec.inner.forEach((op, oi) => {
1073
1192
  if (op.label === '' || op.label === label) return
1074
1193
  const innerKey = 'settings.section#' + id + '#o' + oi
1075
- if (out.some((e) => e.key === innerKey)) return
1076
- const dup = out.some((e) => e.kind === 'option' && e.parentLabel === label && e.label === op.label)
1077
- if (dup) return
1194
+ if (seenKeys.has(innerKey)) return
1195
+ const pair = label + '\u0000' + op.label
1196
+ if (seenPairs.has(pair)) return
1197
+ seenKeys.add(innerKey)
1198
+ seenPairs.add(pair)
1078
1199
  out.push({
1079
1200
  kind: 'option',
1080
1201
  key: innerKey,
@@ -1099,15 +1220,19 @@ window.__ModuleLoader__.load({
1099
1220
  : a.order - b.order)
1100
1221
  }
1101
1222
 
1102
- return { readSections, readDeep, runHarvest, bump: () => harvestSeq }
1223
+ return { readSections, readDeep, runHarvest }
1103
1224
  }
1104
1225
 
1105
1226
  /* ---------- DOM-level navigation into the settings shell ---------- */
1106
1227
 
1228
+ /** Root element that hosts the settings panel's nav buttons. */
1229
+ const panelRoot = () => {
1230
+ if (typeof document === 'undefined') return null
1231
+ return document.querySelector('[role="dialog"]') ?? document.body
1232
+ }
1107
1233
  const panelButtons = () => {
1108
- if (typeof document === 'undefined') return []
1109
- const root = document.querySelector('[role="dialog"]') ?? document.body
1110
- return [...root.querySelectorAll('button')]
1234
+ const root = panelRoot()
1235
+ return root === null ? [] : [...root.querySelectorAll('button')]
1111
1236
  }
1112
1237
  const clickNavButton = (wantText) => {
1113
1238
  const want = String(wantText ?? '').trim()
@@ -1122,8 +1247,8 @@ window.__ModuleLoader__.load({
1122
1247
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
1123
1248
  /** Label of the currently-active first-level settings nav item. */
1124
1249
  const activeSectionLabel = () => {
1125
- if (typeof document === 'undefined') return ''
1126
- const root = document.querySelector('[role="dialog"]') ?? document.body
1250
+ const root = panelRoot()
1251
+ if (root === null) return ''
1127
1252
  const isTab = (b) => b.getAttribute('role') === 'tab'
1128
1253
  const buttons = [...root.querySelectorAll('button')].filter((b) => !isTab(b))
1129
1254
  const byCurrent = buttons.find((b) => b.getAttribute('aria-current') === 'true')
@@ -1153,11 +1278,9 @@ window.__ModuleLoader__.load({
1153
1278
  model.runHarvest()
1154
1279
  visited += 1
1155
1280
  }
1156
- if (original !== '') {
1157
- if (clickNavButton(original)) {
1158
- await delay(160)
1159
- model.runHarvest()
1160
- }
1281
+ if (clickNavButton(original)) {
1282
+ await delay(160)
1283
+ model.runHarvest()
1161
1284
  }
1162
1285
  } finally {
1163
1286
  if (typeof onDone === 'function') onDone()
@@ -1168,13 +1291,10 @@ window.__ModuleLoader__.load({
1168
1291
 
1169
1292
  function createNavigator(ctx) {
1170
1293
  /** Jump to a page result; falls back to showing a manual hint. */
1171
- const jumpToPage = async (item) => {
1172
- const ok = await Promise.resolve(clickNavButton(item.label))
1173
- return ok
1174
- }
1294
+ const jumpToPage = async (item) => clickNavButton(item.label)
1175
1295
  /** Jump to an in-page option: open its page, select its tab, flash its row. */
1176
1296
  const jumpToOption = async (item) => {
1177
- const opened = await Promise.resolve(clickNavButton(item.pageLabel ?? item.parentLabel))
1297
+ const opened = clickNavButton(item.pageLabel ?? item.parentLabel)
1178
1298
  if (opened !== true) { flashOptionRow(item); return false }
1179
1299
  await delay(160)
1180
1300
  const label = item.label
@@ -1242,6 +1362,36 @@ window.__ModuleLoader__.load({
1242
1362
  const model = createModel(ctx)
1243
1363
  const navigator = createNavigator(ctx)
1244
1364
 
1365
+ /** Read the model fields from DSH; credentials are never requested. */
1366
+ const readDshModelConfig = async () => {
1367
+ const connection = ctx.get('connection')
1368
+ const response = await connection.api.settings.describe({})
1369
+ if (!response.result.ok) throw new Error(response.result.error?.message || 'settings.describe failed')
1370
+ const namespaces = new Map(response.result.value.namespaces.map((view) => [view.ns, view]))
1371
+ const defaultModel = namespaces.get('agent-default-model')?.value ?? {}
1372
+ const providerId = String(defaultModel.provider ?? '').trim()
1373
+ const modelId = String(defaultModel.model ?? '').trim()
1374
+ const readPath = (value, path) => (path ?? []).reduce((current, key) => current?.[key], value)
1375
+ const providersResponse = await connection.api.llm.providers({}).catch(() => null)
1376
+ const providerRow = providersResponse?.result?.ok
1377
+ ? providersResponse.result.value.providers.find((entry) => entry.provider === providerId)
1378
+ : undefined
1379
+ const modelNamespace = namespaces.get(providerRow?.settingsNs || 'llm-pi-ai')
1380
+ const providerProfile = providerRow
1381
+ ? readPath(modelNamespace?.value, providerRow.settingsPath) ?? {}
1382
+ : modelNamespace?.value?.providers?.[providerId] ?? {}
1383
+ const configuredModels = Array.isArray(providerProfile.models) ? providerProfile.models : []
1384
+ const modelProfile = configuredModels.find((entry) => entry?.id === modelId) ?? {}
1385
+ const protocol = String(modelProfile.api ?? providerProfile.api ?? '').trim()
1386
+ const baseUrl = String(providerProfile.baseURL ?? '').trim()
1387
+ if (!modelId || !baseUrl) return null
1388
+ return {
1389
+ provider: protocol === 'anthropic-messages' ? 'anthropic' : 'openai',
1390
+ baseUrl,
1391
+ model: modelId,
1392
+ }
1393
+ }
1394
+
1245
1395
  const SearchIcon = React.createElement('svg', {
1246
1396
  className: 'sss-icon',
1247
1397
  viewBox: '0 0 16.5 16.5',
@@ -1295,7 +1445,6 @@ window.__ModuleLoader__.load({
1295
1445
  React.createElement('div', { className: 'sss-wl-footer' }, [
1296
1446
  React.createElement('button', { className: 'sss-wl-btn sss-wl-primary', type: 'button', onClick: onClose }, t('wlStart')),
1297
1447
  React.createElement('button', { className: 'sss-wl-btn', type: 'button', onClick: onOpenPanel }, t('wlOpenPanel')),
1298
- React.createElement('button', { className: 'sss-wl-btn sss-wl-skip', type: 'button', onClick: onClose }, t('wlSkip')),
1299
1448
  ]),
1300
1449
  ]),
1301
1450
  ])
@@ -1326,6 +1475,8 @@ window.__ModuleLoader__.load({
1326
1475
  function SettingsHeaderSearch() {
1327
1476
  const [query, setQuery] = React.useState('')
1328
1477
  const [open, setOpen] = React.useState(false)
1478
+ const [searchStyle, setSearchStyleState] = React.useState(readSearchStyle)
1479
+ const [pageOpen, setPageOpen] = React.useState(false)
1329
1480
  const [showWelcome, setShowWelcome] = React.useState(() => !welcomed())
1330
1481
  const [tick, setTick] = React.useState(0)
1331
1482
  const [warming, setWarming] = React.useState(false)
@@ -1343,17 +1494,29 @@ window.__ModuleLoader__.load({
1343
1494
  ctx.locale.subscribe(forceTick),
1344
1495
  ]
1345
1496
  // Grow the index whenever the panel body re-renders (visits new pages).
1497
+ // DOM mutations arrive in bursts (animations, list mounts), so the
1498
+ // harvest pass runs at most once per throttle window (trailing edge).
1346
1499
  let warmAlive = true
1347
1500
  let warmAttempts = 0
1501
+ let mutatorAlive = true
1502
+ let throttleTimer = null
1503
+ const onMutate = () => {
1504
+ if (!mutatorAlive) return
1505
+ if (model.runHarvest()) setTick((x) => x + 1)
1506
+ if (!warmed && activeSectionLabel() !== '') {
1507
+ void warmAllSections(model, () => setWarming(true), () => setWarming(false)).then((done) => {
1508
+ if (!warmAlive) return
1509
+ if (done) setTick((x) => x + 1)
1510
+ })
1511
+ }
1512
+ }
1348
1513
  const observer = typeof MutationObserver === 'function'
1349
1514
  ? new MutationObserver(() => {
1350
- if (model.runHarvest()) setTick((x) => x + 1)
1351
- if (!warmed && activeSectionLabel() !== '') {
1352
- void warmAllSections(model, () => setWarming(true), () => setWarming(false)).then((done) => {
1353
- if (!warmAlive) return
1354
- if (done) setTick((x) => x + 1)
1355
- })
1356
- }
1515
+ if (throttleTimer !== null) return
1516
+ throttleTimer = setTimeout(() => {
1517
+ throttleTimer = null
1518
+ onMutate()
1519
+ }, 120)
1357
1520
  })
1358
1521
  : null
1359
1522
  observer?.observe(document.body, { childList: true, subtree: true })
@@ -1372,9 +1535,11 @@ window.__ModuleLoader__.load({
1372
1535
  }, 800)
1373
1536
  return () => {
1374
1537
  warmAlive = false
1538
+ mutatorAlive = false
1375
1539
  for (const off of offs) off()
1376
1540
  observer?.disconnect()
1377
1541
  clearTimeout(warm)
1542
+ if (throttleTimer !== null) clearTimeout(throttleTimer)
1378
1543
  }
1379
1544
  }, [])
1380
1545
 
@@ -1384,6 +1549,12 @@ window.__ModuleLoader__.load({
1384
1549
  return () => window.removeEventListener(WELCOME_EVENT, onWelcome)
1385
1550
  }, [])
1386
1551
 
1552
+ React.useEffect(() => {
1553
+ const sync = () => setSearchStyleState(readSearchStyle())
1554
+ window.addEventListener(SEARCH_STYLE_EVENT, sync)
1555
+ return () => window.removeEventListener(SEARCH_STYLE_EVENT, sync)
1556
+ }, [])
1557
+
1387
1558
  React.useEffect(() => {
1388
1559
  const sync = () => setAiEnabled(aiSearchEnabled())
1389
1560
  const onConfig = () => forceTick()
@@ -1406,7 +1577,7 @@ window.__ModuleLoader__.load({
1406
1577
  tokens.every((tok) => tokenMatch(haystackLower, item.keys, tok) !== null)
1407
1578
  const kwMatch = (item, haystackLower, kw) => {
1408
1579
  if (/^[\x00-\x7f]+$/.test(kw)) {
1409
- if (new RegExp(`\\b${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(haystackLower)) return true
1580
+ if (wordBoundaryRegex(kw).test(haystackLower)) return true
1410
1581
  } else if (haystackLower.includes(kw)) return true
1411
1582
  if (item.keys === null) return false
1412
1583
  const kwKeys = pinyinKeys(kw)
@@ -1498,15 +1669,24 @@ window.__ModuleLoader__.load({
1498
1669
  const [active, setActive] = React.useState(0)
1499
1670
  const popRef = React.useRef(null)
1500
1671
  const inputRef = React.useRef(null)
1672
+ const pageMode = searchStyle === 'page'
1673
+ const mixedMode = searchStyle === 'mixed'
1674
+ const topMode = searchStyle === 'top' || mixedMode
1501
1675
  const activeIndex = Math.min(active, Math.max(results.length - 1, 0))
1502
1676
 
1503
1677
  React.useEffect(() => { setActive(0) }, [query])
1678
+ React.useEffect(() => {
1679
+ if (!pageMode || !pageOpen) return
1680
+ const timer = setTimeout(() => inputRef.current?.focus(), 0)
1681
+ return () => clearTimeout(timer)
1682
+ }, [pageMode, pageOpen])
1504
1683
  React.useEffect(() => {
1505
1684
  const el = popRef.current?.querySelector('.sss-item.is-active')
1506
1685
  el?.scrollIntoView?.({ block: 'nearest' })
1507
1686
  }, [activeIndex, q])
1508
1687
  const pick = (item) => {
1509
1688
  setOpen(false)
1689
+ setPageOpen(false)
1510
1690
  log('info', '打开设置', { kind: item.kind, id: item.id || item.key || '', label: String(item.label).slice(0, 60) })
1511
1691
  const jumped = item.kind === 'page'
1512
1692
  ? navigator.jumpToPage(item)
@@ -1587,6 +1767,192 @@ window.__ModuleLoader__.load({
1587
1767
  kindBadge(via, t('option')),
1588
1768
  ])
1589
1769
 
1770
+ const resultsNode = none
1771
+ ? (aiStatus === 'loading'
1772
+ ? React.createElement('p', { className: 'sss-ai-note' }, t('aiThinking'))
1773
+ : aiStatus === 'error'
1774
+ ? React.createElement('p', { className: 'sss-ai-note' }, t('aiUnavailable'))
1775
+ : (aiEnabled && !aiConfigured)
1776
+ ? React.createElement('p', { className: 'sss-ai-note' }, t('aiNeedConfig'))
1777
+ : React.createElement('p', { className: 'sss-empty' }, t('empty')))
1778
+ : React.createElement('div', { className: 'sss-groups' }, [
1779
+ shownPages.length > 0 ? React.createElement('div', { className: 'sss-group' }, [
1780
+ React.createElement('div', { className: 'sss-group-title' }, t('pages')),
1781
+ React.createElement('ul', { className: 'sss-list' },
1782
+ shownPages.map((item, i) => renderPage(item, i))),
1783
+ ]) : null,
1784
+ shownOptions.length > 0 ? React.createElement('div', { className: 'sss-group' }, [
1785
+ React.createElement('div', { className: 'sss-group-title' }, t('options')),
1786
+ React.createElement('ul', { className: 'sss-list' },
1787
+ shownOptions.map((item, i) => renderOption(item, shownPages.length + i))),
1788
+ ]) : null,
1789
+ related.length > 0 ? React.createElement('div', { className: 'sss-group' }, [
1790
+ React.createElement('div', { className: 'sss-group-title' }, t('guess')),
1791
+ React.createElement('ul', { className: 'sss-list' },
1792
+ related.map((r, i) => r.kind === 'page'
1793
+ ? renderPage(r.item, shownPages.length + shownOptions.length + i, r.via)
1794
+ : renderOption(r.item, shownPages.length + shownOptions.length + i, r.via))),
1795
+ ]) : null,
1796
+ ])
1797
+
1798
+ const searchInput = React.createElement('input', {
1799
+ className: pageMode ? 'sss-input sss-page-input' : 'sss-input',
1800
+ value: query,
1801
+ placeholder: t('placeholder'),
1802
+ spellCheck: false,
1803
+ ref: inputRef,
1804
+ onKeyDown: (e) => {
1805
+ if (e.key === 'ArrowDown') {
1806
+ e.preventDefault()
1807
+ if (results.length > 0) setActive(Math.min(activeIndex + 1, results.length - 1))
1808
+ } else if (e.key === 'ArrowUp') {
1809
+ e.preventDefault()
1810
+ setActive(Math.max(activeIndex - 1, 0))
1811
+ } else if (e.key === 'Enter') {
1812
+ const item = results[activeIndex]
1813
+ if (item) { e.preventDefault(); pick(item) }
1814
+ } else if (e.key === 'Escape') {
1815
+ if (pageMode || mixedMode) setPageOpen(false)
1816
+ else setOpen(false)
1817
+ }
1818
+ },
1819
+ onChange: (e) => { setQuery(e.target.value); setHint(null); setOpen(true); if (mixedMode) setPageOpen(true) },
1820
+ onFocus: () => { setOpen(true); if (mixedMode) setPageOpen(true) },
1821
+ onBlur: () => setOpen(false),
1822
+ })
1823
+ const clearButton = query !== '' ? React.createElement('button', {
1824
+ className: 'sss-clear',
1825
+ type: 'button',
1826
+ onClick: () => { setQuery(''); setHint(null); setOpen(false) },
1827
+ }, '✕') : null
1828
+ const pageTriggerButton = React.createElement('button', {
1829
+ className: 'sss-trigger sss-top-trigger',
1830
+ type: 'button',
1831
+ onClick: () => setPageOpen(true),
1832
+ }, [SearchIcon, t('openSearch')])
1833
+
1834
+ if (pageMode && pageOpen) {
1835
+ return React.createElement(React.Fragment, null, [
1836
+ React.createElement('div', { className: 'sss-wrap' }, [
1837
+ React.createElement('div', { className: 'sss-title' }, t('title')),
1838
+ showWelcome
1839
+ ? React.createElement(Welcome, { t, onClose: dismissWelcome, onOpenPanel: openPanelFromWelcome })
1840
+ : null,
1841
+ ]),
1842
+ React.createElement('div', { className: 'sss-wrap sss-top' }, [pageTriggerButton]),
1843
+ React.createElement('div', {
1844
+ className: 'sss-page-overlay',
1845
+ onMouseDown: (event) => { if (event.target === event.currentTarget) setPageOpen(false) },
1846
+ }, [
1847
+ React.createElement('div', {
1848
+ className: 'sss-page',
1849
+ role: 'dialog',
1850
+ 'aria-modal': true,
1851
+ 'aria-label': t('placeholder'),
1852
+ }, [
1853
+ React.createElement('div', { className: 'sss-page-head' }, [
1854
+ React.createElement('button', {
1855
+ className: 'sss-icon-btn',
1856
+ type: 'button',
1857
+ 'aria-label': t('searchBtn'),
1858
+ title: t('searchBtn'),
1859
+ onClick: () => inputRef.current?.focus(),
1860
+ }, [SearchIcon]),
1861
+ searchInput,
1862
+ clearButton,
1863
+ React.createElement('button', {
1864
+ className: 'sss-page-close',
1865
+ type: 'button',
1866
+ 'aria-label': 'Close',
1867
+ onClick: () => setPageOpen(false),
1868
+ }, '✕'),
1869
+ ]),
1870
+ React.createElement('div', { className: 'sss-page-results' }, [
1871
+ warming ? React.createElement('p', { className: 'sss-warm-note' }, t('warming')) : null,
1872
+ q !== '' ? resultsNode : null,
1873
+ hint !== null
1874
+ ? React.createElement('div', { className: 'sss-guide' }, t('jumpFail').replace('{path}', hint))
1875
+ : null,
1876
+ ]),
1877
+ ]),
1878
+ ]),
1879
+ ])
1880
+ }
1881
+
1882
+ if (pageMode) {
1883
+ return React.createElement(React.Fragment, null, [
1884
+ React.createElement('div', { className: 'sss-wrap' }, [
1885
+ React.createElement('div', { className: 'sss-title' }, t('title')),
1886
+ showWelcome
1887
+ ? React.createElement(Welcome, { t, onClose: dismissWelcome, onOpenPanel: openPanelFromWelcome })
1888
+ : null,
1889
+ ]),
1890
+ React.createElement('div', { className: 'sss-wrap sss-top' }, [pageTriggerButton]),
1891
+ ])
1892
+ }
1893
+
1894
+ if (topMode) {
1895
+ return React.createElement(React.Fragment, null, [
1896
+ React.createElement('div', { className: 'sss-wrap' }, [
1897
+ React.createElement('div', { className: 'sss-title' }, t('title')),
1898
+ showWelcome
1899
+ ? React.createElement(Welcome, { t, onClose: dismissWelcome, onOpenPanel: openPanelFromWelcome })
1900
+ : null,
1901
+ ]),
1902
+ React.createElement('div', { className: 'sss-wrap sss-top' }, [
1903
+ React.createElement('div', { className: 'sss-box' }, [
1904
+ React.createElement('button', {
1905
+ className: 'sss-icon-btn',
1906
+ type: 'button',
1907
+ 'aria-label': t('searchBtn'),
1908
+ title: t('searchBtn'),
1909
+ onClick: () => inputRef.current?.focus(),
1910
+ }, [SearchIcon]),
1911
+ searchInput,
1912
+ clearButton,
1913
+ ]),
1914
+ warming ? React.createElement('p', { className: 'sss-warm-note' }, t('warming')) : null,
1915
+ !mixedMode && open && q !== '' ? React.createElement('div', { className: 'sss-pop', ref: popRef }, [resultsNode]) : null,
1916
+ !mixedMode && hint !== null
1917
+ ? React.createElement('div', { className: 'sss-guide' }, t('jumpFail').replace('{path}', hint))
1918
+ : null,
1919
+ ]),
1920
+ mixedMode && pageOpen
1921
+ ? React.createElement('div', {
1922
+ className: 'sss-page-overlay',
1923
+ onMouseDown: (event) => { if (event.target === event.currentTarget) setPageOpen(false) },
1924
+ }, [
1925
+ React.createElement('div', { className: 'sss-page' }, [
1926
+ React.createElement('div', { className: 'sss-page-head' }, [
1927
+ React.createElement('button', {
1928
+ className: 'sss-icon-btn',
1929
+ type: 'button',
1930
+ 'aria-label': t('searchBtn'),
1931
+ title: t('searchBtn'),
1932
+ onClick: () => inputRef.current?.focus(),
1933
+ }, [SearchIcon]),
1934
+ React.createElement('span', { className: 'sss-page-input sss-mixed-title' }, t('pageSearchTitle')),
1935
+ clearButton,
1936
+ React.createElement('button', {
1937
+ className: 'sss-page-close',
1938
+ type: 'button',
1939
+ 'aria-label': 'Close',
1940
+ onClick: () => setPageOpen(false),
1941
+ }, '✕'),
1942
+ ]),
1943
+ React.createElement('div', { className: 'sss-page-results' }, [
1944
+ warming ? React.createElement('p', { className: 'sss-warm-note' }, t('warming')) : null,
1945
+ q !== '' ? resultsNode : null,
1946
+ hint !== null
1947
+ ? React.createElement('div', { className: 'sss-guide' }, t('jumpFail').replace('{path}', hint))
1948
+ : null,
1949
+ ]),
1950
+ ]),
1951
+ ])
1952
+ : null,
1953
+ ])
1954
+ }
1955
+
1590
1956
  return React.createElement('div', { className: 'sss-wrap' }, [
1591
1957
  React.createElement('div', { className: 'sss-title' }, t('title')),
1592
1958
  React.createElement('div', { className: 'sss-box' }, [
@@ -1597,66 +1963,11 @@ window.__ModuleLoader__.load({
1597
1963
  title: t('searchBtn'),
1598
1964
  onClick: () => inputRef.current?.focus(),
1599
1965
  }, [SearchIcon]),
1600
- React.createElement('input', {
1601
- className: 'sss-input',
1602
- value: query,
1603
- placeholder: t('placeholder'),
1604
- spellCheck: false,
1605
- ref: inputRef,
1606
- onKeyDown: (e) => {
1607
- if (e.key === 'ArrowDown') {
1608
- e.preventDefault()
1609
- if (results.length > 0) setActive(Math.min(activeIndex + 1, results.length - 1))
1610
- } else if (e.key === 'ArrowUp') {
1611
- e.preventDefault()
1612
- setActive(Math.max(activeIndex - 1, 0))
1613
- } else if (e.key === 'Enter') {
1614
- const item = results[activeIndex]
1615
- if (item) { e.preventDefault(); pick(item) }
1616
- } else if (e.key === 'Escape') {
1617
- setOpen(false)
1618
- }
1619
- },
1620
- onChange: (e) => { setQuery(e.target.value); setHint(null); setOpen(true) },
1621
- onFocus: () => setOpen(true),
1622
- onBlur: () => setOpen(false),
1623
- }),
1624
- query !== '' ? React.createElement('button', {
1625
- className: 'sss-clear',
1626
- type: 'button',
1627
- onClick: () => { setQuery(''); setHint(null); setOpen(false) },
1628
- }, '✕') : null,
1966
+ searchInput,
1967
+ clearButton,
1629
1968
  ]),
1630
1969
  warming ? React.createElement('p', { className: 'sss-warm-note' }, t('warming')) : null,
1631
- open && q !== '' ? React.createElement('div', { className: 'sss-pop', ref: popRef }, [
1632
- none
1633
- ? (aiStatus === 'loading'
1634
- ? React.createElement('p', { className: 'sss-ai-note' }, t('aiThinking'))
1635
- : aiStatus === 'error'
1636
- ? React.createElement('p', { className: 'sss-ai-note' }, t('aiUnavailable'))
1637
- : (aiEnabled && !aiConfigured)
1638
- ? React.createElement('p', { className: 'sss-ai-note' }, t('aiNeedConfig'))
1639
- : React.createElement('p', { className: 'sss-empty' }, t('empty')))
1640
- : React.createElement('div', { className: 'sss-groups' }, [
1641
- shownPages.length > 0 ? React.createElement('div', { className: 'sss-group' }, [
1642
- React.createElement('div', { className: 'sss-group-title' }, t('pages')),
1643
- React.createElement('ul', { className: 'sss-list' },
1644
- shownPages.map((item, i) => renderPage(item, i))),
1645
- ]) : null,
1646
- shownOptions.length > 0 ? React.createElement('div', { className: 'sss-group' }, [
1647
- React.createElement('div', { className: 'sss-group-title' }, t('options')),
1648
- React.createElement('ul', { className: 'sss-list' },
1649
- shownOptions.map((item, i) => renderOption(item, shownPages.length + i))),
1650
- ]) : null,
1651
- related.length > 0 ? React.createElement('div', { className: 'sss-group' }, [
1652
- React.createElement('div', { className: 'sss-group-title' }, t('guess')),
1653
- React.createElement('ul', { className: 'sss-list' },
1654
- related.map((r, i) => r.kind === 'page'
1655
- ? renderPage(r.item, shownPages.length + shownOptions.length + i, r.via)
1656
- : renderOption(r.item, shownPages.length + shownOptions.length + i, r.via))),
1657
- ]) : null,
1658
- ]),
1659
- ]) : null,
1970
+ open && q !== '' ? React.createElement('div', { className: 'sss-pop', ref: popRef }, [resultsNode]) : null,
1660
1971
  hint !== null
1661
1972
  ? React.createElement('div', { className: 'sss-guide' }, t('jumpFail').replace('{path}', hint))
1662
1973
  : null,
@@ -1675,8 +1986,18 @@ window.__ModuleLoader__.load({
1675
1986
  return 0
1676
1987
  }
1677
1988
 
1989
+ /** Auto-clear a transient confirmation flag (copied ✓, cleared…) after ms. */
1990
+ const useAutoReset = (active, reset, ms = 1600) => {
1991
+ React.useEffect(() => {
1992
+ if (!active) return
1993
+ const off = setTimeout(reset, ms)
1994
+ return () => clearTimeout(off)
1995
+ }, [active])
1996
+ }
1997
+
1678
1998
  function UpdatePanel() {
1679
1999
  const [open, setOpen] = React.useState(true)
2000
+ const [searchStyle, setSearchStyleState] = React.useState(readSearchStyle)
1680
2001
  const [state, setState] = React.useState('idle') // idle | loading | newest | outdated | error
1681
2002
  const [latest, setLatest] = React.useState('')
1682
2003
  const [copied, setCopied] = React.useState(false)
@@ -1692,6 +2013,8 @@ window.__ModuleLoader__.load({
1692
2013
  const [aiSaved, setAiSaved] = React.useState(false)
1693
2014
  const [testState, setTestState] = React.useState('idle') // idle | loading | ok | fail
1694
2015
  const [testMsg, setTestMsg] = React.useState('')
2016
+ const [dshImportState, setDshImportState] = React.useState('idle') // idle | loading | ok | fail
2017
+ const [dshImportMsg, setDshImportMsg] = React.useState('')
1695
2018
  const [, setLogVersion] = React.useState(0)
1696
2019
  const [logCopied, setLogCopied] = React.useState(false)
1697
2020
  const [logCopyFail, setLogCopyFail] = React.useState(false)
@@ -1707,16 +2030,13 @@ window.__ModuleLoader__.load({
1707
2030
  }, [])
1708
2031
 
1709
2032
  React.useEffect(() => {
1710
- if (!copied) return
1711
- const off = setTimeout(() => { setCopied(false); setCopyFailed(false) }, 1600)
1712
- return () => clearTimeout(off)
1713
- }, [copied])
2033
+ const sync = () => setSearchStyleState(readSearchStyle())
2034
+ window.addEventListener(SEARCH_STYLE_EVENT, sync)
2035
+ return () => window.removeEventListener(SEARCH_STYLE_EVENT, sync)
2036
+ }, [])
1714
2037
 
1715
- React.useEffect(() => {
1716
- if (!aiSaved) return
1717
- const off = setTimeout(() => setAiSaved(false), 1600)
1718
- return () => clearTimeout(off)
1719
- }, [aiSaved])
2038
+ useAutoReset(copied, () => { setCopied(false); setCopyFailed(false) })
2039
+ useAutoReset(aiSaved, () => setAiSaved(false))
1720
2040
 
1721
2041
  React.useEffect(() => {
1722
2042
  const sync = () => setLogVersion((v) => v + 1)
@@ -1724,23 +2044,9 @@ window.__ModuleLoader__.load({
1724
2044
  return () => window.removeEventListener(LOG_EVENT, sync)
1725
2045
  }, [])
1726
2046
 
1727
- React.useEffect(() => {
1728
- if (!logCopied) return
1729
- const off = setTimeout(() => setLogCopied(false), 1600)
1730
- return () => clearTimeout(off)
1731
- }, [logCopied])
1732
-
1733
- React.useEffect(() => {
1734
- if (!logCleared) return
1735
- const off = setTimeout(() => setLogCleared(false), 1600)
1736
- return () => clearTimeout(off)
1737
- }, [logCleared])
1738
-
1739
- React.useEffect(() => {
1740
- if (!logExportFail) return
1741
- const off = setTimeout(() => setLogExportFail(false), 1600)
1742
- return () => clearTimeout(off)
1743
- }, [logExportFail])
2047
+ useAutoReset(logCopied, () => setLogCopied(false))
2048
+ useAutoReset(logCleared, () => setLogCleared(false))
2049
+ useAutoReset(logExportFail, () => setLogExportFail(false))
1744
2050
 
1745
2051
  const saveAi = () => {
1746
2052
  setAiConfig({
@@ -1753,6 +2059,33 @@ window.__ModuleLoader__.load({
1753
2059
  log('info', '保存 AI 配置', { provider, model: model.trim() })
1754
2060
  }
1755
2061
 
2062
+ const importDshModelConfig = async () => {
2063
+ if (dshImportState === 'loading') return
2064
+ setDshImportState('loading')
2065
+ setDshImportMsg('')
2066
+ try {
2067
+ const imported = await readDshModelConfig()
2068
+ if (!imported) throw new Error(t('aiDshImportEmpty'))
2069
+ setAiConfig({ ...imported, apiKey })
2070
+ setProvider(imported.provider)
2071
+ setBaseUrl(imported.baseUrl)
2072
+ setModel(imported.model)
2073
+ setDshImportState('ok')
2074
+ setDshImportMsg(t('aiDshImportOk'))
2075
+ log('info', '导入 DSH 模型配置', { provider: imported.provider, model: imported.model })
2076
+ } catch (error) {
2077
+ const reason = String(error?.message ?? error).slice(0, 160)
2078
+ setDshImportState('fail')
2079
+ setDshImportMsg(t('aiDshImportFail').replace('{reason}', reason))
2080
+ log('error', '导入 DSH 模型配置失败', { detail: reason })
2081
+ }
2082
+ }
2083
+
2084
+ const updateSearchStyle = (style) => {
2085
+ setStoredSearchStyle(style)
2086
+ setSearchStyleState(style)
2087
+ }
2088
+
1756
2089
  const requestClearAi = () => setConfirmClearAi(true)
1757
2090
  const cancelClearAi = () => setConfirmClearAi(false)
1758
2091
  const clearAiConfig = () => {
@@ -1914,6 +2247,34 @@ window.__ModuleLoader__.load({
1914
2247
  React.createElement('span', { className: 'sss-vr-chevron' + (open ? ' sss-vr-chevron-open' : '') }, '▾'),
1915
2248
  ]),
1916
2249
  open ? React.createElement('div', { className: 'sss-vr-body' }, [
2250
+ React.createElement('div', { className: 'sss-vr-field' }, [
2251
+ React.createElement('div', { className: 'sss-vr-field-head' }, [
2252
+ React.createElement('span', { className: 'sss-vr-label' }, t('styleTitle')),
2253
+ ]),
2254
+ React.createElement('p', { className: 'sss-vr-hint' }, t('styleHint')),
2255
+ React.createElement('div', { className: 'sss-vr-segment' }, [
2256
+ React.createElement('button', {
2257
+ className: 'sss-vr-seg' + (searchStyle === 'sidebar' ? ' is-active' : ''),
2258
+ type: 'button',
2259
+ onClick: () => updateSearchStyle('sidebar'),
2260
+ }, t('styleClassic')),
2261
+ React.createElement('button', {
2262
+ className: 'sss-vr-seg' + (searchStyle === 'top' ? ' is-active' : ''),
2263
+ type: 'button',
2264
+ onClick: () => updateSearchStyle('top'),
2265
+ }, t('styleTop')),
2266
+ React.createElement('button', {
2267
+ className: 'sss-vr-seg' + (searchStyle === 'page' ? ' is-active' : ''),
2268
+ type: 'button',
2269
+ onClick: () => updateSearchStyle('page'),
2270
+ }, t('stylePage')),
2271
+ React.createElement('button', {
2272
+ className: 'sss-vr-seg' + (searchStyle === 'mixed' ? ' is-active' : ''),
2273
+ type: 'button',
2274
+ onClick: () => updateSearchStyle('mixed'),
2275
+ }, t('styleMixed')),
2276
+ ]),
2277
+ ]),
1917
2278
  React.createElement('div', { className: 'sss-vr-field' }, [
1918
2279
  React.createElement('div', { className: 'sss-vr-field-head' }, [
1919
2280
  React.createElement('span', { className: 'sss-vr-label' }, t('aiSearch')),
@@ -2001,8 +2362,13 @@ window.__ModuleLoader__.load({
2001
2362
  aiSaved ? t('aiConfigSaved') : t('aiConfigSave')),
2002
2363
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: testState === 'loading', onClick: () => { void testService() } },
2003
2364
  testState === 'loading' ? t('aiTesting') : t('aiTest')),
2365
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: dshImportState === 'loading', onClick: () => { void importDshModelConfig() } },
2366
+ dshImportState === 'loading' ? t('aiDshImporting') : t('aiDshImport')),
2004
2367
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => requestClearAi() }, t('aiClear')),
2005
2368
  ]),
2369
+ dshImportMsg
2370
+ ? React.createElement('p', { className: 'sss-vr-note', style: { color: dshImportState === 'ok' ? '#1e8e3e' : 'var(--dsw-alias-label-danger)' } }, dshImportMsg)
2371
+ : null,
2006
2372
  testMsg
2007
2373
  ? React.createElement('p', { className: 'sss-vr-note', style: { color: testState === 'ok' ? '#1e8e3e' : 'var(--dsw-alias-label-danger)' } }, testMsg)
2008
2374
  : null,
@@ -2038,7 +2404,6 @@ window.__ModuleLoader__.load({
2038
2404
  state === 'loading' ? t('updChecking') : t('updCheck')),
2039
2405
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: notesState === 'loading', onClick: () => { void fetchNotes() } },
2040
2406
  notesState === 'loading' ? t('updFetchingNotes') : t('updReleaseNotes')),
2041
- React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => openWelcome() }, t('wlReopen')),
2042
2407
  ]),
2043
2408
  notesState === 'loaded'
2044
2409
  ? React.createElement('div', { className: 'sss-vr-notes' }, notesBody)
@@ -2105,7 +2470,7 @@ window.__ModuleLoader__.load({
2105
2470
  }
2106
2471
 
2107
2472
  exports.apply = apply
2108
- exports.inject = ['slots', 'locale']
2473
+ exports.inject = ['slots', 'locale', 'connection']
2109
2474
  return module.exports
2110
2475
  },
2111
2476
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectivex666/dsh-settings-search",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
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",
@@ -44,6 +44,7 @@
44
44
  "client": {
45
45
  "platform": "web",
46
46
  "inject": [
47
+ "@deepseek-ai/dsh-client-connection",
47
48
  "@deepseek-ai/dsh-client-runtime",
48
49
  "@deepseek-ai/dsh-client-locale",
49
50
  "@deepseek-ai/dsh-client-ui-settings"
@@ -29,6 +29,7 @@ function arg(name) {
29
29
  const version = process.argv.includes('--version') ? arg('--version') : undefined
30
30
  const publish = process.argv.includes('--publish')
31
31
  const dryRun = process.argv.includes('--dry-run')
32
+ const VERSION_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/
32
33
 
33
34
  const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'))
34
35
  const target = version ?? pkg.version
@@ -36,6 +37,10 @@ if (pkg.version !== target) {
36
37
  console.error(`version mismatch: package.json=${pkg.version}, requested=${target}`)
37
38
  process.exit(1)
38
39
  }
40
+ if (!VERSION_RE.test(target)) {
41
+ console.error(`invalid semantic version: ${target}`)
42
+ process.exit(1)
43
+ }
39
44
  const tag = `v${target}`
40
45
  const repo = pkg.repository?.url?.replace(/^git\+/, '').replace(/\.git$/, '') ?? 'https://github.com/objectivex666/dsh-settings-search'
41
46