@objectivex666/dsh-settings-search 1.7.2 → 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/lib/client.js CHANGED
@@ -30,17 +30,27 @@ 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.7.2'
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
+ searchBtn: '搜索',
39
+ styleTitle: '搜索框样式',
40
+ styleHint: '选择搜索入口的展示方式;设置会保存在本机浏览器。',
41
+ styleClassic: '经典侧栏',
42
+ styleTop: '窗口顶栏',
43
+ stylePage: '弹出搜索页',
44
+ styleMixed: '顶栏 + 搜索页',
45
+ openSearch: '打开搜索',
46
+ warming: '正在导入各分区选项…',
38
47
  empty: '没有匹配的设置',
39
48
  pages: '设置页',
40
49
  options: '页内选项',
41
50
  guess: '猜你想找',
42
51
  page: '页面',
43
52
  option: '选项',
53
+ pageSearchTitle: '搜索结果',
44
54
  jumpFail: '未能自动跳转,请手动打开:{path}',
45
55
  updn: '设置搜索',
46
56
  updDesc: '设置面板顶部搜索框由本插件提供。在此检查插件的版本与更新。',
@@ -72,10 +82,35 @@ window.__ModuleLoader__.load({
72
82
  aiOpenAI: 'OpenAI 兼容',
73
83
  aiAnthropic: 'Anthropic',
74
84
  aiBaseUrl: 'Base URL',
85
+ aiBaseUrlHint: '仅支持 http(s) 地址;非本机地址必须使用 https,以免泄露 API Key。',
75
86
  aiModel: '模型名',
76
87
  aiApiKey: 'API Key',
88
+ aiDshImport: '导入 DSH 模型配置',
89
+ aiDshImporting: '正在导入…',
90
+ aiDshImportOk: '已导入 Base URL 与模型名;API Key 未改动 ✓',
91
+ aiDshImportFail: '导入失败:{reason}',
92
+ aiDshImportEmpty: '未找到 DSH 当前默认模型配置',
77
93
  aiConfigSave: '保存配置',
78
94
  aiConfigSaved: '已保存 ✓',
95
+ aiClear: '清除配置',
96
+ aiClearTitle: '清除 API 配置',
97
+ aiClearConfirm: '确定要清除 Base URL、模型名与 API Key 吗?此操作不可撤销。',
98
+ aiClearYes: '确认清除',
99
+ aiTest: '测试接口',
100
+ aiTesting: '测试中…',
101
+ aiTestOk: '连接成功 ✓',
102
+ aiTestFail: '连接失败',
103
+ aiTestErrAuth: '认证失败,请检查 API Key 是否正确',
104
+ aiTestErrForbidden: '无权限,请检查 API Key 是否被允许使用该模型',
105
+ aiTestErrNotFound: '接口地址或模型名不存在,请检查 Base URL 与模型名',
106
+ aiTestErrRateLimit: '请求过于频繁或额度不足,请稍后重试或检查套餐',
107
+ aiTestErrBadRequest: '请求参数错误,请检查模型名与接口类型是否匹配',
108
+ aiTestErrServer: '服务端错误,请稍后重试或检查服务状态',
109
+ aiTestErrTimeout: '连接超时,请检查网络与 Base URL 是否可达',
110
+ aiTestErrNetwork: '无法连接,请检查网络或 Base URL',
111
+ aiTestErrBadResponse: '响应格式异常,请确认接口类型与服务兼容',
112
+ aiTestErrHttp: '请求失败(HTTP 状态码错误),请检查地址与配置',
113
+ aiTestErrUnsafeUrl: '接口地址不安全:仅支持 http(s),非本机必须为 https,且不可包含账号密码',
79
114
  logTitle: '日志',
80
115
  logDesc: '记录插件运行、搜索与 AI 请求的关键动作,便于排查问题。',
81
116
  logEmpty: '暂无日志',
@@ -85,17 +120,47 @@ window.__ModuleLoader__.load({
85
120
  logCopyFail: '复制失败,请稍后重试。',
86
121
  logClear: '清空日志',
87
122
  logCleared: '已清空',
123
+ logConfirmTitle: '清空日志',
124
+ logConfirm: '确定要清空全部日志吗?此操作不可撤销。',
125
+ logConfirmYes: '确认清空',
126
+ logConfirmCancel: '取消',
88
127
  logExportFail: '导出失败,请重试。',
128
+ wlTitle: '欢迎使用设置搜索',
129
+ wlSubtitle: '为 DSH 设置面板提供即时搜索与导航,几秒钟就能找到任何设置。',
130
+ wlFeature1Title: '实时搜索',
131
+ wlFeature1Desc: '支持经典侧栏、窗口顶栏、弹出搜索页与顶栏加搜索页混合模式;输入关键词即可过滤设置。',
132
+ wlFeature2Title: '拼音与意图联想',
133
+ wlFeature2Desc: '输入拼音首字母(如 sz)或直接描述需求(如「太亮」),也能联想设置项。',
134
+ wlFeature3Title: 'AI 联想(可选)',
135
+ wlFeature3Desc: '没有本地匹配时,可用你配置的模型理解意图并推荐设置项。',
136
+ wlFeature4Title: '点击即达',
137
+ wlFeature4Desc: '选中结果自动跳转到对应分区,并高亮闪烁目标选项。',
138
+ wlFeature5Title: '自带设置页',
139
+ wlFeature5Desc: '左侧导航的「设置搜索」页可检查更新、查看说明与导出日志。',
140
+ wlStart: '开始使用',
141
+ wlOpenPanel: '打开设置搜索页',
142
+ wlFeature6Title: '自动导入索引',
143
+ wlFeature6Desc: '首次打开时自动遍历一遍各设置分区,把分区内选项一并纳入搜索,无需逐页手动打开即可直接搜到。',
89
144
  }
90
145
  const en = {
91
146
  title: 'Settings',
92
147
  placeholder: 'Search settings…',
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',
156
+ warming: 'Importing section options…',
93
157
  empty: 'No matching settings',
94
158
  pages: 'Settings pages',
95
159
  options: 'In-page options',
96
160
  guess: 'You may want',
97
161
  page: 'Page',
98
162
  option: 'Option',
163
+ pageSearchTitle: 'Search results',
99
164
  jumpFail: 'Could not navigate automatically — open it here: {path}',
100
165
  updn: 'Settings Search',
101
166
  updDesc: "The search box atop the settings panel comes from this plugin. Check its version and updates here.",
@@ -127,10 +192,35 @@ window.__ModuleLoader__.load({
127
192
  aiOpenAI: 'OpenAI-compatible',
128
193
  aiAnthropic: 'Anthropic',
129
194
  aiBaseUrl: 'Base URL',
195
+ aiBaseUrlHint: 'Only http(s) URLs are allowed; remote hosts must use https to avoid exposing your API key.',
130
196
  aiModel: 'Model',
131
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',
132
203
  aiConfigSave: 'Save config',
133
204
  aiConfigSaved: 'Saved ✓',
205
+ aiClear: 'Clear config',
206
+ aiClearTitle: 'Clear API config',
207
+ aiClearConfirm: 'Clear Base URL, model, and API key? This cannot be undone.',
208
+ aiClearYes: 'Clear config',
209
+ aiTest: 'Test API',
210
+ aiTesting: 'Testing…',
211
+ aiTestOk: 'Connection OK ✓',
212
+ aiTestFail: 'Connection failed',
213
+ aiTestErrAuth: 'Auth failed — check that your API key is correct',
214
+ aiTestErrForbidden: 'Forbidden — check that the API key is allowed to use this model',
215
+ aiTestErrNotFound: 'Not found — check the Base URL and model name',
216
+ aiTestErrRateLimit: 'Rate limited or quota exceeded — try again later or check your plan',
217
+ aiTestErrBadRequest: 'Bad request — check the model name and API type',
218
+ aiTestErrServer: 'Server error — try again later or check the service status',
219
+ aiTestErrTimeout: 'Timed out — check the network and whether the Base URL is reachable',
220
+ aiTestErrNetwork: 'Cannot connect — check the network or Base URL',
221
+ aiTestErrBadResponse: 'Unexpected response — confirm the API type matches the service',
222
+ aiTestErrHttp: 'Request failed (HTTP status error) — check the URL and config',
223
+ aiTestErrUnsafeUrl: 'Unsafe URL — only http(s) is allowed, remote hosts must use https, and embedded credentials are not permitted',
134
224
  logTitle: 'Logs',
135
225
  logDesc: 'Logs key plugin actions, searches, and AI requests to help troubleshoot.',
136
226
  logEmpty: 'No logs yet',
@@ -140,12 +230,33 @@ window.__ModuleLoader__.load({
140
230
  logCopyFail: 'Copy failed. Please try again.',
141
231
  logClear: 'Clear logs',
142
232
  logCleared: 'Cleared',
233
+ logConfirmTitle: 'Clear logs',
234
+ logConfirm: 'Clear all logs? This cannot be undone.',
235
+ logConfirmYes: 'Clear logs',
236
+ logConfirmCancel: 'Cancel',
143
237
  logExportFail: 'Export failed. Please try again.',
238
+ wlTitle: 'Welcome to Settings Search',
239
+ wlSubtitle: 'Instant search and navigation for the DSH settings panel — find any setting in seconds.',
240
+ wlFeature1Title: 'Instant search',
241
+ wlFeature1Desc: 'Choose classic sidebar, top bar, popup page, or mixed top bar plus popup search; typing filters settings.',
242
+ wlFeature2Title: 'Pinyin & intent hints',
243
+ wlFeature2Desc: 'Type pinyin initials (e.g. sz) or describe what you want to do to find settings.',
244
+ wlFeature3Title: 'AI hints (optional)',
245
+ wlFeature3Desc: 'When no local match exists, your own model can understand the intent and suggest settings.',
246
+ wlFeature4Title: 'Click to navigate',
247
+ wlFeature4Desc: 'Selecting a result jumps to its section and flashes the target option.',
248
+ wlFeature5Title: 'Built-in panel',
249
+ wlFeature5Desc: 'The Settings Search page in the sidebar checks updates, shows notes, and exports logs.',
250
+ wlStart: 'Get started',
251
+ wlOpenPanel: 'Open Settings Search panel',
252
+ wlFeature6Title: 'Auto-index',
253
+ wlFeature6Desc: 'On first open it automatically passes through every settings section, so in-page options are searchable without opening each page manually.',
144
254
  }
145
255
 
146
256
  /** Inner-row slots that represent specific options inside one page. */
147
257
  const DEEP_SLOTS = [
148
258
  { slot: 'settings.plugins.tab', parent: 'plugins' },
259
+ { slot: 'settings.plugin.item', parent: 'plugins' },
149
260
  { slot: 'web-ui.plugin.item', parent: 'web-ui-plugins' },
150
261
  { slot: 'settings.general.item', parent: 'general' },
151
262
  ]
@@ -189,28 +300,42 @@ window.__ModuleLoader__.load({
189
300
  PINYIN_TABLE.split(/\s+/).filter(Boolean).map((pair) => [pair[0], pair.slice(1)])
190
301
  )
191
302
  const CJK_RE = /[\u4e00-\u9fff]/
303
+ const ALNUM_RE = /[a-zA-Z0-9]/
192
304
 
193
305
  /**
194
306
  * Transliterate a label into full-pinyin and initial-letter keys for
195
307
  * association search. Returns null when any CJK character is outside
196
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.
197
314
  */
315
+ const PINYIN_KEY_CACHE = new Map()
198
316
  const pinyinKeys = (text) => {
317
+ const cached = PINYIN_KEY_CACHE.get(text)
318
+ if (cached !== undefined) return cached
199
319
  let full = ''
200
320
  let initials = ''
201
321
  for (const ch of text) {
202
322
  if (CJK_RE.test(ch)) {
203
323
  const py = PINYIN.get(ch)
204
- if (py === undefined) return null
324
+ if (py === undefined) {
325
+ PINYIN_KEY_CACHE.set(text, null)
326
+ return null
327
+ }
205
328
  full += py
206
329
  initials += py[0]
207
- } else if (/[a-zA-Z0-9]/.test(ch)) {
330
+ } else if (ALNUM_RE.test(ch)) {
208
331
  const lower = ch.toLowerCase()
209
332
  full += lower
210
333
  initials += lower
211
334
  }
212
335
  }
213
- return full === '' ? null : { full, initials }
336
+ const keys = full === '' ? null : { full, initials }
337
+ PINYIN_KEY_CACHE.set(text, keys)
338
+ return keys
214
339
  }
215
340
 
216
341
  /** Match one query token: raw substring, full pinyin, or initial letters. */
@@ -223,6 +348,17 @@ window.__ModuleLoader__.load({
223
348
  return null
224
349
  }
225
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
+
226
362
  /** Character count of the label consumed by a full-pinyin prefix. */
227
363
  const pinyinPrefixChars = (label, pinyinLength) => {
228
364
  let consumed = 0
@@ -271,8 +407,14 @@ window.__ModuleLoader__.load({
271
407
  return { phrase, keywords: entry.slice(at + 1).split(','), keys: pinyinKeys(phrase) }
272
408
  })
273
409
 
274
- /** 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()
275
415
  const intentKeywords = (tok) => {
416
+ const cached = INTENT_KEYWORD_CACHE.get(tok)
417
+ if (cached !== undefined) return cached
276
418
  const hits = []
277
419
  for (const intent of INTENTS) {
278
420
  const phraseHit = intent.phrase.includes(tok) || tok.includes(intent.phrase) ||
@@ -284,6 +426,7 @@ window.__ModuleLoader__.load({
284
426
  }
285
427
  }
286
428
  }
429
+ INTENT_KEYWORD_CACHE.set(tok, hits)
287
430
  return hits
288
431
  }
289
432
 
@@ -318,6 +461,24 @@ window.__ModuleLoader__.load({
318
461
  const AI_BASE_URL_KEY = 'dsh-settings-search:ai-base-url'
319
462
  const AI_MODEL_KEY = 'dsh-settings-search:ai-model'
320
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
+ }
321
482
  const aiSearchEnabled = () => {
322
483
  if (typeof localStorage === 'undefined') return false
323
484
  return localStorage.getItem(AI_STORAGE_KEY) === '1'
@@ -328,23 +489,51 @@ window.__ModuleLoader__.load({
328
489
  window.dispatchEvent(new CustomEvent(AI_TOGGLE_EVENT, { detail: enabled }))
329
490
  }
330
491
  const aiConfig = () => {
331
- if (typeof localStorage === 'undefined') return { provider: 'openai', baseUrl: '', model: '', apiKey: '' }
332
- 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()
333
496
  return {
334
497
  provider: providerRaw === 'anthropic' ? 'anthropic' : 'openai',
335
- baseUrl: (localStorage.getItem(AI_BASE_URL_KEY) ?? '').trim(),
336
- model: (localStorage.getItem(AI_MODEL_KEY) ?? '').trim(),
337
- 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(),
338
501
  }
339
502
  }
340
503
  const setAiConfig = (cfg) => {
504
+ if (typeof localStorage === 'undefined' && typeof sessionStorage === 'undefined') return
505
+ writeApiKey(cfg.apiKey)
341
506
  if (typeof localStorage === 'undefined') return
342
507
  localStorage.setItem(AI_PROVIDER_KEY, cfg.provider === 'anthropic' ? 'anthropic' : 'openai')
343
508
  localStorage.setItem(AI_BASE_URL_KEY, cfg.baseUrl ?? '')
344
509
  localStorage.setItem(AI_MODEL_KEY, cfg.model ?? '')
345
- localStorage.setItem(AI_API_KEY_KEY, cfg.apiKey ?? '')
346
510
  window.dispatchEvent(new CustomEvent(AI_CONFIG_EVENT))
347
511
  }
512
+ /** First-run onboarding: remembered via localStorage so it shows once. */
513
+ const ONBOARD_KEY = 'dsh-settings-search:onboarded'
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
+ }
528
+ const welcomed = () => {
529
+ if (typeof localStorage === 'undefined') return true
530
+ return localStorage.getItem(ONBOARD_KEY) === '1'
531
+ }
532
+ const setWelcomed = () => {
533
+ if (typeof localStorage === 'undefined') return
534
+ localStorage.setItem(ONBOARD_KEY, '1')
535
+ window.dispatchEvent(new CustomEvent(WELCOME_EVENT, { detail: false }))
536
+ }
348
537
  const extractAiKeywords = (raw) => {
349
538
  const text = String(raw ?? '').replace(/^```(?:json)?/i, '').replace(/```$/, '').trim()
350
539
  const start = text.indexOf('{')
@@ -360,19 +549,49 @@ window.__ModuleLoader__.load({
360
549
  .slice(0, 6)
361
550
  }
362
551
  const AI_PROMPT = '你是设置搜索助手。只输出 JSON 对象,格式为 {"keywords":["..."]}。keywords 最多 6 个,每个最多 12 个字符,只能从用户提供的设置索引中选择或提取核心词,不要解释。'
552
+ const loopbackHost = (hostname) => {
553
+ const h = String(hostname || '').replace(/^\[|\]$/g, '').toLowerCase()
554
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '0.0.0.0'
555
+ }
556
+ /**
557
+ * Normalize and validate an AI service base URL. Prevents the API key
558
+ * from being sent to a dangerous scheme (javascript:, data:, …), to a URL
559
+ * with embedded credentials, or over cleartext HTTP to a non-loopback
560
+ * host. Returns a normalized origin+path (no trailing slash) or ''.
561
+ */
562
+ const safeBaseUrl = (baseUrl) => {
563
+ const value = String(baseUrl ?? '').trim()
564
+ if (!value) return ''
565
+ const candidate = /^[a-z][a-z0-9+.-]*:/i.test(value) ? value : `https://${value}`
566
+ let url
567
+ try { url = new URL(candidate) } catch { return '' }
568
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''
569
+ if (url.username || url.password) return ''
570
+ const isHttp = url.protocol === 'http:'
571
+ if (!url.hostname) return ''
572
+ if (isHttp && !loopbackHost(url.hostname)) return ''
573
+ const path = url.pathname === '/' ? '' : url.pathname
574
+ return url.origin + path.replace(/\/+$/, '')
575
+ }
363
576
  const chatCompletionsUrl = (baseUrl) => {
364
- const trimmed = String(baseUrl).trim().replace(/\/+$/, '')
365
- return /\/chat\/completions$/i.test(trimmed) ? trimmed : `${trimmed}/chat/completions`
577
+ // safeBaseUrl already strips trailing slashes
578
+ const safe = safeBaseUrl(baseUrl)
579
+ if (!safe) return ''
580
+ return /\/chat\/completions$/i.test(safe) ? safe : `${safe}/chat/completions`
366
581
  }
367
582
  const anthropicMessagesUrl = (baseUrl) => {
368
- const trimmed = String(baseUrl).trim().replace(/\/+$/, '')
369
- if (/\/v1\/messages$/i.test(trimmed)) return trimmed
370
- return /\/v1$/i.test(trimmed) ? `${trimmed}/messages` : `${trimmed}/v1/messages`
583
+ // safeBaseUrl already strips trailing slashes
584
+ const safe = safeBaseUrl(baseUrl)
585
+ if (!safe) return ''
586
+ if (/\/v1\/messages$/i.test(safe)) return safe
587
+ return /\/v1$/i.test(safe) ? `${safe}/messages` : `${safe}/v1/messages`
371
588
  }
372
589
  const requestUserAi = async (query, labels, signal) => {
373
590
  const { provider, baseUrl, model, apiKey } = aiConfig()
374
591
  if (!baseUrl || !model || !apiKey) return []
375
592
  const isAnthropic = provider === 'anthropic'
593
+ const url = isAnthropic ? anthropicMessagesUrl(baseUrl) : chatCompletionsUrl(baseUrl)
594
+ if (!url) return []
376
595
  const startedAt = Date.now()
377
596
  log('info', 'AI 请求', { provider, model, query: String(query).slice(0, 50) })
378
597
  let timer
@@ -407,7 +626,7 @@ window.__ModuleLoader__.load({
407
626
  ],
408
627
  temperature: 0,
409
628
  }
410
- const response = await fetch(isAnthropic ? anthropicMessagesUrl(baseUrl) : chatCompletionsUrl(baseUrl), {
629
+ const response = await fetch(url, {
411
630
  method: 'POST',
412
631
  headers,
413
632
  signal,
@@ -439,10 +658,96 @@ window.__ModuleLoader__.load({
439
658
  const cacheKey = query.toLowerCase()
440
659
  if (AI_CACHE.has(cacheKey)) return AI_CACHE.get(cacheKey)
441
660
  const keywords = await requestUserAi(query, labels, signal)
442
- 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
+ }
443
666
  return keywords
444
667
  }
445
668
 
669
+ /**
670
+ * Lightweight connectivity test for the configured model service. Sends a
671
+ * minimal chat request and reports whether the endpoint/auth/model work.
672
+ * Returns { ok, code, detail } where code is a stable error kind used to
673
+ * look up a localized hint in the UI.
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
+ }
683
+ const testHttpCode = (status) => {
684
+ const code = Number(status)
685
+ return HTTP_TEST_CODES[code] ?? (code >= 500 && code <= 599 ? 'server' : 'http')
686
+ }
687
+ const testAiService = async (cfg) => {
688
+ const provider = cfg.provider === 'anthropic' ? 'anthropic' : 'openai'
689
+ const baseUrl = String(cfg.baseUrl ?? '').trim()
690
+ const model = String(cfg.model ?? '').trim()
691
+ const apiKey = String(cfg.apiKey ?? '').trim()
692
+ if (!baseUrl || !model || !apiKey) return { ok: false, code: 'need-config', detail: '' }
693
+ const isAnthropic = provider === 'anthropic'
694
+ const requestUrl = isAnthropic ? anthropicMessagesUrl(baseUrl) : chatCompletionsUrl(baseUrl)
695
+ if (!requestUrl) return { ok: false, code: 'unsafe-url', detail: '' }
696
+ const startedAt = Date.now()
697
+ log('info', '测试 API 服务', { provider, model })
698
+ let timer
699
+ const deadline = new Promise((_, reject) => {
700
+ timer = setTimeout(() => {
701
+ const error = new Error('AI request timed out')
702
+ error.name = 'AI_TIMEOUT'
703
+ reject(error)
704
+ }, 12000)
705
+ })
706
+ const work = (async () => {
707
+ const headers = {
708
+ 'content-type': 'application/json',
709
+ ...(isAnthropic
710
+ ? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true' }
711
+ : { authorization: `Bearer ${apiKey}` }),
712
+ }
713
+ const body = isAnthropic
714
+ ? { model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] }
715
+ : { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, temperature: 0 }
716
+ const response = await fetch(requestUrl, {
717
+ method: 'POST',
718
+ headers,
719
+ body: JSON.stringify(body),
720
+ })
721
+ if (!response.ok) return { ok: false, code: testHttpCode(response.status), detail: `HTTP ${response.status}` }
722
+ const data = await response.json().catch(() => ({}))
723
+ const valid = isAnthropic
724
+ ? (Array.isArray(data?.content) && data.content.length > 0)
725
+ : Boolean(data?.choices?.[0]?.message)
726
+ if (!valid) return { ok: false, code: 'bad-response', detail: '' }
727
+ return { ok: true }
728
+ })()
729
+ try {
730
+ const result = await Promise.race([work, deadline])
731
+ if (result.ok) log('info', '测试 API 成功', { duration: Date.now() - startedAt })
732
+ else log('error', '测试 API 失败', { code: result.code ?? 'http', detail: result.detail ?? '', duration: Date.now() - startedAt })
733
+ return result
734
+ } catch (error) {
735
+ if (error.name === 'AbortError') {
736
+ log('warn', '测试 API 取消', { duration: Date.now() - startedAt })
737
+ throw error
738
+ }
739
+ if (error.name === 'AI_TIMEOUT') {
740
+ log('error', '测试 API 超时', { duration: Date.now() - startedAt })
741
+ return { ok: false, code: 'timeout', detail: '' }
742
+ }
743
+ const reason = String(error?.message ?? error).slice(0, 160)
744
+ log('error', '测试 API 失败', { detail: reason, duration: Date.now() - startedAt })
745
+ return { ok: false, code: 'network', detail: reason }
746
+ } finally {
747
+ clearTimeout(timer)
748
+ }
749
+ }
750
+
446
751
  const CSS = `
447
752
  .sss-wrap { position: relative; display: flex; flex-direction: column; gap: 8px; }
448
753
  .sss-title { font-size: 16px; font-weight: 600; line-height: 24px; color: var(--dsw-alias-label-primary); }
@@ -452,6 +757,10 @@ window.__ModuleLoader__.load({
452
757
  .sss-input::placeholder { color: var(--dsw-alias-label-dimmed); }
453
758
  .sss-clear { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; padding: 0; border: none; border-radius: 4px; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 1; cursor: pointer; }
454
759
  .sss-clear:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
760
+ .sss-icon-btn { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; padding: 0; border: none; border-radius: 4px; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; }
761
+ .sss-icon-btn:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
762
+ .sss-icon-btn:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; }
763
+ .sss-warm-note { display: flex; align-items: center; gap: 6px; margin: 0; font-size: 12px; line-height: 16px; color: var(--dsw-alias-label-secondary); }
455
764
  .sss-pop { position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 20; background: var(--dsw-alias-bg-overlay); border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; box-shadow: var(--dsw-shadow-lv3); padding: 6px; max-height: 320px; overflow: auto; }
456
765
  .sss-groups { display: flex; flex-direction: column; gap: 8px; }
457
766
  .sss-group-title { font-size: 11px; color: var(--dsw-alias-label-secondary); letter-spacing: .05em; padding: 2px 6px; }
@@ -460,7 +769,7 @@ window.__ModuleLoader__.load({
460
769
  .sss-item:hover { background: var(--dsw-alias-interactive-bg-hover); }
461
770
  .sss-item.is-active { background: var(--dsw-alias-interactive-bg-hover); }
462
771
  .sss-item-col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
463
- .sss-item-label { color: var(--dsw-alias-label-primary); font-size: 13px; line-height: 18px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
772
+ .sss-item-label { color: var(--dsw-alias-label-primary); font-size: 13px; line-height: 18px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
464
773
  .sss-item-label mark { background: transparent; color: inherit; font-weight: 600; }
465
774
  .sss-item-path { font-size: 10px; line-height: 14px; color: var(--dsw-alias-label-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
466
775
  .sss-item-kind { flex-shrink: 0; font-size: 10px; color: var(--dsw-alias-label-secondary); border: 1px solid var(--dsw-alias-border-l2); border-radius: 4px; padding: 1px 5px; }
@@ -468,6 +777,25 @@ window.__ModuleLoader__.load({
468
777
  .sss-ai-note { margin: 0; padding: 6px 8px; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; }
469
778
  .sss-empty { margin: 0; padding: 10px 8px; color: var(--dsw-alias-label-secondary); font-size: 13px; }
470
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; }
471
799
  @keyframes sssFlashBg { 0% { background-color: var(--dsw-alias-interactive-bg-hover); } 100% { background-color: transparent; } }
472
800
  .sss-flash { animation: sssFlashBg 1.8s ease-out; border-radius: 8px; }
473
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; }
@@ -491,12 +819,17 @@ window.__ModuleLoader__.load({
491
819
  .sss-vr-input { border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-3); min-height: 34px; font: inherit; color: var(--dsw-alias-label-primary); border-radius: 8px; padding: 6px 12px; font-size: 13px; line-height: 1.5; width: 100%; box-sizing: border-box; }
492
820
  .sss-vr-input:focus-visible { border-color: var(--dsw-alias-brand-primary); outline: none; }
493
821
  .sss-vr-input::placeholder { color: var(--dsw-alias-label-dimmed); }
822
+ .sss-vr-input.sss-vr-mask { -webkit-text-security: disc; }
494
823
  .sss-vr-select { cursor: pointer; }
495
824
  .sss-vr-check { width: 16px; height: 16px; accent-color: var(--dsw-alias-brand-primary); cursor: pointer; margin: 0; }
496
825
  .sss-vr-btn { appearance: none; font: inherit; cursor: pointer; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; padding: 5px 14px; font-size: 13px; line-height: 1.5; color: var(--dsw-alias-label-secondary); background: 0 0; }
497
826
  .sss-vr-btn:hover:not(:disabled) { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
498
827
  .sss-vr-btn:disabled { opacity: .4; cursor: default; }
499
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; }
500
833
  .sss-vr-primary { background: var(--dsw-alias-label-primary); color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
501
834
  .sss-vr-primary:hover:not(:disabled) { color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
502
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; }
@@ -514,6 +847,40 @@ window.__ModuleLoader__.load({
514
847
  .sss-vr-link { appearance: none; font: inherit; cursor: pointer; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; padding: 5px 14px; font-size: 13px; line-height: 1.5; color: var(--dsw-alias-label-secondary); background: 0 0; text-decoration: none; display: inline-flex; align-items: center; }
515
848
  .sss-vr-link:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
516
849
  .sss-vr-notes { box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-layer-3); color: var(--dsw-alias-label-primary); padding: 10px 12px; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; max-height: 240px; overflow-y: auto; margin-top: 8px; }
850
+ .sss-wl-overlay { position: fixed; inset: 0; z-index: 999; display: flex; align-items: center; justify-content: center; padding: 20px; background: var(--dsw-alias-bg-mask, rgb(0 0 0 / 36%)); box-sizing: border-box; }
851
+ .sss-wl { width: 100%; max-width: 520px; max-height: min(88vh, 720px); overflow: auto; box-sizing: border-box; background: var(--dsw-alias-bg-layer-3); border: 1px solid var(--dsw-alias-border-l2); border-radius: 16px; box-shadow: var(--dsw-shadow-lv3); padding: 22px 24px 20px; }
852
+ .sss-wl-header { display: flex; align-items: flex-start; gap: 12px; }
853
+ .sss-wl-headText { flex: 1; min-width: 0; }
854
+ .sss-wl-title { margin: 0; color: var(--dsw-alias-label-primary); font-size: 19px; font-weight: 650; line-height: 1.4; }
855
+ .sss-wl-subtitle { margin: 6px 0 0; color: var(--dsw-alias-label-secondary); font-size: 13px; line-height: 1.6; }
856
+ .sss-wl-close { flex: none; appearance: none; cursor: pointer; width: 28px; height: 28px; padding: 0; border: none; border-radius: 7px; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 15px; line-height: 1; }
857
+ .sss-wl-close:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
858
+ .sss-wl-features { list-style: none; margin: 18px 0 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
859
+ .sss-wl-feature { display: flex; gap: 12px; align-items: flex-start; padding: 11px 12px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--dsw-alias-bg-layer-1); }
860
+ .sss-wl-feature-icon { flex: none; width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); font-size: 15px; line-height: 1; }
861
+ .sss-wl-feature-body { flex: 1; min-width: 0; }
862
+ .sss-wl-feature-title { color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 600; line-height: 1.5; }
863
+ .sss-wl-feature-desc { margin: 2px 0 0; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 1.6; }
864
+ .sss-wl-footer { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 20px; }
865
+ .sss-wl-btn { appearance: none; font: inherit; cursor: pointer; border: 1px solid var(--dsw-alias-border-l2); border-radius: 9px; padding: 7px 16px; font-size: 13px; line-height: 1.5; color: var(--dsw-alias-label-secondary); background: 0 0; }
866
+ .sss-wl-btn:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
867
+ .sss-wl-primary { background: var(--dsw-alias-label-primary); color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
868
+ .sss-wl-primary:hover { color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
869
+ .sss-wl-skip { margin-left: auto; border: none; background: transparent; color: var(--dsw-alias-label-tertiary); padding: 7px 4px; }
870
+ .sss-wl-skip:hover { color: var(--dsw-alias-label-primary); border-color: transparent; }
871
+ .sss-cf-overlay { position: fixed; inset: 0; z-index: 999; display: flex; align-items: center; justify-content: center; padding: 20px; background: var(--dsw-alias-bg-mask, rgb(0 0 0 / 36%)); box-sizing: border-box; }
872
+ .sss-cf { width: 100%; max-width: 400px; box-sizing: border-box; background: var(--dsw-alias-bg-layer-3); border: 1px solid var(--dsw-alias-border-l2); border-radius: 14px; box-shadow: var(--dsw-shadow-lv3); padding: 20px 22px 18px; }
873
+ .sss-cf-header { display: flex; align-items: flex-start; gap: 12px; }
874
+ .sss-cf-headText { flex: 1; min-width: 0; }
875
+ .sss-cf-title { margin: 0; color: var(--dsw-alias-label-primary); font-size: 16px; font-weight: 650; line-height: 1.4; }
876
+ .sss-cf-desc { margin: 8px 0 0; color: var(--dsw-alias-label-secondary); font-size: 13px; line-height: 1.6; }
877
+ .sss-cf-close { flex: none; appearance: none; cursor: pointer; width: 28px; height: 28px; padding: 0; border: none; border-radius: 7px; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 15px; line-height: 1; }
878
+ .sss-cf-close:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
879
+ .sss-cf-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 18px; }
880
+ .sss-cf-btn { appearance: none; font: inherit; cursor: pointer; border: 1px solid var(--dsw-alias-border-l2); border-radius: 9px; padding: 7px 16px; font-size: 13px; line-height: 1.5; color: var(--dsw-alias-label-secondary); background: 0 0; }
881
+ .sss-cf-btn:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
882
+ .sss-cf-danger { background: #c0392b; color: #fff; border-color: transparent; }
883
+ .sss-cf-danger:hover { background: #a93226; color: #fff; border-color: transparent; }
517
884
  `
518
885
  let stylesInstalled = false
519
886
  function installStyles() {
@@ -543,7 +910,6 @@ window.__ModuleLoader__.load({
543
910
  function createModel(ctx) {
544
911
  /** id → harvested visible title, per slot. */
545
912
  const harvest = new Map()
546
- let harvestSeq = 0
547
913
 
548
914
  const entriesOf = (slot) => {
549
915
  try { return ctx.slots.entries(slot) ?? [] } catch { return [] }
@@ -558,7 +924,7 @@ window.__ModuleLoader__.load({
558
924
  if (typeof o.locale === 'string') {
559
925
  const t = callSafe(() => ctx.locale.bind(o.locale))
560
926
  if (typeof t === 'function') {
561
- for (const key of ['tab', 'title', 'card.title', 'name']) {
927
+ for (const key of ['tab', 'title', 'card.title', 'name', 'nav']) {
562
928
  const value = callSafe(() => t(key))
563
929
  if (typeof value === 'string' && value !== '' && value !== key) return value
564
930
  }
@@ -576,6 +942,77 @@ window.__ModuleLoader__.load({
576
942
  * stacked section): harvest each direct child as one row, keyed by
577
943
  * render order ("#i") since id pairing is impossible.
578
944
  */
945
+ /** Strip a trailing description (e.g. "关闭后恢复…") from a row label. */
946
+ const trimDescription = (text) => {
947
+ const t = (text ?? '').replace(/\s+/g, ' ').trim()
948
+ if (t === '') return ''
949
+ const re = /(?:[\s,,。;;])?(关闭后|开启后|启用后|禁用后|创建后|使用时|点击后|勾选后|开启时|关闭时|完成后|启用时|禁用时|开始后|停止后)/
950
+ const m = re.exec(t)
951
+ if (m) {
952
+ const head = t.slice(0, m.index).trim()
953
+ if (head.length >= 2 && head.length <= 60) return head
954
+ }
955
+ const p = t.indexOf('。')
956
+ if (p > 0) {
957
+ const head = t.slice(0, p).trim()
958
+ if (head.length >= 2 && head.length <= 40 && head.length < t.length - p) return head
959
+ }
960
+ return t
961
+ }
962
+
963
+ const mineInnerOptions = (node) => {
964
+ try {
965
+ const seen = new Set()
966
+ const out = []
967
+ const textOf = (el) => (el.textContent ?? '').replace(/\s+/g, ' ').trim()
968
+ const titleOf = (el) => {
969
+ const t = textOf(el)
970
+ return t.length >= 2 && t.length <= 120 ? t : ''
971
+ }
972
+ const bestTitle = (row) => {
973
+ let best = ''
974
+ let bestLen = 1e9
975
+ const sels = ['h1,h2,h3,h4,h5', '[class*="title"]', '[class*="heading"]', '[class*="name"]', '[class*="label"]', 'label', 'strong', 'b']
976
+ for (const sel of sels) {
977
+ for (const el of row.querySelectorAll(sel)) {
978
+ if (el === row) continue
979
+ const t = titleOf(el)
980
+ if (t === '') continue
981
+ if (t.length < bestLen) { best = t; bestLen = t.length }
982
+ }
983
+ }
984
+ if (best !== '') return best
985
+ const raw = textOf(row)
986
+ const part = raw.split('·')[0].trim()
987
+ return part.length >= 2 && part.length <= 120 ? part : raw.slice(0, 120)
988
+ }
989
+ const push = (label, el) => {
990
+ const clean = trimDescription(label)
991
+ const text = textOf(el)
992
+ if (clean.length < 2 || el === node || seen.has(clean)) return
993
+ seen.add(clean)
994
+ out.push({ label: clean, text })
995
+ }
996
+ const ctrls = node.querySelectorAll('input[type="checkbox"], input[type="radio"], [role="switch"], [role="checkbox"], [role="radio"], button[aria-pressed], button[aria-checked]')
997
+ for (const ctl of ctrls) {
998
+ let row = ctl
999
+ for (let d = 0; d < 6 && row.parentElement; d += 1) {
1000
+ row = row.parentElement
1001
+ if (row === node) break
1002
+ const t = textOf(row)
1003
+ if (t.length >= 2 && t.length <= 220) break
1004
+ }
1005
+ push(bestTitle(row), row)
1006
+ }
1007
+ const heads = node.querySelectorAll('h1,h2,h3,h4,h5,label,[class*="title"],[class*="heading"],[class*="label"],[class*="name"]')
1008
+ for (const h of heads) push(bestTitle(h) || titleOf(h), h)
1009
+ return out.slice(0, 24)
1010
+ } catch {
1011
+ return []
1012
+ }
1013
+ }
1014
+
1015
+
579
1016
  const runHarvest = () => {
580
1017
  let grew = false
581
1018
  if (typeof document === 'undefined') return false
@@ -589,7 +1026,7 @@ window.__ModuleLoader__.load({
589
1026
  if (containers.length === 1 && containers[0].childElementCount > 0) {
590
1027
  nodes = [...containers[0].children]
591
1028
  } else {
592
- nodes = containers.filter((n) => n.childElementCount >= 0)
1029
+ nodes = containers
593
1030
  }
594
1031
  if (nodes.length === 0) continue
595
1032
  map = harvest.get(cfg.slot)
@@ -601,28 +1038,60 @@ window.__ModuleLoader__.load({
601
1038
  if (map.has(key)) return
602
1039
  const title = titleFromNode(node)
603
1040
  if (title === '') return
604
- const text = (node.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 160)
605
- map.set(key, { title, idx: i, text }); grew = true
1041
+ const text = (node.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 500)
1042
+ const record = { title, idx: i, text, inner: mineInnerOptions(node) }
1043
+ map.set(key, record); grew = true
606
1044
  })
607
1045
  continue
608
1046
  }
609
1047
  regs.forEach((entry, i) => {
610
- const id = String(entry.options?.id ?? i)
1048
+ const id = String(entry.options?.id ?? entry.options?.key ?? i)
611
1049
  if (map.has(id)) return
612
1050
  const title = titleFromNode(nodes[i])
613
1051
  if (title === '') return
614
- const text = (nodes[i].textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 160)
615
- map.set(id, { title, idx: i, text }); grew = true
1052
+ const text = (nodes[i].textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 500)
1053
+ const record = { title, idx: i, text, inner: mineInnerOptions(nodes[i]) }
1054
+ map.set(id, record); map.set('#' + i, record); grew = true
616
1055
  })
617
1056
  }
618
- if (grew) harvestSeq += 1
1057
+ // Mine the active first-level section's own inline controls
1058
+ // (e.g. 创意工坊 → 启用创意工坊卡片), which live outside DEEP_SLOTS.
1059
+ try {
1060
+ const sections = readSections()
1061
+ const activeLabel = activeSectionLabel()
1062
+ const active = activeLabel === '' ? undefined : sections.find((s) => s.label === activeLabel)
1063
+ if (active && active.id !== '') {
1064
+ const containers = [...document.querySelectorAll('[data-slot="settings.section"]')]
1065
+ const node = containers.length === 1 && containers[0].childElementCount > 0
1066
+ ? containers[0].children[0] ?? null
1067
+ : containers[0] ?? null
1068
+ if (node) {
1069
+ const inner = mineInnerOptions(node)
1070
+ const text = (node.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 500)
1071
+ if (inner.length > 0 || text !== '') {
1072
+ let map = harvest.get('settings.section')
1073
+ if (map === undefined) { map = new Map(); harvest.set('settings.section', map) }
1074
+ const rec = map.get(active.id)
1075
+ if (!rec) {
1076
+ map.set(active.id, { title: active.label, idx: 0, text, inner })
1077
+ grew = true
1078
+ } else if ((rec.inner?.length ?? 0) === 0 && inner.length > 0) {
1079
+ rec.inner = inner
1080
+ rec.text = text
1081
+ grew = true
1082
+ }
1083
+ }
1084
+ }
1085
+ }
1086
+ } catch { /* section mining is best-effort */ }
619
1087
  return grew
620
1088
  }
621
1089
 
622
1090
  const titleFromNode = (node) => {
623
1091
  const pick = (el) => {
624
1092
  const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim()
625
- return text !== '' && text.length <= 60 ? text : ''
1093
+ if (text === '' || text.length > 200) return ''
1094
+ return trimDescription(text)
626
1095
  }
627
1096
  for (const sel of ['h1,h2,h3,h4,h5', '[class*="title"]', '[class*="heading"]', 'label', 'p']) {
628
1097
  const hit = node.querySelector(sel)
@@ -632,7 +1101,7 @@ window.__ModuleLoader__.load({
632
1101
  if (first) {
633
1102
  for (const child of first.children) { const text = pick(child); if (text !== '') return text }
634
1103
  }
635
- return pick(node).split('·')[0].trim()
1104
+ return trimDescription(pick(node).split('·')[0].trim())
636
1105
  }
637
1106
 
638
1107
  const readSections = () => sortEntries(entriesOf('settings.section'))
@@ -650,10 +1119,14 @@ window.__ModuleLoader__.load({
650
1119
  const parentLabel = (parentId) =>
651
1120
  sections.find((s) => s.id === parentId)?.label ?? parentId
652
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()
653
1126
  for (const cfg of DEEP_SLOTS) {
654
1127
  const mapped = harvest.get(cfg.slot)
655
1128
  sortEntries(entriesOf(cfg.slot)).forEach((entry, i) => {
656
- const id = String(entry.options?.id ?? '')
1129
+ const id = String(entry.options?.id ?? entry.options?.key ?? '')
657
1130
  if (id === '') return
658
1131
  // static label first, then id-paired harvest, then loose index-keyed harvest
659
1132
  const hitId = mapped?.get(id)
@@ -662,7 +1135,10 @@ window.__ModuleLoader__.load({
662
1135
  const label = staticLabel(entry, cfg) || hitObj?.title || ''
663
1136
  if (label === '') return // unmapped, unharvested — invisible until it renders once
664
1137
  const key = cfg.slot + '#' + id
665
- if (out.some((e) => e.key === key)) return
1138
+ if (seenKeys.has(key)) return
1139
+ const pageLabel = parentLabel(cfg.parent)
1140
+ seenKeys.add(key)
1141
+ seenPairs.add(pageLabel + '\u0000' + label)
666
1142
  out.push({
667
1143
  kind: 'option',
668
1144
  key,
@@ -673,24 +1149,90 @@ window.__ModuleLoader__.load({
673
1149
  hay: label + ' ' + (hitObj?.text ?? ''),
674
1150
  rowIndex: hitObj?.idx ?? i,
675
1151
  parentId: cfg.parent,
676
- parentLabel: parentLabel(cfg.parent),
1152
+ parentLabel: pageLabel,
677
1153
  })
1154
+ if (cfg.slot === 'settings.plugin.item' && Array.isArray(hitObj?.inner)) {
1155
+ hitObj.inner.forEach((op, oi) => {
1156
+ if (op.label === '' || op.label === label) return
1157
+ const innerKey = key + '#o' + oi
1158
+ if (seenKeys.has(innerKey)) return
1159
+ seenKeys.add(innerKey)
1160
+ seenPairs.add(label + '\u0000' + op.label)
1161
+ out.push({
1162
+ kind: 'option',
1163
+ key: innerKey,
1164
+ slot: cfg.slot,
1165
+ id: id + '#o' + oi,
1166
+ order: (entry.options?.order ?? 0) + oi / 1000,
1167
+ label: op.label,
1168
+ hay: `${op.text} ${label} ${pageLabel}`.toLowerCase(),
1169
+ rowIndex: hitObj?.idx ?? i,
1170
+ parentId: cfg.parent,
1171
+ parentLabel: label,
1172
+ pageLabel,
1173
+ inner: true,
1174
+ cardLabel: label,
1175
+ })
1176
+ })
1177
+ }
678
1178
  })
679
1179
  }
1180
+ // Expose inline options mined from active first-level sections.
1181
+ {
1182
+ const mapped = harvest.get('settings.section')
1183
+ if (mapped) {
1184
+ sections.forEach((sec) => {
1185
+ const id = sec.id
1186
+ if (id === '') return
1187
+ const rec = mapped.get(id)
1188
+ if (!rec || !Array.isArray(rec.inner)) return
1189
+ const label = sec.label
1190
+ if (label === '') return
1191
+ rec.inner.forEach((op, oi) => {
1192
+ if (op.label === '' || op.label === label) return
1193
+ const innerKey = 'settings.section#' + id + '#o' + oi
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)
1199
+ out.push({
1200
+ kind: 'option',
1201
+ key: innerKey,
1202
+ slot: 'settings.section',
1203
+ id: id + '#o' + oi,
1204
+ order: sec.order + oi / 1000,
1205
+ label: op.label,
1206
+ hay: `${op.text} ${label}`.toLowerCase(),
1207
+ rowIndex: rec.idx ?? 0,
1208
+ parentId: id,
1209
+ parentLabel: label,
1210
+ pageLabel: label,
1211
+ inner: true,
1212
+ cardLabel: label,
1213
+ })
1214
+ })
1215
+ })
1216
+ }
1217
+ }
680
1218
  return out.sort((a, b) => a.parentLabel !== b.parentLabel
681
1219
  ? a.parentLabel.localeCompare(b.parentLabel)
682
1220
  : a.order - b.order)
683
1221
  }
684
1222
 
685
- return { readSections, readDeep, runHarvest, bump: () => harvestSeq }
1223
+ return { readSections, readDeep, runHarvest }
686
1224
  }
687
1225
 
688
1226
  /* ---------- DOM-level navigation into the settings shell ---------- */
689
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
+ }
690
1233
  const panelButtons = () => {
691
- if (typeof document === 'undefined') return []
692
- const root = document.querySelector('[role="dialog"]') ?? document.body
693
- return [...root.querySelectorAll('button')]
1234
+ const root = panelRoot()
1235
+ return root === null ? [] : [...root.querySelectorAll('button')]
694
1236
  }
695
1237
  const clickNavButton = (wantText) => {
696
1238
  const want = String(wantText ?? '').trim()
@@ -703,16 +1245,56 @@ window.__ModuleLoader__.load({
703
1245
  return true
704
1246
  }
705
1247
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
1248
+ /** Label of the currently-active first-level settings nav item. */
1249
+ const activeSectionLabel = () => {
1250
+ const root = panelRoot()
1251
+ if (root === null) return ''
1252
+ const isTab = (b) => b.getAttribute('role') === 'tab'
1253
+ const buttons = [...root.querySelectorAll('button')].filter((b) => !isTab(b))
1254
+ const byCurrent = buttons.find((b) => b.getAttribute('aria-current') === 'true')
1255
+ const byClass = buttons.find((b) => /_navCell/.test(b.className || '') && /_active/.test(b.className || ''))
1256
+ const hit = byCurrent ?? byClass
1257
+ return (hit?.textContent ?? '').replace(/\s+/g, ' ').trim()
1258
+ }
1259
+
1260
+ let warmed = false
1261
+ /**
1262
+ * One-shot background pass: visit every first-level settings section once,
1263
+ * let each mount, harvest its options, then restore the previously-active one.
1264
+ */
1265
+ const warmAllSections = async (model, onStart, onDone) => {
1266
+ if (warmed || typeof document === 'undefined') return true
1267
+ const sections = model.readSections()
1268
+ if (sections.length === 0) return true
1269
+ const original = activeSectionLabel()
1270
+ if (original === '') return false // settings panel not open yet; retry on next mount
1271
+ warmed = true
1272
+ if (typeof onStart === 'function') onStart()
1273
+ let visited = 0
1274
+ try {
1275
+ for (const sec of sections.slice(0, 40)) {
1276
+ if (!clickNavButton(sec.label)) continue
1277
+ await delay(180)
1278
+ model.runHarvest()
1279
+ visited += 1
1280
+ }
1281
+ if (clickNavButton(original)) {
1282
+ await delay(160)
1283
+ model.runHarvest()
1284
+ }
1285
+ } finally {
1286
+ if (typeof onDone === 'function') onDone()
1287
+ }
1288
+ log('info', '预载设置索引', { total: sections.length, visited })
1289
+ return true
1290
+ }
706
1291
 
707
1292
  function createNavigator(ctx) {
708
1293
  /** Jump to a page result; falls back to showing a manual hint. */
709
- const jumpToPage = async (item) => {
710
- const ok = await Promise.resolve(clickNavButton(item.label))
711
- return ok
712
- }
1294
+ const jumpToPage = async (item) => clickNavButton(item.label)
713
1295
  /** Jump to an in-page option: open its page, select its tab, flash its row. */
714
1296
  const jumpToOption = async (item) => {
715
- const opened = await Promise.resolve(clickNavButton(item.parentLabel))
1297
+ const opened = clickNavButton(item.pageLabel ?? item.parentLabel)
716
1298
  if (opened !== true) { flashOptionRow(item); return false }
717
1299
  await delay(160)
718
1300
  const label = item.label
@@ -727,21 +1309,34 @@ window.__ModuleLoader__.load({
727
1309
  const flashOptionRow = (item) => {
728
1310
  try {
729
1311
  let target = null
1312
+ let card = null
730
1313
  if (typeof item.rowIndex === 'number') {
731
- // wrapper shape: pick the exact rendered row by index
732
1314
  const container = document.querySelector(`[data-slot="${item.slot}"]`)
733
- target = container?.children?.[item.rowIndex] ?? null
734
- if (target && !(target.textContent ?? '').includes(item.label)) {
735
- // index drift — fall through to textual match below
736
- target = null
737
- }
1315
+ card = container?.children?.[item.rowIndex] ?? null
1316
+ if (card && !(card.textContent ?? '').includes(item.label)) card = null
738
1317
  }
739
- if (!target) {
1318
+ if (!card) {
740
1319
  const nodes = [...document.querySelectorAll(`[data-slot="${item.slot}"]`)]
741
1320
  const rows = nodes.length === 1 && nodes[0].childElementCount > 0
742
1321
  ? [...nodes[0].children]
743
1322
  : nodes
744
- target = rows.find((n) => (n.textContent ?? '').includes(item.label)) ?? null
1323
+ card = rows.find((n) => (n.textContent ?? '').includes(item.label)) ?? null
1324
+ }
1325
+ if (card) {
1326
+ if (item.inner) {
1327
+ let best = null
1328
+ let bestLen = Infinity
1329
+ for (const el of card.querySelectorAll('*')) {
1330
+ const t = (el.textContent ?? '')
1331
+ if (t.includes(item.label) && t.length < bestLen) {
1332
+ best = el
1333
+ bestLen = t.length
1334
+ }
1335
+ }
1336
+ target = best ?? card
1337
+ } else {
1338
+ target = card
1339
+ }
745
1340
  }
746
1341
  if (!target) return
747
1342
  target.scrollIntoView({ block: 'nearest' })
@@ -767,6 +1362,36 @@ window.__ModuleLoader__.load({
767
1362
  const model = createModel(ctx)
768
1363
  const navigator = createNavigator(ctx)
769
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
+
770
1395
  const SearchIcon = React.createElement('svg', {
771
1396
  className: 'sss-icon',
772
1397
  viewBox: '0 0 16.5 16.5',
@@ -785,10 +1410,76 @@ window.__ModuleLoader__.load({
785
1410
  }),
786
1411
  ])
787
1412
 
1413
+ /** Feature cards shown in the first-run welcome modal. */
1414
+ const WELCOME_FEATURES = [
1415
+ { icon: '🔍', titleKey: 'wlFeature1Title', descKey: 'wlFeature1Desc' },
1416
+ { icon: '🔤', titleKey: 'wlFeature2Title', descKey: 'wlFeature2Desc' },
1417
+ { icon: '🤖', titleKey: 'wlFeature3Title', descKey: 'wlFeature3Desc' },
1418
+ { icon: '🧭', titleKey: 'wlFeature4Title', descKey: 'wlFeature4Desc' },
1419
+ { icon: '🧾', titleKey: 'wlFeature5Title', descKey: 'wlFeature5Desc' },
1420
+ { icon: '⚡', titleKey: 'wlFeature6Title', descKey: 'wlFeature6Desc' },
1421
+ ]
1422
+
1423
+ /** First-run onboarding modal rendered as a fixed overlay. */
1424
+ function Welcome({ t, onClose, onOpenPanel }) {
1425
+ return React.createElement('div', {
1426
+ className: 'sss-wl-overlay',
1427
+ onMouseDown: (event) => { if (event.target === event.currentTarget) onClose() },
1428
+ }, [
1429
+ React.createElement('div', { className: 'sss-wl', role: 'dialog', 'aria-modal': 'true' }, [
1430
+ React.createElement('div', { className: 'sss-wl-header' }, [
1431
+ React.createElement('div', { className: 'sss-wl-headText' }, [
1432
+ React.createElement('h2', { className: 'sss-wl-title' }, t('wlTitle')),
1433
+ React.createElement('p', { className: 'sss-wl-subtitle' }, t('wlSubtitle')),
1434
+ ]),
1435
+ React.createElement('button', { className: 'sss-wl-close', type: 'button', 'aria-label': 'Close', onClick: onClose }, '✕'),
1436
+ ]),
1437
+ React.createElement('ul', { className: 'sss-wl-features' },
1438
+ WELCOME_FEATURES.map((f) => React.createElement('li', { className: 'sss-wl-feature', key: f.titleKey }, [
1439
+ React.createElement('span', { className: 'sss-wl-feature-icon', 'aria-hidden': 'true' }, f.icon),
1440
+ React.createElement('span', { className: 'sss-wl-feature-body' }, [
1441
+ React.createElement('span', { className: 'sss-wl-feature-title' }, t(f.titleKey)),
1442
+ React.createElement('p', { className: 'sss-wl-feature-desc' }, t(f.descKey)),
1443
+ ]),
1444
+ ]))),
1445
+ React.createElement('div', { className: 'sss-wl-footer' }, [
1446
+ React.createElement('button', { className: 'sss-wl-btn sss-wl-primary', type: 'button', onClick: onClose }, t('wlStart')),
1447
+ React.createElement('button', { className: 'sss-wl-btn', type: 'button', onClick: onOpenPanel }, t('wlOpenPanel')),
1448
+ ]),
1449
+ ]),
1450
+ ])
1451
+ }
1452
+
1453
+ /** Destructive-action confirmation modal (danger-styled confirm button). */
1454
+ function ConfirmModal({ t, title, desc, confirmLabel, cancelLabel, onConfirm, onCancel }) {
1455
+ return React.createElement('div', {
1456
+ className: 'sss-cf-overlay',
1457
+ onMouseDown: (event) => { if (event.target === event.currentTarget) onCancel() },
1458
+ }, [
1459
+ React.createElement('div', { className: 'sss-cf', role: 'dialog', 'aria-modal': 'true' }, [
1460
+ React.createElement('div', { className: 'sss-cf-header' }, [
1461
+ React.createElement('div', { className: 'sss-cf-headText' }, [
1462
+ React.createElement('h2', { className: 'sss-cf-title' }, title),
1463
+ React.createElement('p', { className: 'sss-cf-desc' }, desc),
1464
+ ]),
1465
+ React.createElement('button', { className: 'sss-cf-close', type: 'button', 'aria-label': 'Close', onClick: onCancel }, '✕'),
1466
+ ]),
1467
+ React.createElement('div', { className: 'sss-cf-actions' }, [
1468
+ React.createElement('button', { className: 'sss-cf-btn sss-cf-danger', type: 'button', onClick: onConfirm }, confirmLabel),
1469
+ React.createElement('button', { className: 'sss-cf-btn', type: 'button', onClick: onCancel }, cancelLabel),
1470
+ ]),
1471
+ ]),
1472
+ ])
1473
+ }
1474
+
788
1475
  function SettingsHeaderSearch() {
789
1476
  const [query, setQuery] = React.useState('')
790
1477
  const [open, setOpen] = React.useState(false)
1478
+ const [searchStyle, setSearchStyleState] = React.useState(readSearchStyle)
1479
+ const [pageOpen, setPageOpen] = React.useState(false)
1480
+ const [showWelcome, setShowWelcome] = React.useState(() => !welcomed())
791
1481
  const [tick, setTick] = React.useState(0)
1482
+ const [warming, setWarming] = React.useState(false)
792
1483
  const [, forceTick] = React.useReducer((x) => x + 1, 0)
793
1484
  void tick
794
1485
  const [aiEnabled, setAiEnabled] = React.useState(aiSearchEnabled)
@@ -803,20 +1494,67 @@ window.__ModuleLoader__.load({
803
1494
  ctx.locale.subscribe(forceTick),
804
1495
  ]
805
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).
1499
+ let warmAlive = true
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
+ }
806
1513
  const observer = typeof MutationObserver === 'function'
807
1514
  ? new MutationObserver(() => {
808
- if (model.runHarvest()) setTick((x) => x + 1)
1515
+ if (throttleTimer !== null) return
1516
+ throttleTimer = setTimeout(() => {
1517
+ throttleTimer = null
1518
+ onMutate()
1519
+ }, 120)
809
1520
  })
810
1521
  : null
811
1522
  observer?.observe(document.body, { childList: true, subtree: true })
812
- const warm = setTimeout(() => { model.runHarvest(); setTick((x) => x + 1) }, 800)
1523
+ const tryWarm = () => {
1524
+ void warmAllSections(model, () => setWarming(true), () => setWarming(false)).then((done) => {
1525
+ if (!warmAlive) return
1526
+ if (done) { setTick((x) => x + 1); return }
1527
+ warmAttempts += 1
1528
+ if (warmAttempts < 4) setTimeout(tryWarm, 700)
1529
+ })
1530
+ }
1531
+ const warm = setTimeout(() => {
1532
+ model.runHarvest()
1533
+ setTick((x) => x + 1)
1534
+ tryWarm()
1535
+ }, 800)
813
1536
  return () => {
1537
+ warmAlive = false
1538
+ mutatorAlive = false
814
1539
  for (const off of offs) off()
815
1540
  observer?.disconnect()
816
1541
  clearTimeout(warm)
1542
+ if (throttleTimer !== null) clearTimeout(throttleTimer)
817
1543
  }
818
1544
  }, [])
819
1545
 
1546
+ React.useEffect(() => {
1547
+ const onWelcome = (event) => { if (event?.detail === true) setShowWelcome(true) }
1548
+ window.addEventListener(WELCOME_EVENT, onWelcome)
1549
+ return () => window.removeEventListener(WELCOME_EVENT, onWelcome)
1550
+ }, [])
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
+
820
1558
  React.useEffect(() => {
821
1559
  const sync = () => setAiEnabled(aiSearchEnabled())
822
1560
  const onConfig = () => forceTick()
@@ -839,7 +1577,7 @@ window.__ModuleLoader__.load({
839
1577
  tokens.every((tok) => tokenMatch(haystackLower, item.keys, tok) !== null)
840
1578
  const kwMatch = (item, haystackLower, kw) => {
841
1579
  if (/^[\x00-\x7f]+$/.test(kw)) {
842
- if (new RegExp(`\\b${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(haystackLower)) return true
1580
+ if (wordBoundaryRegex(kw).test(haystackLower)) return true
843
1581
  } else if (haystackLower.includes(kw)) return true
844
1582
  if (item.keys === null) return false
845
1583
  const kwKeys = pinyinKeys(kw)
@@ -930,15 +1668,25 @@ window.__ModuleLoader__.load({
930
1668
  const [hint, setHint] = React.useState(null)
931
1669
  const [active, setActive] = React.useState(0)
932
1670
  const popRef = React.useRef(null)
1671
+ const inputRef = React.useRef(null)
1672
+ const pageMode = searchStyle === 'page'
1673
+ const mixedMode = searchStyle === 'mixed'
1674
+ const topMode = searchStyle === 'top' || mixedMode
933
1675
  const activeIndex = Math.min(active, Math.max(results.length - 1, 0))
934
1676
 
935
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])
936
1683
  React.useEffect(() => {
937
1684
  const el = popRef.current?.querySelector('.sss-item.is-active')
938
1685
  el?.scrollIntoView?.({ block: 'nearest' })
939
1686
  }, [activeIndex, q])
940
1687
  const pick = (item) => {
941
1688
  setOpen(false)
1689
+ setPageOpen(false)
942
1690
  log('info', '打开设置', { kind: item.kind, id: item.id || item.key || '', label: String(item.label).slice(0, 60) })
943
1691
  const jumped = item.kind === 'page'
944
1692
  ? navigator.jumpToPage(item)
@@ -948,6 +1696,15 @@ window.__ModuleLoader__.load({
948
1696
  })
949
1697
  }
950
1698
 
1699
+ const dismissWelcome = () => {
1700
+ setWelcomed()
1701
+ setShowWelcome(false)
1702
+ }
1703
+ const openPanelFromWelcome = () => {
1704
+ dismissWelcome()
1705
+ clickNavButton(t('updn'))
1706
+ }
1707
+
951
1708
  const mark = (text) => React.createElement('mark', { className: 'sss-hit' }, text)
952
1709
 
953
1710
  /** Highlight the matched substring, or the pinyin-covered prefix. */
@@ -1010,71 +1767,213 @@ window.__ModuleLoader__.load({
1010
1767
  kindBadge(via, t('option')),
1011
1768
  ])
1012
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
+
1013
1956
  return React.createElement('div', { className: 'sss-wrap' }, [
1014
1957
  React.createElement('div', { className: 'sss-title' }, t('title')),
1015
1958
  React.createElement('div', { className: 'sss-box' }, [
1016
- SearchIcon,
1017
- React.createElement('input', {
1018
- className: 'sss-input',
1019
- value: query,
1020
- placeholder: t('placeholder'),
1021
- spellCheck: false,
1022
- onKeyDown: (e) => {
1023
- if (e.key === 'ArrowDown') {
1024
- e.preventDefault()
1025
- if (results.length > 0) setActive(Math.min(activeIndex + 1, results.length - 1))
1026
- } else if (e.key === 'ArrowUp') {
1027
- e.preventDefault()
1028
- setActive(Math.max(activeIndex - 1, 0))
1029
- } else if (e.key === 'Enter') {
1030
- const item = results[activeIndex]
1031
- if (item) { e.preventDefault(); pick(item) }
1032
- } else if (e.key === 'Escape') {
1033
- setOpen(false)
1034
- }
1035
- },
1036
- onChange: (e) => { setQuery(e.target.value); setHint(null); setOpen(true) },
1037
- onFocus: () => setOpen(true),
1038
- onBlur: () => setOpen(false),
1039
- }),
1040
- query !== '' ? React.createElement('button', {
1041
- className: 'sss-clear',
1959
+ React.createElement('button', {
1960
+ className: 'sss-icon-btn',
1042
1961
  type: 'button',
1043
- onClick: () => { setQuery(''); setHint(null); setOpen(false) },
1044
- }, '') : null,
1962
+ 'aria-label': t('searchBtn'),
1963
+ title: t('searchBtn'),
1964
+ onClick: () => inputRef.current?.focus(),
1965
+ }, [SearchIcon]),
1966
+ searchInput,
1967
+ clearButton,
1045
1968
  ]),
1046
- open && q !== '' ? React.createElement('div', { className: 'sss-pop', ref: popRef }, [
1047
- none
1048
- ? (aiStatus === 'loading'
1049
- ? React.createElement('p', { className: 'sss-ai-note' }, t('aiThinking'))
1050
- : aiStatus === 'error'
1051
- ? React.createElement('p', { className: 'sss-ai-note' }, t('aiUnavailable'))
1052
- : (aiEnabled && !aiConfigured)
1053
- ? React.createElement('p', { className: 'sss-ai-note' }, t('aiNeedConfig'))
1054
- : React.createElement('p', { className: 'sss-empty' }, t('empty')))
1055
- : React.createElement('div', { className: 'sss-groups' }, [
1056
- shownPages.length > 0 ? React.createElement('div', { className: 'sss-group' }, [
1057
- React.createElement('div', { className: 'sss-group-title' }, t('pages')),
1058
- React.createElement('ul', { className: 'sss-list' },
1059
- shownPages.map((item, i) => renderPage(item, i))),
1060
- ]) : null,
1061
- shownOptions.length > 0 ? React.createElement('div', { className: 'sss-group' }, [
1062
- React.createElement('div', { className: 'sss-group-title' }, t('options')),
1063
- React.createElement('ul', { className: 'sss-list' },
1064
- shownOptions.map((item, i) => renderOption(item, shownPages.length + i))),
1065
- ]) : null,
1066
- related.length > 0 ? React.createElement('div', { className: 'sss-group' }, [
1067
- React.createElement('div', { className: 'sss-group-title' }, t('guess')),
1068
- React.createElement('ul', { className: 'sss-list' },
1069
- related.map((r, i) => r.kind === 'page'
1070
- ? renderPage(r.item, shownPages.length + shownOptions.length + i, r.via)
1071
- : renderOption(r.item, shownPages.length + shownOptions.length + i, r.via))),
1072
- ]) : null,
1073
- ]),
1074
- ]) : null,
1969
+ warming ? React.createElement('p', { className: 'sss-warm-note' }, t('warming')) : null,
1970
+ open && q !== '' ? React.createElement('div', { className: 'sss-pop', ref: popRef }, [resultsNode]) : null,
1075
1971
  hint !== null
1076
1972
  ? React.createElement('div', { className: 'sss-guide' }, t('jumpFail').replace('{path}', hint))
1077
1973
  : null,
1974
+ showWelcome
1975
+ ? React.createElement(Welcome, { t, onClose: dismissWelcome, onOpenPanel: openPanelFromWelcome })
1976
+ : null,
1078
1977
  ])
1079
1978
  }
1080
1979
 
@@ -1087,8 +1986,18 @@ window.__ModuleLoader__.load({
1087
1986
  return 0
1088
1987
  }
1089
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
+
1090
1998
  function UpdatePanel() {
1091
1999
  const [open, setOpen] = React.useState(true)
2000
+ const [searchStyle, setSearchStyleState] = React.useState(readSearchStyle)
1092
2001
  const [state, setState] = React.useState('idle') // idle | loading | newest | outdated | error
1093
2002
  const [latest, setLatest] = React.useState('')
1094
2003
  const [copied, setCopied] = React.useState(false)
@@ -1102,11 +2011,17 @@ window.__ModuleLoader__.load({
1102
2011
  const [model, setModel] = React.useState(cfg.model)
1103
2012
  const [apiKey, setApiKey] = React.useState(cfg.apiKey)
1104
2013
  const [aiSaved, setAiSaved] = React.useState(false)
2014
+ const [testState, setTestState] = React.useState('idle') // idle | loading | ok | fail
2015
+ const [testMsg, setTestMsg] = React.useState('')
2016
+ const [dshImportState, setDshImportState] = React.useState('idle') // idle | loading | ok | fail
2017
+ const [dshImportMsg, setDshImportMsg] = React.useState('')
1105
2018
  const [, setLogVersion] = React.useState(0)
1106
2019
  const [logCopied, setLogCopied] = React.useState(false)
1107
2020
  const [logCopyFail, setLogCopyFail] = React.useState(false)
1108
2021
  const [logExportFail, setLogExportFail] = React.useState(false)
1109
2022
  const [logCleared, setLogCleared] = React.useState(false)
2023
+ const [confirmClear, setConfirmClear] = React.useState(false)
2024
+ const [confirmClearAi, setConfirmClearAi] = React.useState(false)
1110
2025
 
1111
2026
  React.useEffect(() => {
1112
2027
  const sync = () => setAiOn(aiSearchEnabled())
@@ -1115,16 +2030,13 @@ window.__ModuleLoader__.load({
1115
2030
  }, [])
1116
2031
 
1117
2032
  React.useEffect(() => {
1118
- if (!copied) return
1119
- const off = setTimeout(() => { setCopied(false); setCopyFailed(false) }, 1600)
1120
- return () => clearTimeout(off)
1121
- }, [copied])
2033
+ const sync = () => setSearchStyleState(readSearchStyle())
2034
+ window.addEventListener(SEARCH_STYLE_EVENT, sync)
2035
+ return () => window.removeEventListener(SEARCH_STYLE_EVENT, sync)
2036
+ }, [])
1122
2037
 
1123
- React.useEffect(() => {
1124
- if (!aiSaved) return
1125
- const off = setTimeout(() => setAiSaved(false), 1600)
1126
- return () => clearTimeout(off)
1127
- }, [aiSaved])
2038
+ useAutoReset(copied, () => { setCopied(false); setCopyFailed(false) })
2039
+ useAutoReset(aiSaved, () => setAiSaved(false))
1128
2040
 
1129
2041
  React.useEffect(() => {
1130
2042
  const sync = () => setLogVersion((v) => v + 1)
@@ -1132,23 +2044,9 @@ window.__ModuleLoader__.load({
1132
2044
  return () => window.removeEventListener(LOG_EVENT, sync)
1133
2045
  }, [])
1134
2046
 
1135
- React.useEffect(() => {
1136
- if (!logCopied) return
1137
- const off = setTimeout(() => setLogCopied(false), 1600)
1138
- return () => clearTimeout(off)
1139
- }, [logCopied])
1140
-
1141
- React.useEffect(() => {
1142
- if (!logCleared) return
1143
- const off = setTimeout(() => setLogCleared(false), 1600)
1144
- return () => clearTimeout(off)
1145
- }, [logCleared])
1146
-
1147
- React.useEffect(() => {
1148
- if (!logExportFail) return
1149
- const off = setTimeout(() => setLogExportFail(false), 1600)
1150
- return () => clearTimeout(off)
1151
- }, [logExportFail])
2047
+ useAutoReset(logCopied, () => setLogCopied(false))
2048
+ useAutoReset(logCleared, () => setLogCleared(false))
2049
+ useAutoReset(logExportFail, () => setLogExportFail(false))
1152
2050
 
1153
2051
  const saveAi = () => {
1154
2052
  setAiConfig({
@@ -1161,6 +2059,79 @@ window.__ModuleLoader__.load({
1161
2059
  log('info', '保存 AI 配置', { provider, model: model.trim() })
1162
2060
  }
1163
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
+
2089
+ const requestClearAi = () => setConfirmClearAi(true)
2090
+ const cancelClearAi = () => setConfirmClearAi(false)
2091
+ const clearAiConfig = () => {
2092
+ setConfirmClearAi(false)
2093
+ setAiConfig({ provider: 'openai', baseUrl: '', model: '', apiKey: '' })
2094
+ setProvider('openai')
2095
+ setBaseUrl('')
2096
+ setModel('')
2097
+ setApiKey('')
2098
+ setAiSaved(false)
2099
+ log('info', '清除 API 配置', {})
2100
+ }
2101
+
2102
+ const testService = async () => {
2103
+ if (testState === 'loading') return
2104
+ if (!baseUrl.trim() || !model.trim() || !apiKey.trim()) {
2105
+ setTestState('fail')
2106
+ setTestMsg(t('aiNeedConfig'))
2107
+ return
2108
+ }
2109
+ setTestState('loading')
2110
+ setTestMsg('')
2111
+ const result = await testAiService({ provider, baseUrl, model, apiKey })
2112
+ if (result.ok) {
2113
+ setTestState('ok')
2114
+ setTestMsg(t('aiTestOk'))
2115
+ } else {
2116
+ setTestState('fail')
2117
+ const hintKey = {
2118
+ auth: 'aiTestErrAuth',
2119
+ forbidden: 'aiTestErrForbidden',
2120
+ 'not-found': 'aiTestErrNotFound',
2121
+ 'rate-limit': 'aiTestErrRateLimit',
2122
+ 'bad-request': 'aiTestErrBadRequest',
2123
+ server: 'aiTestErrServer',
2124
+ timeout: 'aiTestErrTimeout',
2125
+ network: 'aiTestErrNetwork',
2126
+ 'bad-response': 'aiTestErrBadResponse',
2127
+ 'unsafe-url': 'aiTestErrUnsafeUrl',
2128
+ }[result.code] ?? 'aiTestErrHttp'
2129
+ const hint = t(hintKey)
2130
+ const detail = result.detail ? `(${result.detail})` : ''
2131
+ setTestMsg(`${t('aiTestFail')}:${hint}${detail}`)
2132
+ }
2133
+ }
2134
+
1164
2135
  const exportLogs = () => {
1165
2136
  setLogExportFail(false)
1166
2137
  try {
@@ -1188,7 +2159,10 @@ window.__ModuleLoader__.load({
1188
2159
  }
1189
2160
  }
1190
2161
 
2162
+ const requestClear = () => setConfirmClear(true)
2163
+ const cancelClear = () => setConfirmClear(false)
1191
2164
  const clearLogs = () => {
2165
+ setConfirmClear(false)
1192
2166
  LOGS.length = 0
1193
2167
  setLogVersion((v) => v + 1)
1194
2168
  setLogCleared(true)
@@ -1204,6 +2178,11 @@ window.__ModuleLoader__.load({
1204
2178
  const data = await res.json()
1205
2179
  const ver = typeof data?.version === 'string' ? data.version : ''
1206
2180
  if (ver === '') throw new Error('no version in registry response')
2181
+ // Only accept a plain semver string so it can never be interpreted
2182
+ // as shell syntax or injected into a URL opened via location.href.
2183
+ if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(ver)) {
2184
+ throw new Error('invalid semver in registry response')
2185
+ }
1207
2186
  setLatest(ver)
1208
2187
  const newer = compareSemver(ver, PKG_VERSION) > 0
1209
2188
  setState(newer ? 'outdated' : 'newest')
@@ -1268,6 +2247,34 @@ window.__ModuleLoader__.load({
1268
2247
  React.createElement('span', { className: 'sss-vr-chevron' + (open ? ' sss-vr-chevron-open' : '') }, '▾'),
1269
2248
  ]),
1270
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
+ ]),
1271
2278
  React.createElement('div', { className: 'sss-vr-field' }, [
1272
2279
  React.createElement('div', { className: 'sss-vr-field-head' }, [
1273
2280
  React.createElement('span', { className: 'sss-vr-label' }, t('aiSearch')),
@@ -1291,7 +2298,11 @@ window.__ModuleLoader__.load({
1291
2298
  ]),
1292
2299
  React.createElement('select', {
1293
2300
  className: 'sss-vr-input sss-vr-select',
2301
+ name: 'dsh-settings-search-provider',
1294
2302
  value: provider,
2303
+ autoComplete: 'off',
2304
+ 'data-lpignore': 'true',
2305
+ 'data-form-type': 'other',
1295
2306
  onChange: (event) => setProvider(event.target.value),
1296
2307
  }, [
1297
2308
  React.createElement('option', { value: 'openai' }, t('aiOpenAI')),
@@ -1303,37 +2314,64 @@ window.__ModuleLoader__.load({
1303
2314
  React.createElement('input', {
1304
2315
  className: 'sss-vr-input',
1305
2316
  type: 'url',
2317
+ name: 'dsh-settings-search-base-url',
1306
2318
  value: baseUrl,
1307
2319
  placeholder: provider === 'anthropic' ? 'https://api.anthropic.com' : 'https://api.openai.com/v1',
1308
2320
  spellCheck: false,
2321
+ autoComplete: 'off',
2322
+ 'data-lpignore': 'true',
2323
+ 'data-1p-ignore': 'true',
2324
+ 'data-form-type': 'other',
1309
2325
  onChange: (event) => setBaseUrl(event.target.value),
1310
2326
  }),
2327
+ React.createElement('p', { className: 'sss-vr-hint' }, t('aiBaseUrlHint')),
1311
2328
  React.createElement('div', { className: 'sss-vr-field-head' }, [
1312
2329
  React.createElement('label', { className: 'sss-vr-label' }, t('aiModel')),
1313
2330
  ]),
1314
2331
  React.createElement('input', {
1315
2332
  className: 'sss-vr-input',
1316
2333
  type: 'text',
2334
+ name: 'dsh-settings-search-model',
1317
2335
  value: model,
1318
2336
  placeholder: provider === 'anthropic' ? 'claude-sonnet-4-20250514' : 'gpt-4o-mini',
1319
2337
  spellCheck: false,
2338
+ autoComplete: 'off',
2339
+ 'data-lpignore': 'true',
2340
+ 'data-1p-ignore': 'true',
2341
+ 'data-form-type': 'other',
1320
2342
  onChange: (event) => setModel(event.target.value),
1321
2343
  }),
1322
2344
  React.createElement('div', { className: 'sss-vr-field-head' }, [
1323
2345
  React.createElement('label', { className: 'sss-vr-label' }, t('aiApiKey')),
1324
2346
  ]),
1325
2347
  React.createElement('input', {
1326
- className: 'sss-vr-input',
1327
- type: 'password',
2348
+ className: 'sss-vr-input sss-vr-mask',
2349
+ type: 'text',
2350
+ name: 'dsh-settings-search-api-key',
1328
2351
  value: apiKey,
1329
2352
  placeholder: 'sk-…',
1330
2353
  spellCheck: false,
1331
2354
  autoComplete: 'off',
2355
+ 'data-lpignore': 'true',
2356
+ 'data-1p-ignore': 'true',
2357
+ 'data-form-type': 'other',
1332
2358
  onChange: (event) => setApiKey(event.target.value),
1333
2359
  }),
1334
- React.createElement('div', { className: 'sss-vr-actions' },
2360
+ React.createElement('div', { className: 'sss-vr-actions' }, [
1335
2361
  React.createElement('button', { className: 'sss-vr-btn sss-vr-primary', type: 'button', onClick: () => { void saveAi() } },
1336
- aiSaved ? t('aiConfigSaved') : t('aiConfigSave'))),
2362
+ aiSaved ? t('aiConfigSaved') : t('aiConfigSave')),
2363
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: testState === 'loading', onClick: () => { void testService() } },
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')),
2367
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => requestClearAi() }, t('aiClear')),
2368
+ ]),
2369
+ dshImportMsg
2370
+ ? React.createElement('p', { className: 'sss-vr-note', style: { color: dshImportState === 'ok' ? '#1e8e3e' : 'var(--dsw-alias-label-danger)' } }, dshImportMsg)
2371
+ : null,
2372
+ testMsg
2373
+ ? React.createElement('p', { className: 'sss-vr-note', style: { color: testState === 'ok' ? '#1e8e3e' : 'var(--dsw-alias-label-danger)' } }, testMsg)
2374
+ : null,
1337
2375
  ]),
1338
2376
  React.createElement('div', { className: 'sss-vr-field' }, [
1339
2377
  React.createElement('div', { className: 'sss-vr-row' }, [
@@ -1384,13 +2422,35 @@ window.__ModuleLoader__.load({
1384
2422
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void exportLogs() } }, t('logExport')),
1385
2423
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void copyLogs() } },
1386
2424
  logCopied ? t('logCopied') : t('logCopy')),
1387
- React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void clearLogs() } },
2425
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: LOGS.length === 0, onClick: () => requestClear() },
1388
2426
  logCleared ? t('logCleared') : t('logClear')),
1389
2427
  ]),
1390
2428
  logCopyFail ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-label-danger)' } }, t('logCopyFail')) : null,
1391
2429
  logExportFail ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-label-danger)' } }, t('logExportFail')) : null,
1392
2430
  ]),
1393
2431
  ]) : null,
2432
+ confirmClear
2433
+ ? React.createElement(ConfirmModal, {
2434
+ t,
2435
+ title: t('logConfirmTitle'),
2436
+ desc: t('logConfirm'),
2437
+ confirmLabel: t('logConfirmYes'),
2438
+ cancelLabel: t('logConfirmCancel'),
2439
+ onConfirm: () => { void clearLogs() },
2440
+ onCancel: () => cancelClear(),
2441
+ })
2442
+ : null,
2443
+ confirmClearAi
2444
+ ? React.createElement(ConfirmModal, {
2445
+ t,
2446
+ title: t('aiClearTitle'),
2447
+ desc: t('aiClearConfirm'),
2448
+ confirmLabel: t('aiClearYes'),
2449
+ cancelLabel: t('logConfirmCancel'),
2450
+ onConfirm: () => { void clearAiConfig() },
2451
+ onCancel: () => cancelClearAi(),
2452
+ })
2453
+ : null,
1394
2454
  ])
1395
2455
  }
1396
2456
 
@@ -1410,7 +2470,7 @@ window.__ModuleLoader__.load({
1410
2470
  }
1411
2471
 
1412
2472
  exports.apply = apply
1413
- exports.inject = ['slots', 'locale']
2473
+ exports.inject = ['slots', 'locale', 'connection']
1414
2474
  return module.exports
1415
2475
  },
1416
2476
  })