@objectivex666/dsh-settings-search 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -6,6 +6,7 @@
6
6
  [![license](https://img.shields.io/npm/l/@objectivex666/dsh-settings-search)](./LICENSE)
7
7
  [![node](https://img.shields.io/node/v/@objectivex666/dsh-settings-search)](https://nodejs.org)
8
8
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
9
+ [![Listed on dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/plugins/objectivex666/dsh-settings-search)
9
10
 
10
11
  **English** · [中文](./README.md)
11
12
 
@@ -13,8 +14,11 @@
13
14
 
14
15
  ## Features
15
16
 
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.
17
+ - 🔍 **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.
18
+ - 🧩 **Three search styles** — choose the classic sidebar, window top bar, or popup search page. The choice is saved in the current browser.
19
+ - ⌨️ **Search shortcut (v1.10.0)** — focus the search box with `Ctrl+F` or `⌘F` inside the settings panel. You can enable or disable it from the "Settings Search" page; disabling keeps the browser's default find behavior.
20
+ - 🤝 **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.
21
+ - 🎯 **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
22
  - 📂 **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
23
  - 🔤 **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
24
  - 🧠 **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
@@ -6,6 +6,7 @@
6
6
  [![license](https://img.shields.io/npm/l/@objectivex666/dsh-settings-search)](./LICENSE)
7
7
  [![node](https://img.shields.io/node/v/@objectivex666/dsh-settings-search)](https://nodejs.org)
8
8
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
9
+ [![Listed on dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/plugins/objectivex666/dsh-settings-search)
9
10
 
10
11
  **中文** · [English](./README.en.md)
11
12
 
@@ -13,8 +14,11 @@
13
14
 
14
15
  ## 功能特性
15
16
 
16
- - 🔍 **实时搜索**:在设置面板左侧导航顶部嵌入搜索框,输入关键词立即过滤所有已注册的设置页(`settings.section`)与通用设置项(`settings.general.item`)。
17
- - 🎯 **新用户引导(v1.8.0)**:首次打开设置面板时弹出引导弹窗,介绍实时搜索、拼音/意图/AI 联想、点击即达与自动导入索引等核心功能;可在「设置搜索」页随时重新打开。
17
+ - 🔍 **实时搜索**:在设置面板中嵌入搜索框,输入关键词立即过滤所有已注册的设置页(`settings.section`)与通用设置项(`settings.general.item`)。
18
+ - 🧩 **三种搜索样式**:可选择经典侧栏、窗口顶栏或弹出搜索页;选择会保存在当前浏览器。
19
+ - ⌨️ **搜索快捷键(v1.10.0)**:在设置面板内按 `Ctrl+F` 或 `⌘F` 聚焦搜索框;可在「设置搜索」页启用或关闭,关闭后保留浏览器默认查找。
20
+ - 🤝 **导入 DSH 模型配置(v1.9.0)**:在 AI 配置区一键读取 DSH 当前默认模型的 Base URL 与模型名;为避免凭据扩散,导入始终不包含 API Key。
21
+ - 🎯 **新用户引导(v1.9.0)**:仅在安装后首次打开设置面板时弹出引导弹窗,介绍四种搜索样式、拼音/意图/AI 联想、点击即达与自动导入索引等核心功能;不再提供“不再显示”和手动重开按钮。
18
22
  - 📂 **页内选项搜索(v1.2.0)**:不只搜页面,还能直达每个设置页内部的具体选项——插件页的标签页(`settings.plugins.tab`)、Web UI 插件的卡片(`web-ui.plugin.item`)、通用设置行等,结果以「页面 › 选项」面包屑展示。
19
23
  - 🔤 **拼音联想搜索(v1.5.0)**:输入拼音首字母(如 `sz`)或全拼(如 `shezhi`)即可联想中文设置项;支持 `↑`/`↓` 选择、`Enter` 跳转、`Esc` 关闭,命中的文字自动高亮。
20
24
  - 🧠 **意图联想搜索(v1.6.0)**:直接描述想做的事(如「太亮了」「字太小」「忘记密码」),无需知道选项名即可联想真实设置项,结果在「猜你想找」分组展示。
package/lib/client.js CHANGED
@@ -30,12 +30,20 @@ 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.10.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
+ openSearch: '打开搜索',
45
+ shortcutTitle: 'Ctrl+F / ⌘F 快捷键',
46
+ shortcutHint: '在设置面板内按 Ctrl+F 或 ⌘F 聚焦搜索框;关闭后保留浏览器默认查找。',
39
47
  warming: '正在导入各分区选项…',
40
48
  empty: '没有匹配的设置',
41
49
  pages: '设置页',
@@ -43,6 +51,7 @@ window.__ModuleLoader__.load({
43
51
  guess: '猜你想找',
44
52
  page: '页面',
45
53
  option: '选项',
54
+ pageSearchTitle: '搜索结果',
46
55
  jumpFail: '未能自动跳转,请手动打开:{path}',
47
56
  updn: '设置搜索',
48
57
  updDesc: '设置面板顶部搜索框由本插件提供。在此检查插件的版本与更新。',
@@ -77,6 +86,11 @@ window.__ModuleLoader__.load({
77
86
  aiBaseUrlHint: '仅支持 http(s) 地址;非本机地址必须使用 https,以免泄露 API Key。',
78
87
  aiModel: '模型名',
79
88
  aiApiKey: 'API Key',
89
+ aiDshImport: '导入 DSH 模型配置',
90
+ aiDshImporting: '正在导入…',
91
+ aiDshImportOk: '已导入 Base URL 与模型名;API Key 未改动 ✓',
92
+ aiDshImportFail: '导入失败:{reason}',
93
+ aiDshImportEmpty: '未找到 DSH 当前默认模型配置',
80
94
  aiConfigSave: '保存配置',
81
95
  aiConfigSaved: '已保存 ✓',
82
96
  aiClear: '清除配置',
@@ -115,7 +129,7 @@ window.__ModuleLoader__.load({
115
129
  wlTitle: '欢迎使用设置搜索',
116
130
  wlSubtitle: '为 DSH 设置面板提供即时搜索与导航,几秒钟就能找到任何设置。',
117
131
  wlFeature1Title: '实时搜索',
118
- wlFeature1Desc: '在顶部搜索框输入关键词,即时过滤所有设置页与页内选项。',
132
+ wlFeature1Desc: '支持经典侧栏、窗口顶栏与弹出搜索页;输入关键词即可过滤设置。',
119
133
  wlFeature2Title: '拼音与意图联想',
120
134
  wlFeature2Desc: '输入拼音首字母(如 sz)或直接描述需求(如「太亮」),也能联想设置项。',
121
135
  wlFeature3Title: 'AI 联想(可选)',
@@ -125,9 +139,7 @@ window.__ModuleLoader__.load({
125
139
  wlFeature5Title: '自带设置页',
126
140
  wlFeature5Desc: '左侧导航的「设置搜索」页可检查更新、查看说明与导出日志。',
127
141
  wlStart: '开始使用',
128
- wlSkip: '不再显示',
129
142
  wlOpenPanel: '打开设置搜索页',
130
- wlReopen: '新用户引导',
131
143
  wlFeature6Title: '自动导入索引',
132
144
  wlFeature6Desc: '首次打开时自动遍历一遍各设置分区,把分区内选项一并纳入搜索,无需逐页手动打开即可直接搜到。',
133
145
  }
@@ -135,6 +147,14 @@ window.__ModuleLoader__.load({
135
147
  title: 'Settings',
136
148
  placeholder: 'Search settings…',
137
149
  searchBtn: 'Search',
150
+ styleTitle: 'Search style',
151
+ styleHint: 'Choose how the search entry is displayed; saved in this browser.',
152
+ styleClassic: 'Classic sidebar',
153
+ styleTop: 'Top bar',
154
+ stylePage: 'Popup page',
155
+ openSearch: 'Open search',
156
+ shortcutTitle: 'Ctrl+F / ⌘F shortcut',
157
+ shortcutHint: 'Focus the search box with Ctrl+F or ⌘F inside the settings panel; turn it off to keep the browser default.',
138
158
  warming: 'Importing section options…',
139
159
  empty: 'No matching settings',
140
160
  pages: 'Settings pages',
@@ -142,6 +162,7 @@ window.__ModuleLoader__.load({
142
162
  guess: 'You may want',
143
163
  page: 'Page',
144
164
  option: 'Option',
165
+ pageSearchTitle: 'Search results',
145
166
  jumpFail: 'Could not navigate automatically — open it here: {path}',
146
167
  updn: 'Settings Search',
147
168
  updDesc: "The search box atop the settings panel comes from this plugin. Check its version and updates here.",
@@ -176,6 +197,11 @@ window.__ModuleLoader__.load({
176
197
  aiBaseUrlHint: 'Only http(s) URLs are allowed; remote hosts must use https to avoid exposing your API key.',
177
198
  aiModel: 'Model',
178
199
  aiApiKey: 'API Key',
200
+ aiDshImport: 'Import DSH model config',
201
+ aiDshImporting: 'Importing…',
202
+ aiDshImportOk: 'Imported Base URL and model; API key unchanged ✓',
203
+ aiDshImportFail: 'Import failed: {reason}',
204
+ aiDshImportEmpty: 'No current DSH default model config found',
179
205
  aiConfigSave: 'Save config',
180
206
  aiConfigSaved: 'Saved ✓',
181
207
  aiClear: 'Clear config',
@@ -214,7 +240,7 @@ window.__ModuleLoader__.load({
214
240
  wlTitle: 'Welcome to Settings Search',
215
241
  wlSubtitle: 'Instant search and navigation for the DSH settings panel — find any setting in seconds.',
216
242
  wlFeature1Title: 'Instant search',
217
- wlFeature1Desc: 'Type in the top search box to instantly filter settings pages and in-page options.',
243
+ wlFeature1Desc: 'Choose classic sidebar, top bar, or popup search page; typing filters settings.',
218
244
  wlFeature2Title: 'Pinyin & intent hints',
219
245
  wlFeature2Desc: 'Type pinyin initials (e.g. sz) or describe what you want to do to find settings.',
220
246
  wlFeature3Title: 'AI hints (optional)',
@@ -224,9 +250,7 @@ window.__ModuleLoader__.load({
224
250
  wlFeature5Title: 'Built-in panel',
225
251
  wlFeature5Desc: 'The Settings Search page in the sidebar checks updates, shows notes, and exports logs.',
226
252
  wlStart: 'Get started',
227
- wlSkip: "Don't show again",
228
253
  wlOpenPanel: 'Open Settings Search panel',
229
- wlReopen: 'Onboarding',
230
254
  wlFeature6Title: 'Auto-index',
231
255
  wlFeature6Desc: 'On first open it automatically passes through every settings section, so in-page options are searchable without opening each page manually.',
232
256
  }
@@ -278,28 +302,42 @@ window.__ModuleLoader__.load({
278
302
  PINYIN_TABLE.split(/\s+/).filter(Boolean).map((pair) => [pair[0], pair.slice(1)])
279
303
  )
280
304
  const CJK_RE = /[\u4e00-\u9fff]/
305
+ const ALNUM_RE = /[a-zA-Z0-9]/
281
306
 
282
307
  /**
283
308
  * Transliterate a label into full-pinyin and initial-letter keys for
284
309
  * association search. Returns null when any CJK character is outside
285
310
  * the built-in table (then only substring matching applies).
311
+ *
312
+ * Results are memoized: the same labels and intent keywords are
313
+ * re-transliterated on every keystroke's re-filter, and this CJK loop
314
+ * is the hottest code in search. Cached objects are shared — callers
315
+ * must treat them as read-only.
286
316
  */
317
+ const PINYIN_KEY_CACHE = new Map()
287
318
  const pinyinKeys = (text) => {
319
+ const cached = PINYIN_KEY_CACHE.get(text)
320
+ if (cached !== undefined) return cached
288
321
  let full = ''
289
322
  let initials = ''
290
323
  for (const ch of text) {
291
324
  if (CJK_RE.test(ch)) {
292
325
  const py = PINYIN.get(ch)
293
- if (py === undefined) return null
326
+ if (py === undefined) {
327
+ PINYIN_KEY_CACHE.set(text, null)
328
+ return null
329
+ }
294
330
  full += py
295
331
  initials += py[0]
296
- } else if (/[a-zA-Z0-9]/.test(ch)) {
332
+ } else if (ALNUM_RE.test(ch)) {
297
333
  const lower = ch.toLowerCase()
298
334
  full += lower
299
335
  initials += lower
300
336
  }
301
337
  }
302
- return full === '' ? null : { full, initials }
338
+ const keys = full === '' ? null : { full, initials }
339
+ PINYIN_KEY_CACHE.set(text, keys)
340
+ return keys
303
341
  }
304
342
 
305
343
  /** Match one query token: raw substring, full pinyin, or initial letters. */
@@ -312,6 +350,17 @@ window.__ModuleLoader__.load({
312
350
  return null
313
351
  }
314
352
 
353
+ /** Cache the \b-wrapped keyword regexes used by related-keyword matching. */
354
+ const WORD_BOUNDARY_CACHE = new Map()
355
+ const wordBoundaryRegex = (kw) => {
356
+ let re = WORD_BOUNDARY_CACHE.get(kw)
357
+ if (re === undefined) {
358
+ re = new RegExp(`\\b${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`)
359
+ WORD_BOUNDARY_CACHE.set(kw, re)
360
+ }
361
+ return re
362
+ }
363
+
315
364
  /** Character count of the label consumed by a full-pinyin prefix. */
316
365
  const pinyinPrefixChars = (label, pinyinLength) => {
317
366
  let consumed = 0
@@ -360,8 +409,14 @@ window.__ModuleLoader__.load({
360
409
  return { phrase, keywords: entry.slice(at + 1).split(','), keys: pinyinKeys(phrase) }
361
410
  })
362
411
 
363
- /** Keywords a query token should additionally search after an intent hit. */
412
+ /**
413
+ * Keywords a query token should additionally search after an intent hit.
414
+ * Memoized per token — tokens repeat on every keystroke's re-filter.
415
+ */
416
+ const INTENT_KEYWORD_CACHE = new Map()
364
417
  const intentKeywords = (tok) => {
418
+ const cached = INTENT_KEYWORD_CACHE.get(tok)
419
+ if (cached !== undefined) return cached
365
420
  const hits = []
366
421
  for (const intent of INTENTS) {
367
422
  const phraseHit = intent.phrase.includes(tok) || tok.includes(intent.phrase) ||
@@ -373,6 +428,7 @@ window.__ModuleLoader__.load({
373
428
  }
374
429
  }
375
430
  }
431
+ INTENT_KEYWORD_CACHE.set(tok, hits)
376
432
  return hits
377
433
  }
378
434
 
@@ -407,6 +463,24 @@ window.__ModuleLoader__.load({
407
463
  const AI_BASE_URL_KEY = 'dsh-settings-search:ai-base-url'
408
464
  const AI_MODEL_KEY = 'dsh-settings-search:ai-model'
409
465
  const AI_API_KEY_KEY = 'dsh-settings-search:ai-api-key'
466
+ /** Session-only API key storage avoids durable browser storage exposure. */
467
+ const readApiKey = () => {
468
+ const legacy = typeof localStorage !== 'undefined' ? (localStorage.getItem(AI_API_KEY_KEY) ?? '').trim() : ''
469
+ if (legacy !== '') {
470
+ if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(AI_API_KEY_KEY, legacy)
471
+ if (typeof localStorage !== 'undefined') localStorage.removeItem(AI_API_KEY_KEY)
472
+ return legacy
473
+ }
474
+ return typeof sessionStorage !== 'undefined' ? (sessionStorage.getItem(AI_API_KEY_KEY) ?? '').trim() : ''
475
+ }
476
+ const writeApiKey = (value) => {
477
+ const apiKey = String(value ?? '').trim()
478
+ if (typeof sessionStorage !== 'undefined') {
479
+ if (apiKey === '') sessionStorage.removeItem(AI_API_KEY_KEY)
480
+ else sessionStorage.setItem(AI_API_KEY_KEY, apiKey)
481
+ }
482
+ if (typeof localStorage !== 'undefined') localStorage.removeItem(AI_API_KEY_KEY)
483
+ }
410
484
  const aiSearchEnabled = () => {
411
485
  if (typeof localStorage === 'undefined') return false
412
486
  return localStorage.getItem(AI_STORAGE_KEY) === '1'
@@ -417,26 +491,53 @@ window.__ModuleLoader__.load({
417
491
  window.dispatchEvent(new CustomEvent(AI_TOGGLE_EVENT, { detail: enabled }))
418
492
  }
419
493
  const aiConfig = () => {
420
- if (typeof localStorage === 'undefined') return { provider: 'openai', baseUrl: '', model: '', apiKey: '' }
421
- const providerRaw = (localStorage.getItem(AI_PROVIDER_KEY) ?? 'openai').trim()
494
+ if (typeof localStorage === 'undefined' && typeof sessionStorage === 'undefined') {
495
+ return { provider: 'openai', baseUrl: '', model: '', apiKey: '' }
496
+ }
497
+ const providerRaw = (localStorage?.getItem(AI_PROVIDER_KEY) ?? 'openai').trim()
422
498
  return {
423
499
  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(),
500
+ baseUrl: (localStorage?.getItem(AI_BASE_URL_KEY) ?? '').trim(),
501
+ model: (localStorage?.getItem(AI_MODEL_KEY) ?? '').trim(),
502
+ apiKey: readApiKey(),
427
503
  }
428
504
  }
429
505
  const setAiConfig = (cfg) => {
506
+ if (typeof localStorage === 'undefined' && typeof sessionStorage === 'undefined') return
507
+ writeApiKey(cfg.apiKey)
430
508
  if (typeof localStorage === 'undefined') return
431
509
  localStorage.setItem(AI_PROVIDER_KEY, cfg.provider === 'anthropic' ? 'anthropic' : 'openai')
432
510
  localStorage.setItem(AI_BASE_URL_KEY, cfg.baseUrl ?? '')
433
511
  localStorage.setItem(AI_MODEL_KEY, cfg.model ?? '')
434
- localStorage.setItem(AI_API_KEY_KEY, cfg.apiKey ?? '')
435
512
  window.dispatchEvent(new CustomEvent(AI_CONFIG_EVENT))
436
513
  }
437
514
  /** First-run onboarding: remembered via localStorage so it shows once. */
438
515
  const ONBOARD_KEY = 'dsh-settings-search:onboarded'
439
516
  const WELCOME_EVENT = 'dsh-settings-search:welcome'
517
+ const SEARCH_STYLE_KEY = 'dsh-settings-search:style'
518
+ const SEARCH_STYLE_EVENT = 'dsh-settings-search:style-change'
519
+ const SEARCH_SHORTCUT_KEY = 'dsh-settings-search:shortcut-enabled'
520
+ const SEARCH_SHORTCUT_EVENT = 'dsh-settings-search:shortcut-change'
521
+ const SEARCH_STYLES = ['sidebar', 'top', 'page']
522
+ const readSearchStyle = () => {
523
+ if (typeof localStorage === 'undefined') return 'sidebar'
524
+ const style = localStorage.getItem(SEARCH_STYLE_KEY)
525
+ return SEARCH_STYLES.includes(style) ? style : 'sidebar'
526
+ }
527
+ const setStoredSearchStyle = (style) => {
528
+ if (typeof localStorage === 'undefined' || !SEARCH_STYLES.includes(style)) return
529
+ localStorage.setItem(SEARCH_STYLE_KEY, style)
530
+ window.dispatchEvent(new CustomEvent(SEARCH_STYLE_EVENT, { detail: style }))
531
+ }
532
+ const searchShortcutEnabled = () => {
533
+ if (typeof localStorage === 'undefined') return true
534
+ return localStorage.getItem(SEARCH_SHORTCUT_KEY) !== '0'
535
+ }
536
+ const setSearchShortcutEnabled = (enabled) => {
537
+ if (typeof localStorage === 'undefined') return
538
+ localStorage.setItem(SEARCH_SHORTCUT_KEY, enabled ? '1' : '0')
539
+ window.dispatchEvent(new CustomEvent(SEARCH_SHORTCUT_EVENT, { detail: enabled }))
540
+ }
440
541
  const welcomed = () => {
441
542
  if (typeof localStorage === 'undefined') return true
442
543
  return localStorage.getItem(ONBOARD_KEY) === '1'
@@ -446,9 +547,6 @@ window.__ModuleLoader__.load({
446
547
  localStorage.setItem(ONBOARD_KEY, '1')
447
548
  window.dispatchEvent(new CustomEvent(WELCOME_EVENT, { detail: false }))
448
549
  }
449
- const openWelcome = () => {
450
- window.dispatchEvent(new CustomEvent(WELCOME_EVENT, { detail: true }))
451
- }
452
550
  const extractAiKeywords = (raw) => {
453
551
  const text = String(raw ?? '').replace(/^```(?:json)?/i, '').replace(/```$/, '').trim()
454
552
  const start = text.indexOf('{')
@@ -489,17 +587,17 @@ window.__ModuleLoader__.load({
489
587
  return url.origin + path.replace(/\/+$/, '')
490
588
  }
491
589
  const chatCompletionsUrl = (baseUrl) => {
590
+ // safeBaseUrl already strips trailing slashes
492
591
  const safe = safeBaseUrl(baseUrl)
493
592
  if (!safe) return ''
494
- const trimmed = safe.replace(/\/+$/, '')
495
- return /\/chat\/completions$/i.test(trimmed) ? trimmed : `${trimmed}/chat/completions`
593
+ return /\/chat\/completions$/i.test(safe) ? safe : `${safe}/chat/completions`
496
594
  }
497
595
  const anthropicMessagesUrl = (baseUrl) => {
596
+ // safeBaseUrl already strips trailing slashes
498
597
  const safe = safeBaseUrl(baseUrl)
499
598
  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`
599
+ if (/\/v1\/messages$/i.test(safe)) return safe
600
+ return /\/v1$/i.test(safe) ? `${safe}/messages` : `${safe}/v1/messages`
503
601
  }
504
602
  const requestUserAi = async (query, labels, signal) => {
505
603
  const { provider, baseUrl, model, apiKey } = aiConfig()
@@ -545,6 +643,7 @@ window.__ModuleLoader__.load({
545
643
  method: 'POST',
546
644
  headers,
547
645
  signal,
646
+ redirect: 'error',
548
647
  body: JSON.stringify(body),
549
648
  })
550
649
  if (!response.ok) throw new Error(`HTTP ${response.status}`)
@@ -573,7 +672,11 @@ window.__ModuleLoader__.load({
573
672
  const cacheKey = query.toLowerCase()
574
673
  if (AI_CACHE.has(cacheKey)) return AI_CACHE.get(cacheKey)
575
674
  const keywords = await requestUserAi(query, labels, signal)
576
- if (keywords.length > 0) AI_CACHE.set(cacheKey, keywords)
675
+ if (keywords.length > 0) {
676
+ // bound the cache so a long session cannot grow it without limit
677
+ if (AI_CACHE.size >= 100) AI_CACHE.clear()
678
+ AI_CACHE.set(cacheKey, keywords)
679
+ }
577
680
  return keywords
578
681
  }
579
682
 
@@ -583,16 +686,17 @@ window.__ModuleLoader__.load({
583
686
  * Returns { ok, code, detail } where code is a stable error kind used to
584
687
  * look up a localized hint in the UI.
585
688
  */
689
+ const HTTP_TEST_CODES = {
690
+ 400: 'bad-request',
691
+ 401: 'auth',
692
+ 403: 'forbidden',
693
+ 404: 'not-found',
694
+ 408: 'timeout',
695
+ 429: 'rate-limit',
696
+ }
586
697
  const testHttpCode = (status) => {
587
698
  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'
699
+ return HTTP_TEST_CODES[code] ?? (code >= 500 && code <= 599 ? 'server' : 'http')
596
700
  }
597
701
  const testAiService = async (cfg) => {
598
702
  const provider = cfg.provider === 'anthropic' ? 'anthropic' : 'openai'
@@ -626,6 +730,7 @@ window.__ModuleLoader__.load({
626
730
  const response = await fetch(requestUrl, {
627
731
  method: 'POST',
628
732
  headers,
733
+ redirect: 'error',
629
734
  body: JSON.stringify(body),
630
735
  })
631
736
  if (!response.ok) return { ok: false, code: testHttpCode(response.status), detail: `HTTP ${response.status}` }
@@ -687,6 +792,24 @@ window.__ModuleLoader__.load({
687
792
  .sss-ai-note { margin: 0; padding: 6px 8px; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; }
688
793
  .sss-empty { margin: 0; padding: 10px 8px; color: var(--dsw-alias-label-secondary); font-size: 13px; }
689
794
  .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; }
795
+ .sss-wrap.sss-top { position: absolute; top: 20px; left: 198px; right: 152px; z-index: 950; width: auto; height: 28px; gap: 8px; }
796
+ .sss-top .sss-title { display: none; }
797
+ .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); }
798
+ .sss-top .sss-input { font-size: 15px; }
799
+ .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; }
800
+ .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; }
801
+ .sss-trigger:hover { border-color: var(--dsw-alias-label-dimmed); color: var(--dsw-alias-label-secondary); }
802
+ .sss-trigger:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; }
803
+ .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%)); }
804
+ .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); }
805
+ .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); }
806
+ .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); }
807
+ .sss-page-input::placeholder { color: var(--dsw-alias-label-dimmed); }
808
+ .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; }
809
+ .sss-page-close:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
810
+ .sss-page-results { flex: 1; min-height: 0; overflow: auto; padding: 8px; }
811
+ .sss-page-results .sss-group { padding: 2px; }
812
+ .sss-page-results .sss-item { padding: 10px 8px; border-radius: 8px; }
690
813
  @keyframes sssFlashBg { 0% { background-color: var(--dsw-alias-interactive-bg-hover); } 100% { background-color: transparent; } }
691
814
  .sss-flash { animation: sssFlashBg 1.8s ease-out; border-radius: 8px; }
692
815
  .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 +840,10 @@ window.__ModuleLoader__.load({
717
840
  .sss-vr-btn:hover:not(:disabled) { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
718
841
  .sss-vr-btn:disabled { opacity: .4; cursor: default; }
719
842
  .sss-vr-btn:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
843
+ .sss-vr-segment { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
844
+ .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; }
845
+ .sss-vr-seg:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
846
+ .sss-vr-seg.is-active { color: var(--dsw-alias-bg-layer-3); background: var(--dsw-alias-label-primary); border-color: transparent; }
720
847
  .sss-vr-primary { background: var(--dsw-alias-label-primary); color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
721
848
  .sss-vr-primary:hover:not(:disabled) { color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
722
849
  .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 +924,6 @@ window.__ModuleLoader__.load({
797
924
  function createModel(ctx) {
798
925
  /** id → harvested visible title, per slot. */
799
926
  const harvest = new Map()
800
- let harvestSeq = 0
801
927
 
802
928
  const entriesOf = (slot) => {
803
929
  try { return ctx.slots.entries(slot) ?? [] } catch { return [] }
@@ -834,7 +960,7 @@ window.__ModuleLoader__.load({
834
960
  const trimDescription = (text) => {
835
961
  const t = (text ?? '').replace(/\s+/g, ' ').trim()
836
962
  if (t === '') return ''
837
- const re = /[\s,,。;;](关闭后|开启后|启用后|禁用后|创建后|使用时|点击后|勾选后|开启时|关闭时|完成后|启用时|禁用时|开始后|停止后)/
963
+ const re = /(?:[\s,,。;;])?(关闭后|开启后|启用后|禁用后|创建后|使用时|点击后|勾选后|开启时|关闭时|完成后|启用时|禁用时|开始后|停止后)/
838
964
  const m = re.exec(t)
839
965
  if (m) {
840
966
  const head = t.slice(0, m.index).trim()
@@ -914,7 +1040,7 @@ window.__ModuleLoader__.load({
914
1040
  if (containers.length === 1 && containers[0].childElementCount > 0) {
915
1041
  nodes = [...containers[0].children]
916
1042
  } else {
917
- nodes = containers.filter((n) => n.childElementCount >= 0)
1043
+ nodes = containers
918
1044
  }
919
1045
  if (nodes.length === 0) continue
920
1046
  map = harvest.get(cfg.slot)
@@ -972,7 +1098,6 @@ window.__ModuleLoader__.load({
972
1098
  }
973
1099
  }
974
1100
  } catch { /* section mining is best-effort */ }
975
- if (grew) harvestSeq += 1
976
1101
  return grew
977
1102
  }
978
1103
 
@@ -1008,6 +1133,10 @@ window.__ModuleLoader__.load({
1008
1133
  const parentLabel = (parentId) =>
1009
1134
  sections.find((s) => s.id === parentId)?.label ?? parentId
1010
1135
  const out = []
1136
+ // Set-based dedup: keys and "parentLabel\u0000label" pairs replace the
1137
+ // former out.some(...) scans, which were O(n²) on every keystroke.
1138
+ const seenKeys = new Set()
1139
+ const seenPairs = new Set()
1011
1140
  for (const cfg of DEEP_SLOTS) {
1012
1141
  const mapped = harvest.get(cfg.slot)
1013
1142
  sortEntries(entriesOf(cfg.slot)).forEach((entry, i) => {
@@ -1020,8 +1149,10 @@ window.__ModuleLoader__.load({
1020
1149
  const label = staticLabel(entry, cfg) || hitObj?.title || ''
1021
1150
  if (label === '') return // unmapped, unharvested — invisible until it renders once
1022
1151
  const key = cfg.slot + '#' + id
1023
- if (out.some((e) => e.key === key)) return
1152
+ if (seenKeys.has(key)) return
1024
1153
  const pageLabel = parentLabel(cfg.parent)
1154
+ seenKeys.add(key)
1155
+ seenPairs.add(pageLabel + '\u0000' + label)
1025
1156
  out.push({
1026
1157
  kind: 'option',
1027
1158
  key,
@@ -1038,7 +1169,9 @@ window.__ModuleLoader__.load({
1038
1169
  hitObj.inner.forEach((op, oi) => {
1039
1170
  if (op.label === '' || op.label === label) return
1040
1171
  const innerKey = key + '#o' + oi
1041
- if (out.some((e) => e.key === innerKey)) return
1172
+ if (seenKeys.has(innerKey)) return
1173
+ seenKeys.add(innerKey)
1174
+ seenPairs.add(label + '\u0000' + op.label)
1042
1175
  out.push({
1043
1176
  kind: 'option',
1044
1177
  key: innerKey,
@@ -1072,9 +1205,11 @@ window.__ModuleLoader__.load({
1072
1205
  rec.inner.forEach((op, oi) => {
1073
1206
  if (op.label === '' || op.label === label) return
1074
1207
  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
1208
+ if (seenKeys.has(innerKey)) return
1209
+ const pair = label + '\u0000' + op.label
1210
+ if (seenPairs.has(pair)) return
1211
+ seenKeys.add(innerKey)
1212
+ seenPairs.add(pair)
1078
1213
  out.push({
1079
1214
  kind: 'option',
1080
1215
  key: innerKey,
@@ -1099,20 +1234,31 @@ window.__ModuleLoader__.load({
1099
1234
  : a.order - b.order)
1100
1235
  }
1101
1236
 
1102
- return { readSections, readDeep, runHarvest, bump: () => harvestSeq }
1237
+ return { readSections, readDeep, runHarvest }
1103
1238
  }
1104
1239
 
1105
1240
  /* ---------- DOM-level navigation into the settings shell ---------- */
1106
1241
 
1242
+ /**
1243
+ * Root element that hosts the settings panel's nav buttons. Derived from
1244
+ * the plugin's own mounted UI so an unrelated or plugin-owned dialog can
1245
+ * never become the click root; the first-dialog fallback only applies
1246
+ * when the plugin's anchors are not mounted.
1247
+ */
1248
+ const panelRoot = () => {
1249
+ if (typeof document === 'undefined') return null
1250
+ const anchor = document.querySelector('.sss-wrap, .sss-vr')
1251
+ if (anchor) return anchor.closest('[role="dialog"]') ?? document.body
1252
+ return document.querySelector('[role="dialog"]') ?? document.body
1253
+ }
1107
1254
  const panelButtons = () => {
1108
- if (typeof document === 'undefined') return []
1109
- const root = document.querySelector('[role="dialog"]') ?? document.body
1110
- return [...root.querySelectorAll('button')]
1255
+ const root = panelRoot()
1256
+ return root === null ? [] : [...root.querySelectorAll('button')]
1111
1257
  }
1112
1258
  const clickNavButton = (wantText) => {
1113
1259
  const want = String(wantText ?? '').trim()
1114
1260
  if (want === '') return false
1115
- const buttons = panelButtons()
1261
+ const buttons = panelButtons().filter((b) => /_navCell/.test(String(b.className || '')))
1116
1262
  const textOf = (b) => (b.textContent ?? '').trim()
1117
1263
  const hit = buttons.find((b) => textOf(b) === want)
1118
1264
  if (!hit) return false
@@ -1122,8 +1268,8 @@ window.__ModuleLoader__.load({
1122
1268
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
1123
1269
  /** Label of the currently-active first-level settings nav item. */
1124
1270
  const activeSectionLabel = () => {
1125
- if (typeof document === 'undefined') return ''
1126
- const root = document.querySelector('[role="dialog"]') ?? document.body
1271
+ const root = panelRoot()
1272
+ if (root === null) return ''
1127
1273
  const isTab = (b) => b.getAttribute('role') === 'tab'
1128
1274
  const buttons = [...root.querySelectorAll('button')].filter((b) => !isTab(b))
1129
1275
  const byCurrent = buttons.find((b) => b.getAttribute('aria-current') === 'true')
@@ -1153,11 +1299,9 @@ window.__ModuleLoader__.load({
1153
1299
  model.runHarvest()
1154
1300
  visited += 1
1155
1301
  }
1156
- if (original !== '') {
1157
- if (clickNavButton(original)) {
1158
- await delay(160)
1159
- model.runHarvest()
1160
- }
1302
+ if (clickNavButton(original)) {
1303
+ await delay(160)
1304
+ model.runHarvest()
1161
1305
  }
1162
1306
  } finally {
1163
1307
  if (typeof onDone === 'function') onDone()
@@ -1168,13 +1312,10 @@ window.__ModuleLoader__.load({
1168
1312
 
1169
1313
  function createNavigator(ctx) {
1170
1314
  /** 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
- }
1315
+ const jumpToPage = async (item) => clickNavButton(item.label)
1175
1316
  /** Jump to an in-page option: open its page, select its tab, flash its row. */
1176
1317
  const jumpToOption = async (item) => {
1177
- const opened = await Promise.resolve(clickNavButton(item.pageLabel ?? item.parentLabel))
1318
+ const opened = clickNavButton(item.pageLabel ?? item.parentLabel)
1178
1319
  if (opened !== true) { flashOptionRow(item); return false }
1179
1320
  await delay(160)
1180
1321
  const label = item.label
@@ -1242,6 +1383,36 @@ window.__ModuleLoader__.load({
1242
1383
  const model = createModel(ctx)
1243
1384
  const navigator = createNavigator(ctx)
1244
1385
 
1386
+ /** Read the model fields from DSH; credentials are never requested. */
1387
+ const readDshModelConfig = async () => {
1388
+ const connection = ctx.get('connection')
1389
+ const response = await connection.api.settings.describe({})
1390
+ if (!response.result.ok) throw new Error(response.result.error?.message || 'settings.describe failed')
1391
+ const namespaces = new Map(response.result.value.namespaces.map((view) => [view.ns, view]))
1392
+ const defaultModel = namespaces.get('agent-default-model')?.value ?? {}
1393
+ const providerId = String(defaultModel.provider ?? '').trim()
1394
+ const modelId = String(defaultModel.model ?? '').trim()
1395
+ const readPath = (value, path) => (path ?? []).reduce((current, key) => current?.[key], value)
1396
+ const providersResponse = await connection.api.llm.providers({}).catch(() => null)
1397
+ const providerRow = providersResponse?.result?.ok
1398
+ ? providersResponse.result.value.providers.find((entry) => entry.provider === providerId)
1399
+ : undefined
1400
+ const modelNamespace = namespaces.get(providerRow?.settingsNs || 'llm-pi-ai')
1401
+ const providerProfile = providerRow
1402
+ ? readPath(modelNamespace?.value, providerRow.settingsPath) ?? {}
1403
+ : modelNamespace?.value?.providers?.[providerId] ?? {}
1404
+ const configuredModels = Array.isArray(providerProfile.models) ? providerProfile.models : []
1405
+ const modelProfile = configuredModels.find((entry) => entry?.id === modelId) ?? {}
1406
+ const protocol = String(modelProfile.api ?? providerProfile.api ?? '').trim()
1407
+ const baseUrl = String(providerProfile.baseURL ?? '').trim()
1408
+ if (!modelId || !baseUrl) return null
1409
+ return {
1410
+ provider: protocol === 'anthropic-messages' ? 'anthropic' : 'openai',
1411
+ baseUrl,
1412
+ model: modelId,
1413
+ }
1414
+ }
1415
+
1245
1416
  const SearchIcon = React.createElement('svg', {
1246
1417
  className: 'sss-icon',
1247
1418
  viewBox: '0 0 16.5 16.5',
@@ -1295,7 +1466,6 @@ window.__ModuleLoader__.load({
1295
1466
  React.createElement('div', { className: 'sss-wl-footer' }, [
1296
1467
  React.createElement('button', { className: 'sss-wl-btn sss-wl-primary', type: 'button', onClick: onClose }, t('wlStart')),
1297
1468
  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
1469
  ]),
1300
1470
  ]),
1301
1471
  ])
@@ -1326,6 +1496,9 @@ window.__ModuleLoader__.load({
1326
1496
  function SettingsHeaderSearch() {
1327
1497
  const [query, setQuery] = React.useState('')
1328
1498
  const [open, setOpen] = React.useState(false)
1499
+ const [searchStyle, setSearchStyleState] = React.useState(readSearchStyle)
1500
+ const [pageOpen, setPageOpen] = React.useState(false)
1501
+ const [shortcutEnabled, setShortcutEnabled] = React.useState(searchShortcutEnabled)
1329
1502
  const [showWelcome, setShowWelcome] = React.useState(() => !welcomed())
1330
1503
  const [tick, setTick] = React.useState(0)
1331
1504
  const [warming, setWarming] = React.useState(false)
@@ -1343,17 +1516,29 @@ window.__ModuleLoader__.load({
1343
1516
  ctx.locale.subscribe(forceTick),
1344
1517
  ]
1345
1518
  // Grow the index whenever the panel body re-renders (visits new pages).
1519
+ // DOM mutations arrive in bursts (animations, list mounts), so the
1520
+ // harvest pass runs at most once per throttle window (trailing edge).
1346
1521
  let warmAlive = true
1347
1522
  let warmAttempts = 0
1523
+ let mutatorAlive = true
1524
+ let throttleTimer = null
1525
+ const onMutate = () => {
1526
+ if (!mutatorAlive) return
1527
+ if (model.runHarvest()) setTick((x) => x + 1)
1528
+ if (!warmed && activeSectionLabel() !== '') {
1529
+ void warmAllSections(model, () => setWarming(true), () => setWarming(false)).then((done) => {
1530
+ if (!warmAlive) return
1531
+ if (done) setTick((x) => x + 1)
1532
+ })
1533
+ }
1534
+ }
1348
1535
  const observer = typeof MutationObserver === 'function'
1349
1536
  ? 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
- }
1537
+ if (throttleTimer !== null) return
1538
+ throttleTimer = setTimeout(() => {
1539
+ throttleTimer = null
1540
+ onMutate()
1541
+ }, 120)
1357
1542
  })
1358
1543
  : null
1359
1544
  observer?.observe(document.body, { childList: true, subtree: true })
@@ -1372,9 +1557,11 @@ window.__ModuleLoader__.load({
1372
1557
  }, 800)
1373
1558
  return () => {
1374
1559
  warmAlive = false
1560
+ mutatorAlive = false
1375
1561
  for (const off of offs) off()
1376
1562
  observer?.disconnect()
1377
1563
  clearTimeout(warm)
1564
+ if (throttleTimer !== null) clearTimeout(throttleTimer)
1378
1565
  }
1379
1566
  }, [])
1380
1567
 
@@ -1384,6 +1571,27 @@ window.__ModuleLoader__.load({
1384
1571
  return () => window.removeEventListener(WELCOME_EVENT, onWelcome)
1385
1572
  }, [])
1386
1573
 
1574
+ React.useEffect(() => {
1575
+ const sync = () => setSearchStyleState(readSearchStyle())
1576
+ window.addEventListener(SEARCH_STYLE_EVENT, sync)
1577
+ return () => window.removeEventListener(SEARCH_STYLE_EVENT, sync)
1578
+ }, [])
1579
+
1580
+ // A style switch must not carry an open popup from the previous layout
1581
+ // into the new one. The host can keep this component mounted while the
1582
+ // settings choice changes, so reset transient view state explicitly.
1583
+ React.useEffect(() => {
1584
+ setOpen(false)
1585
+ setPageOpen(false)
1586
+ setHint(null)
1587
+ }, [searchStyle])
1588
+
1589
+ React.useEffect(() => {
1590
+ const sync = () => setShortcutEnabled(searchShortcutEnabled())
1591
+ window.addEventListener(SEARCH_SHORTCUT_EVENT, sync)
1592
+ return () => window.removeEventListener(SEARCH_SHORTCUT_EVENT, sync)
1593
+ }, [])
1594
+
1387
1595
  React.useEffect(() => {
1388
1596
  const sync = () => setAiEnabled(aiSearchEnabled())
1389
1597
  const onConfig = () => forceTick()
@@ -1406,7 +1614,7 @@ window.__ModuleLoader__.load({
1406
1614
  tokens.every((tok) => tokenMatch(haystackLower, item.keys, tok) !== null)
1407
1615
  const kwMatch = (item, haystackLower, kw) => {
1408
1616
  if (/^[\x00-\x7f]+$/.test(kw)) {
1409
- if (new RegExp(`\\b${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(haystackLower)) return true
1617
+ if (wordBoundaryRegex(kw).test(haystackLower)) return true
1410
1618
  } else if (haystackLower.includes(kw)) return true
1411
1619
  if (item.keys === null) return false
1412
1620
  const kwKeys = pinyinKeys(kw)
@@ -1498,15 +1706,40 @@ window.__ModuleLoader__.load({
1498
1706
  const [active, setActive] = React.useState(0)
1499
1707
  const popRef = React.useRef(null)
1500
1708
  const inputRef = React.useRef(null)
1709
+ const pageMode = searchStyle === 'page'
1710
+ const topMode = searchStyle === 'top'
1501
1711
  const activeIndex = Math.min(active, Math.max(results.length - 1, 0))
1502
1712
 
1503
1713
  React.useEffect(() => { setActive(0) }, [query])
1714
+ React.useEffect(() => {
1715
+ if (!pageMode || !pageOpen) return
1716
+ const timer = setTimeout(() => inputRef.current?.focus(), 0)
1717
+ return () => clearTimeout(timer)
1718
+ }, [pageMode, pageOpen])
1719
+ React.useEffect(() => {
1720
+ if (!shortcutEnabled) return
1721
+ const onKeyDown = (event) => {
1722
+ if (!event.ctrlKey && !event.metaKey) return
1723
+ if (event.altKey || event.shiftKey || event.key !== 'f' && event.key !== 'F') return
1724
+ event.preventDefault()
1725
+ if (pageMode) {
1726
+ setPageOpen(true)
1727
+ return
1728
+ }
1729
+ setHint(null)
1730
+ setOpen(true)
1731
+ inputRef.current?.focus()
1732
+ }
1733
+ window.addEventListener('keydown', onKeyDown)
1734
+ return () => window.removeEventListener('keydown', onKeyDown)
1735
+ }, [pageMode, shortcutEnabled])
1504
1736
  React.useEffect(() => {
1505
1737
  const el = popRef.current?.querySelector('.sss-item.is-active')
1506
1738
  el?.scrollIntoView?.({ block: 'nearest' })
1507
1739
  }, [activeIndex, q])
1508
1740
  const pick = (item) => {
1509
1741
  setOpen(false)
1742
+ setPageOpen(false)
1510
1743
  log('info', '打开设置', { kind: item.kind, id: item.id || item.key || '', label: String(item.label).slice(0, 60) })
1511
1744
  const jumped = item.kind === 'page'
1512
1745
  ? navigator.jumpToPage(item)
@@ -1587,6 +1820,159 @@ window.__ModuleLoader__.load({
1587
1820
  kindBadge(via, t('option')),
1588
1821
  ])
1589
1822
 
1823
+ const resultsNode = none
1824
+ ? (aiStatus === 'loading'
1825
+ ? React.createElement('p', { className: 'sss-ai-note' }, t('aiThinking'))
1826
+ : aiStatus === 'error'
1827
+ ? React.createElement('p', { className: 'sss-ai-note' }, t('aiUnavailable'))
1828
+ : (aiEnabled && !aiConfigured)
1829
+ ? React.createElement('p', { className: 'sss-ai-note' }, t('aiNeedConfig'))
1830
+ : React.createElement('p', { className: 'sss-empty' }, t('empty')))
1831
+ : React.createElement('div', { className: 'sss-groups' }, [
1832
+ shownPages.length > 0 ? React.createElement('div', { className: 'sss-group' }, [
1833
+ React.createElement('div', { className: 'sss-group-title' }, t('pages')),
1834
+ React.createElement('ul', { className: 'sss-list' },
1835
+ shownPages.map((item, i) => renderPage(item, i))),
1836
+ ]) : null,
1837
+ shownOptions.length > 0 ? React.createElement('div', { className: 'sss-group' }, [
1838
+ React.createElement('div', { className: 'sss-group-title' }, t('options')),
1839
+ React.createElement('ul', { className: 'sss-list' },
1840
+ shownOptions.map((item, i) => renderOption(item, shownPages.length + i))),
1841
+ ]) : null,
1842
+ related.length > 0 ? React.createElement('div', { className: 'sss-group' }, [
1843
+ React.createElement('div', { className: 'sss-group-title' }, t('guess')),
1844
+ React.createElement('ul', { className: 'sss-list' },
1845
+ related.map((r, i) => r.kind === 'page'
1846
+ ? renderPage(r.item, shownPages.length + shownOptions.length + i, r.via)
1847
+ : renderOption(r.item, shownPages.length + shownOptions.length + i, r.via))),
1848
+ ]) : null,
1849
+ ])
1850
+
1851
+ const searchInput = React.createElement('input', {
1852
+ className: pageMode ? 'sss-input sss-page-input' : 'sss-input',
1853
+ value: query,
1854
+ placeholder: t('placeholder'),
1855
+ spellCheck: false,
1856
+ ref: inputRef,
1857
+ onKeyDown: (e) => {
1858
+ if (e.key === 'ArrowDown') {
1859
+ e.preventDefault()
1860
+ if (results.length > 0) setActive(Math.min(activeIndex + 1, results.length - 1))
1861
+ } else if (e.key === 'ArrowUp') {
1862
+ e.preventDefault()
1863
+ setActive(Math.max(activeIndex - 1, 0))
1864
+ } else if (e.key === 'Enter') {
1865
+ const item = results[activeIndex]
1866
+ if (item) { e.preventDefault(); pick(item) }
1867
+ } else if (e.key === 'Escape') {
1868
+ if (pageMode) setPageOpen(false)
1869
+ else setOpen(false)
1870
+ }
1871
+ },
1872
+ onChange: (e) => { setQuery(e.target.value); setHint(null); setOpen(true) },
1873
+ onFocus: () => setOpen(true),
1874
+ onBlur: () => setOpen(false),
1875
+ })
1876
+ const clearButton = query !== '' ? React.createElement('button', {
1877
+ className: 'sss-clear',
1878
+ type: 'button',
1879
+ onClick: () => { setQuery(''); setHint(null); setOpen(false) },
1880
+ }, '✕') : null
1881
+ const pageTriggerButton = React.createElement('button', {
1882
+ className: 'sss-trigger sss-top-trigger',
1883
+ type: 'button',
1884
+ onClick: () => setPageOpen(true),
1885
+ }, [SearchIcon, t('openSearch')])
1886
+
1887
+ if (pageMode && pageOpen) {
1888
+ return React.createElement(React.Fragment, null, [
1889
+ React.createElement('div', { className: 'sss-wrap' }, [
1890
+ React.createElement('div', { className: 'sss-title' }, t('title')),
1891
+ showWelcome
1892
+ ? React.createElement(Welcome, { t, onClose: dismissWelcome, onOpenPanel: openPanelFromWelcome })
1893
+ : null,
1894
+ ]),
1895
+ React.createElement('div', { className: 'sss-wrap sss-top' }, [pageTriggerButton]),
1896
+ React.createElement('div', {
1897
+ className: 'sss-page-overlay',
1898
+ onMouseDown: (event) => { if (event.target === event.currentTarget) setPageOpen(false) },
1899
+ }, [
1900
+ React.createElement('div', {
1901
+ className: 'sss-page',
1902
+ role: 'dialog',
1903
+ 'aria-modal': true,
1904
+ 'aria-label': t('placeholder'),
1905
+ }, [
1906
+ React.createElement('div', { className: 'sss-page-head' }, [
1907
+ React.createElement('button', {
1908
+ className: 'sss-icon-btn',
1909
+ type: 'button',
1910
+ 'aria-label': t('searchBtn'),
1911
+ title: t('searchBtn'),
1912
+ onClick: () => inputRef.current?.focus(),
1913
+ }, [SearchIcon]),
1914
+ searchInput,
1915
+ clearButton,
1916
+ React.createElement('button', {
1917
+ className: 'sss-page-close',
1918
+ type: 'button',
1919
+ 'aria-label': 'Close',
1920
+ onClick: () => setPageOpen(false),
1921
+ }, '✕'),
1922
+ ]),
1923
+ React.createElement('div', { className: 'sss-page-results' }, [
1924
+ warming ? React.createElement('p', { className: 'sss-warm-note' }, t('warming')) : null,
1925
+ q !== '' ? resultsNode : null,
1926
+ hint !== null
1927
+ ? React.createElement('div', { className: 'sss-guide' }, t('jumpFail').replace('{path}', hint))
1928
+ : null,
1929
+ ]),
1930
+ ]),
1931
+ ]),
1932
+ ])
1933
+ }
1934
+
1935
+ if (pageMode) {
1936
+ return React.createElement(React.Fragment, null, [
1937
+ React.createElement('div', { className: 'sss-wrap' }, [
1938
+ React.createElement('div', { className: 'sss-title' }, t('title')),
1939
+ showWelcome
1940
+ ? React.createElement(Welcome, { t, onClose: dismissWelcome, onOpenPanel: openPanelFromWelcome })
1941
+ : null,
1942
+ ]),
1943
+ React.createElement('div', { className: 'sss-wrap sss-top' }, [pageTriggerButton]),
1944
+ ])
1945
+ }
1946
+
1947
+ if (topMode) {
1948
+ return React.createElement(React.Fragment, null, [
1949
+ React.createElement('div', { className: 'sss-wrap' }, [
1950
+ React.createElement('div', { className: 'sss-title' }, t('title')),
1951
+ showWelcome
1952
+ ? React.createElement(Welcome, { t, onClose: dismissWelcome, onOpenPanel: openPanelFromWelcome })
1953
+ : null,
1954
+ ]),
1955
+ React.createElement('div', { className: 'sss-wrap sss-top' }, [
1956
+ React.createElement('div', { className: 'sss-box' }, [
1957
+ React.createElement('button', {
1958
+ className: 'sss-icon-btn',
1959
+ type: 'button',
1960
+ 'aria-label': t('searchBtn'),
1961
+ title: t('searchBtn'),
1962
+ onClick: () => inputRef.current?.focus(),
1963
+ }, [SearchIcon]),
1964
+ searchInput,
1965
+ clearButton,
1966
+ ]),
1967
+ warming ? React.createElement('p', { className: 'sss-warm-note' }, t('warming')) : null,
1968
+ open && q !== '' ? React.createElement('div', { className: 'sss-pop', ref: popRef }, [resultsNode]) : null,
1969
+ hint !== null
1970
+ ? React.createElement('div', { className: 'sss-guide' }, t('jumpFail').replace('{path}', hint))
1971
+ : null,
1972
+ ]),
1973
+ ])
1974
+ }
1975
+
1590
1976
  return React.createElement('div', { className: 'sss-wrap' }, [
1591
1977
  React.createElement('div', { className: 'sss-title' }, t('title')),
1592
1978
  React.createElement('div', { className: 'sss-box' }, [
@@ -1597,66 +1983,11 @@ window.__ModuleLoader__.load({
1597
1983
  title: t('searchBtn'),
1598
1984
  onClick: () => inputRef.current?.focus(),
1599
1985
  }, [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,
1986
+ searchInput,
1987
+ clearButton,
1629
1988
  ]),
1630
1989
  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,
1990
+ open && q !== '' ? React.createElement('div', { className: 'sss-pop', ref: popRef }, [resultsNode]) : null,
1660
1991
  hint !== null
1661
1992
  ? React.createElement('div', { className: 'sss-guide' }, t('jumpFail').replace('{path}', hint))
1662
1993
  : null,
@@ -1675,8 +2006,19 @@ window.__ModuleLoader__.load({
1675
2006
  return 0
1676
2007
  }
1677
2008
 
2009
+ /** Auto-clear a transient confirmation flag (copied ✓, cleared…) after ms. */
2010
+ const useAutoReset = (active, reset, ms = 1600) => {
2011
+ React.useEffect(() => {
2012
+ if (!active) return
2013
+ const off = setTimeout(reset, ms)
2014
+ return () => clearTimeout(off)
2015
+ }, [active])
2016
+ }
2017
+
1678
2018
  function UpdatePanel() {
1679
2019
  const [open, setOpen] = React.useState(true)
2020
+ const [searchStyle, setSearchStyleState] = React.useState(readSearchStyle)
2021
+ const [shortcutOn, setShortcutOn] = React.useState(searchShortcutEnabled)
1680
2022
  const [state, setState] = React.useState('idle') // idle | loading | newest | outdated | error
1681
2023
  const [latest, setLatest] = React.useState('')
1682
2024
  const [copied, setCopied] = React.useState(false)
@@ -1692,6 +2034,8 @@ window.__ModuleLoader__.load({
1692
2034
  const [aiSaved, setAiSaved] = React.useState(false)
1693
2035
  const [testState, setTestState] = React.useState('idle') // idle | loading | ok | fail
1694
2036
  const [testMsg, setTestMsg] = React.useState('')
2037
+ const [dshImportState, setDshImportState] = React.useState('idle') // idle | loading | ok | fail
2038
+ const [dshImportMsg, setDshImportMsg] = React.useState('')
1695
2039
  const [, setLogVersion] = React.useState(0)
1696
2040
  const [logCopied, setLogCopied] = React.useState(false)
1697
2041
  const [logCopyFail, setLogCopyFail] = React.useState(false)
@@ -1707,16 +2051,19 @@ window.__ModuleLoader__.load({
1707
2051
  }, [])
1708
2052
 
1709
2053
  React.useEffect(() => {
1710
- if (!copied) return
1711
- const off = setTimeout(() => { setCopied(false); setCopyFailed(false) }, 1600)
1712
- return () => clearTimeout(off)
1713
- }, [copied])
2054
+ const sync = () => setSearchStyleState(readSearchStyle())
2055
+ window.addEventListener(SEARCH_STYLE_EVENT, sync)
2056
+ return () => window.removeEventListener(SEARCH_STYLE_EVENT, sync)
2057
+ }, [])
1714
2058
 
1715
2059
  React.useEffect(() => {
1716
- if (!aiSaved) return
1717
- const off = setTimeout(() => setAiSaved(false), 1600)
1718
- return () => clearTimeout(off)
1719
- }, [aiSaved])
2060
+ const sync = () => setShortcutOn(searchShortcutEnabled())
2061
+ window.addEventListener(SEARCH_SHORTCUT_EVENT, sync)
2062
+ return () => window.removeEventListener(SEARCH_SHORTCUT_EVENT, sync)
2063
+ }, [])
2064
+
2065
+ useAutoReset(copied, () => { setCopied(false); setCopyFailed(false) })
2066
+ useAutoReset(aiSaved, () => setAiSaved(false))
1720
2067
 
1721
2068
  React.useEffect(() => {
1722
2069
  const sync = () => setLogVersion((v) => v + 1)
@@ -1724,23 +2071,9 @@ window.__ModuleLoader__.load({
1724
2071
  return () => window.removeEventListener(LOG_EVENT, sync)
1725
2072
  }, [])
1726
2073
 
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])
2074
+ useAutoReset(logCopied, () => setLogCopied(false))
2075
+ useAutoReset(logCleared, () => setLogCleared(false))
2076
+ useAutoReset(logExportFail, () => setLogExportFail(false))
1744
2077
 
1745
2078
  const saveAi = () => {
1746
2079
  setAiConfig({
@@ -1753,6 +2086,39 @@ window.__ModuleLoader__.load({
1753
2086
  log('info', '保存 AI 配置', { provider, model: model.trim() })
1754
2087
  }
1755
2088
 
2089
+ const importDshModelConfig = async () => {
2090
+ if (dshImportState === 'loading') return
2091
+ setDshImportState('loading')
2092
+ setDshImportMsg('')
2093
+ try {
2094
+ const imported = await readDshModelConfig()
2095
+ if (!imported) throw new Error(t('aiDshImportEmpty'))
2096
+ setAiConfig({ ...imported, apiKey })
2097
+ setProvider(imported.provider)
2098
+ setBaseUrl(imported.baseUrl)
2099
+ setModel(imported.model)
2100
+ setDshImportState('ok')
2101
+ setDshImportMsg(t('aiDshImportOk'))
2102
+ log('info', '导入 DSH 模型配置', { provider: imported.provider, model: imported.model })
2103
+ } catch (error) {
2104
+ const reason = String(error?.message ?? error).slice(0, 160)
2105
+ setDshImportState('fail')
2106
+ setDshImportMsg(t('aiDshImportFail').replace('{reason}', reason))
2107
+ log('error', '导入 DSH 模型配置失败', { detail: reason })
2108
+ }
2109
+ }
2110
+
2111
+ const updateSearchStyle = (style) => {
2112
+ setStoredSearchStyle(style)
2113
+ setSearchStyleState(style)
2114
+ }
2115
+
2116
+ const updateShortcutEnabled = (enabled) => {
2117
+ setSearchShortcutEnabled(enabled)
2118
+ setShortcutOn(enabled)
2119
+ log('info', enabled ? '启用搜索快捷键' : '禁用搜索快捷键', {})
2120
+ }
2121
+
1756
2122
  const requestClearAi = () => setConfirmClearAi(true)
1757
2123
  const cancelClearAi = () => setConfirmClearAi(false)
1758
2124
  const clearAiConfig = () => {
@@ -1914,6 +2280,29 @@ window.__ModuleLoader__.load({
1914
2280
  React.createElement('span', { className: 'sss-vr-chevron' + (open ? ' sss-vr-chevron-open' : '') }, '▾'),
1915
2281
  ]),
1916
2282
  open ? React.createElement('div', { className: 'sss-vr-body' }, [
2283
+ React.createElement('div', { className: 'sss-vr-field' }, [
2284
+ React.createElement('div', { className: 'sss-vr-field-head' }, [
2285
+ React.createElement('span', { className: 'sss-vr-label' }, t('styleTitle')),
2286
+ ]),
2287
+ React.createElement('p', { className: 'sss-vr-hint' }, t('styleHint')),
2288
+ React.createElement('div', { className: 'sss-vr-segment' }, [
2289
+ React.createElement('button', {
2290
+ className: 'sss-vr-seg' + (searchStyle === 'sidebar' ? ' is-active' : ''),
2291
+ type: 'button',
2292
+ onClick: () => updateSearchStyle('sidebar'),
2293
+ }, t('styleClassic')),
2294
+ React.createElement('button', {
2295
+ className: 'sss-vr-seg' + (searchStyle === 'top' ? ' is-active' : ''),
2296
+ type: 'button',
2297
+ onClick: () => updateSearchStyle('top'),
2298
+ }, t('styleTop')),
2299
+ React.createElement('button', {
2300
+ className: 'sss-vr-seg' + (searchStyle === 'page' ? ' is-active' : ''),
2301
+ type: 'button',
2302
+ onClick: () => updateSearchStyle('page'),
2303
+ }, t('stylePage')),
2304
+ ]),
2305
+ ]),
1917
2306
  React.createElement('div', { className: 'sss-vr-field' }, [
1918
2307
  React.createElement('div', { className: 'sss-vr-field-head' }, [
1919
2308
  React.createElement('span', { className: 'sss-vr-label' }, t('aiSearch')),
@@ -1931,6 +2320,18 @@ window.__ModuleLoader__.load({
1931
2320
  React.createElement('p', { className: 'sss-vr-hint' }, t('aiDesc')),
1932
2321
  React.createElement('p', { className: 'sss-vr-hint' }, t('aiPrivacy')),
1933
2322
  ]),
2323
+ React.createElement('div', { className: 'sss-vr-field' }, [
2324
+ React.createElement('div', { className: 'sss-vr-field-head' }, [
2325
+ React.createElement('span', { className: 'sss-vr-label' }, t('shortcutTitle')),
2326
+ React.createElement('input', {
2327
+ className: 'sss-vr-check',
2328
+ type: 'checkbox',
2329
+ checked: shortcutOn,
2330
+ onChange: (event) => updateShortcutEnabled(event.target.checked),
2331
+ }),
2332
+ ]),
2333
+ React.createElement('p', { className: 'sss-vr-hint' }, t('shortcutHint')),
2334
+ ]),
1934
2335
  React.createElement('div', { className: 'sss-vr-field sss-vr-form' }, [
1935
2336
  React.createElement('div', { className: 'sss-vr-field-head' }, [
1936
2337
  React.createElement('label', { className: 'sss-vr-label' }, t('aiProvider')),
@@ -2001,8 +2402,13 @@ window.__ModuleLoader__.load({
2001
2402
  aiSaved ? t('aiConfigSaved') : t('aiConfigSave')),
2002
2403
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: testState === 'loading', onClick: () => { void testService() } },
2003
2404
  testState === 'loading' ? t('aiTesting') : t('aiTest')),
2405
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: dshImportState === 'loading', onClick: () => { void importDshModelConfig() } },
2406
+ dshImportState === 'loading' ? t('aiDshImporting') : t('aiDshImport')),
2004
2407
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => requestClearAi() }, t('aiClear')),
2005
2408
  ]),
2409
+ dshImportMsg
2410
+ ? React.createElement('p', { className: 'sss-vr-note', style: { color: dshImportState === 'ok' ? '#1e8e3e' : 'var(--dsw-alias-label-danger)' } }, dshImportMsg)
2411
+ : null,
2006
2412
  testMsg
2007
2413
  ? React.createElement('p', { className: 'sss-vr-note', style: { color: testState === 'ok' ? '#1e8e3e' : 'var(--dsw-alias-label-danger)' } }, testMsg)
2008
2414
  : null,
@@ -2038,7 +2444,6 @@ window.__ModuleLoader__.load({
2038
2444
  state === 'loading' ? t('updChecking') : t('updCheck')),
2039
2445
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: notesState === 'loading', onClick: () => { void fetchNotes() } },
2040
2446
  notesState === 'loading' ? t('updFetchingNotes') : t('updReleaseNotes')),
2041
- React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => openWelcome() }, t('wlReopen')),
2042
2447
  ]),
2043
2448
  notesState === 'loaded'
2044
2449
  ? React.createElement('div', { className: 'sss-vr-notes' }, notesBody)
@@ -2105,7 +2510,7 @@ window.__ModuleLoader__.load({
2105
2510
  }
2106
2511
 
2107
2512
  exports.apply = apply
2108
- exports.inject = ['slots', 'locale']
2513
+ exports.inject = ['slots', 'locale', 'connection']
2109
2514
  return module.exports
2110
2515
  },
2111
2516
  })
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.10.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