@objectivex666/dsh-settings-search 1.7.2 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -5,6 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/@objectivex666/dsh-settings-search)](https://www.npmjs.com/package/@objectivex666/dsh-settings-search)
6
6
  [![license](https://img.shields.io/npm/l/@objectivex666/dsh-settings-search)](./LICENSE)
7
7
  [![node](https://img.shields.io/node/v/@objectivex666/dsh-settings-search)](https://nodejs.org)
8
+ [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
8
9
 
9
10
  **English** · [中文](./README.md)
10
11
 
@@ -13,13 +14,14 @@
13
14
  ## Features
14
15
 
15
16
  - 🔍 **Live search** — a search box at the top of the settings panel's left navigation filters every registered settings page (`settings.section`) and general item (`settings.general.item`) as you type.
17
+ - 🎯 **First-run onboarding (v1.8.0)** — a welcome modal introduces live search, pinyin/intent/AI hints, click-to-jump, and auto-indexing when the settings panel first opens; reopen it anytime from the "Settings Search" page.
16
18
  - 📂 **In-page option search (v1.2.0)** — search beyond pages and reach the specific options inside them: Plugins-page tabs (`settings.plugins.tab`), Web UI plugin cards (`web-ui.plugin.item`), general rows, and more, each shown with a "page › option" breadcrumb.
17
19
  - 🔤 **Pinyin association search (v1.5.0)** — type initials (`sz`) or full pinyin (`shezhi`) to surface Chinese settings items; navigate with `↑`/`↓`, confirm with `Enter`, dismiss with `Esc`, and matched text is highlighted.
18
20
  - 🧠 **Intent search (v1.6.0)** — describe the goal in Chinese (e.g. 「太亮了」 "too bright", 「字太小」 "text too small") to surface matching settings without knowing their names, shown in a "You may want" group.
19
21
  - 🤖 **AI-assisted search (v1.7.0)** — when nothing matches locally, your own model (OpenAI-compatible or Anthropic: Base URL / model / API key) understands your search intent and suggests settings. Off by default; configure it on the plugin's settings page.
20
22
  - 🧾 **Logging & export (v1.7.0)** — records key plugin actions, searches, and AI requests; export, copy, or clear them from the plugin's settings page for troubleshooting. API keys are never logged.
21
23
  - 🧭 **Click-to-jump** — selecting a result clicks the matching left-nav entry to open its section; for tab options it also opens the tab and flashes the target row. A manual-path hint appears if automatic navigation is not possible.
22
- - 🌱 **Progressive indexing** — self-drawing settings rows are harvested from the rendered DOM (via their `[data-slot]` anchors) the first time their page is visited, then stay searchable.
24
+ - 🌱 **Auto-index on startup (v1.8.0)** — the first time the settings panel opens, the plugin automatically visits every settings section, silently reads in-page options (e.g. "Enable Workshop card") into the search index, then restores your current section, so all section options are searchable without having to open each page manually. A "Importing section options…" hint shows under the search box and clears when done.
23
25
  - 🛠 **Built-in settings page (v1.4.0)** — a "Settings Search" page in the left navigation shows the current version and checks npm for a newer release on demand; when one exists it hands you a copy-ready `dsh plugin update` command.
24
26
  - 🧭 **View release notes (v1.7.1)** — the "Settings Search" page has a "View changelog" button next to "Check for updates"; it fetches the GitHub release notes and shows them inline, opening the release page if fetching fails.
25
27
  - 🌐 **i18n** — built-in Chinese/English dictionaries that follow the DSH localization system and are easy to extend.
package/README.md CHANGED
@@ -5,6 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/@objectivex666/dsh-settings-search)](https://www.npmjs.com/package/@objectivex666/dsh-settings-search)
6
6
  [![license](https://img.shields.io/npm/l/@objectivex666/dsh-settings-search)](./LICENSE)
7
7
  [![node](https://img.shields.io/node/v/@objectivex666/dsh-settings-search)](https://nodejs.org)
8
+ [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
8
9
 
9
10
  **中文** · [English](./README.en.md)
10
11
 
@@ -13,13 +14,14 @@
13
14
  ## 功能特性
14
15
 
15
16
  - 🔍 **实时搜索**:在设置面板左侧导航顶部嵌入搜索框,输入关键词立即过滤所有已注册的设置页(`settings.section`)与通用设置项(`settings.general.item`)。
17
+ - 🎯 **新用户引导(v1.8.0)**:首次打开设置面板时弹出引导弹窗,介绍实时搜索、拼音/意图/AI 联想、点击即达与自动导入索引等核心功能;可在「设置搜索」页随时重新打开。
16
18
  - 📂 **页内选项搜索(v1.2.0)**:不只搜页面,还能直达每个设置页内部的具体选项——插件页的标签页(`settings.plugins.tab`)、Web UI 插件的卡片(`web-ui.plugin.item`)、通用设置行等,结果以「页面 › 选项」面包屑展示。
17
19
  - 🔤 **拼音联想搜索(v1.5.0)**:输入拼音首字母(如 `sz`)或全拼(如 `shezhi`)即可联想中文设置项;支持 `↑`/`↓` 选择、`Enter` 跳转、`Esc` 关闭,命中的文字自动高亮。
18
20
  - 🧠 **意图联想搜索(v1.6.0)**:直接描述想做的事(如「太亮了」「字太小」「忘记密码」),无需知道选项名即可联想真实设置项,结果在「猜你想找」分组展示。
19
21
  - 🤖 **AI 联想搜索(v1.7.0)**:没有本地匹配时,用你配置的模型(OpenAI 兼容或 Anthropic:Base URL / 模型名 / API Key)理解搜索意图并推荐设置项。默认关闭,可在「设置搜索」页开启并配置。
20
22
  - 🧾 **日志与导出(v1.7.0)**:记录插件运行、搜索与 AI 请求的关键动作,可在「设置搜索」页一键导出、复制或清空,便于排查问题。不会记录 API Key。
21
23
  - 🧭 **点击即达**:选中结果后自动点击对应的左侧导航项打开所在分区;若是页内标签页会继续点开对应标签,并高亮闪烁目标选项行。无法自动跳转时显示手动路径提示。
22
- - 🌱 **渐进索引**:自绘界面的设置行会在其所在页面首次被访问时从渲染 DOM 中收割标题并加入索引(此后始终可搜)。
24
+ - 🌱 **启动自动索引(v1.8.0)**:首次打开设置面板时自动遍历各设置分区,静默读取分区内选项(如「启用创意工坊卡片」)并导入搜索索引;此后分区选项无需逐页手动打开即可直接搜索,读取完成后自动恢复当前所在分区,导入过程搜索框下方会显示提示,完成后自动消失。
23
25
  - 🛠 **自带设置页(v1.4.0)**:左侧导航新增「设置搜索」页——展示当前版本,一键检查 npm 注册表上的最新版本;有更新时直接给出复制就用的 `dsh plugin update` 命令。
24
26
  - 🧭 **更新说明查看(v1.7.1)**:在「设置搜索」页「检查更新」旁新增「查看更新说明」按钮,可拉取 GitHub Release 更新内容并就地展示;拉取失败时自动跳转 Release 页面。
25
27
  - 🌐 **多语言支持**:内置中英文国际化,可跟随 DSH 本地化系统扩展。
package/lib/client.js CHANGED
@@ -30,11 +30,13 @@ 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.8.0'
34
34
  const NPM_NAME = '@objectivex666/dsh-settings-search'
35
35
  const zh = {
36
36
  title: '设置',
37
37
  placeholder: '搜索设置…',
38
+ searchBtn: '搜索',
39
+ warming: '正在导入各分区选项…',
38
40
  empty: '没有匹配的设置',
39
41
  pages: '设置页',
40
42
  options: '页内选项',
@@ -72,10 +74,30 @@ window.__ModuleLoader__.load({
72
74
  aiOpenAI: 'OpenAI 兼容',
73
75
  aiAnthropic: 'Anthropic',
74
76
  aiBaseUrl: 'Base URL',
77
+ aiBaseUrlHint: '仅支持 http(s) 地址;非本机地址必须使用 https,以免泄露 API Key。',
75
78
  aiModel: '模型名',
76
79
  aiApiKey: 'API Key',
77
80
  aiConfigSave: '保存配置',
78
81
  aiConfigSaved: '已保存 ✓',
82
+ aiClear: '清除配置',
83
+ aiClearTitle: '清除 API 配置',
84
+ aiClearConfirm: '确定要清除 Base URL、模型名与 API Key 吗?此操作不可撤销。',
85
+ aiClearYes: '确认清除',
86
+ aiTest: '测试接口',
87
+ aiTesting: '测试中…',
88
+ aiTestOk: '连接成功 ✓',
89
+ aiTestFail: '连接失败',
90
+ aiTestErrAuth: '认证失败,请检查 API Key 是否正确',
91
+ aiTestErrForbidden: '无权限,请检查 API Key 是否被允许使用该模型',
92
+ aiTestErrNotFound: '接口地址或模型名不存在,请检查 Base URL 与模型名',
93
+ aiTestErrRateLimit: '请求过于频繁或额度不足,请稍后重试或检查套餐',
94
+ aiTestErrBadRequest: '请求参数错误,请检查模型名与接口类型是否匹配',
95
+ aiTestErrServer: '服务端错误,请稍后重试或检查服务状态',
96
+ aiTestErrTimeout: '连接超时,请检查网络与 Base URL 是否可达',
97
+ aiTestErrNetwork: '无法连接,请检查网络或 Base URL',
98
+ aiTestErrBadResponse: '响应格式异常,请确认接口类型与服务兼容',
99
+ aiTestErrHttp: '请求失败(HTTP 状态码错误),请检查地址与配置',
100
+ aiTestErrUnsafeUrl: '接口地址不安全:仅支持 http(s),非本机必须为 https,且不可包含账号密码',
79
101
  logTitle: '日志',
80
102
  logDesc: '记录插件运行、搜索与 AI 请求的关键动作,便于排查问题。',
81
103
  logEmpty: '暂无日志',
@@ -85,11 +107,35 @@ window.__ModuleLoader__.load({
85
107
  logCopyFail: '复制失败,请稍后重试。',
86
108
  logClear: '清空日志',
87
109
  logCleared: '已清空',
110
+ logConfirmTitle: '清空日志',
111
+ logConfirm: '确定要清空全部日志吗?此操作不可撤销。',
112
+ logConfirmYes: '确认清空',
113
+ logConfirmCancel: '取消',
88
114
  logExportFail: '导出失败,请重试。',
115
+ wlTitle: '欢迎使用设置搜索',
116
+ wlSubtitle: '为 DSH 设置面板提供即时搜索与导航,几秒钟就能找到任何设置。',
117
+ wlFeature1Title: '实时搜索',
118
+ wlFeature1Desc: '在顶部搜索框输入关键词,即时过滤所有设置页与页内选项。',
119
+ wlFeature2Title: '拼音与意图联想',
120
+ wlFeature2Desc: '输入拼音首字母(如 sz)或直接描述需求(如「太亮」),也能联想设置项。',
121
+ wlFeature3Title: 'AI 联想(可选)',
122
+ wlFeature3Desc: '没有本地匹配时,可用你配置的模型理解意图并推荐设置项。',
123
+ wlFeature4Title: '点击即达',
124
+ wlFeature4Desc: '选中结果自动跳转到对应分区,并高亮闪烁目标选项。',
125
+ wlFeature5Title: '自带设置页',
126
+ wlFeature5Desc: '左侧导航的「设置搜索」页可检查更新、查看说明与导出日志。',
127
+ wlStart: '开始使用',
128
+ wlSkip: '不再显示',
129
+ wlOpenPanel: '打开设置搜索页',
130
+ wlReopen: '新用户引导',
131
+ wlFeature6Title: '自动导入索引',
132
+ wlFeature6Desc: '首次打开时自动遍历一遍各设置分区,把分区内选项一并纳入搜索,无需逐页手动打开即可直接搜到。',
89
133
  }
90
134
  const en = {
91
135
  title: 'Settings',
92
136
  placeholder: 'Search settings…',
137
+ searchBtn: 'Search',
138
+ warming: 'Importing section options…',
93
139
  empty: 'No matching settings',
94
140
  pages: 'Settings pages',
95
141
  options: 'In-page options',
@@ -127,10 +173,30 @@ window.__ModuleLoader__.load({
127
173
  aiOpenAI: 'OpenAI-compatible',
128
174
  aiAnthropic: 'Anthropic',
129
175
  aiBaseUrl: 'Base URL',
176
+ aiBaseUrlHint: 'Only http(s) URLs are allowed; remote hosts must use https to avoid exposing your API key.',
130
177
  aiModel: 'Model',
131
178
  aiApiKey: 'API Key',
132
179
  aiConfigSave: 'Save config',
133
180
  aiConfigSaved: 'Saved ✓',
181
+ aiClear: 'Clear config',
182
+ aiClearTitle: 'Clear API config',
183
+ aiClearConfirm: 'Clear Base URL, model, and API key? This cannot be undone.',
184
+ aiClearYes: 'Clear config',
185
+ aiTest: 'Test API',
186
+ aiTesting: 'Testing…',
187
+ aiTestOk: 'Connection OK ✓',
188
+ aiTestFail: 'Connection failed',
189
+ aiTestErrAuth: 'Auth failed — check that your API key is correct',
190
+ aiTestErrForbidden: 'Forbidden — check that the API key is allowed to use this model',
191
+ aiTestErrNotFound: 'Not found — check the Base URL and model name',
192
+ aiTestErrRateLimit: 'Rate limited or quota exceeded — try again later or check your plan',
193
+ aiTestErrBadRequest: 'Bad request — check the model name and API type',
194
+ aiTestErrServer: 'Server error — try again later or check the service status',
195
+ aiTestErrTimeout: 'Timed out — check the network and whether the Base URL is reachable',
196
+ aiTestErrNetwork: 'Cannot connect — check the network or Base URL',
197
+ aiTestErrBadResponse: 'Unexpected response — confirm the API type matches the service',
198
+ aiTestErrHttp: 'Request failed (HTTP status error) — check the URL and config',
199
+ aiTestErrUnsafeUrl: 'Unsafe URL — only http(s) is allowed, remote hosts must use https, and embedded credentials are not permitted',
134
200
  logTitle: 'Logs',
135
201
  logDesc: 'Logs key plugin actions, searches, and AI requests to help troubleshoot.',
136
202
  logEmpty: 'No logs yet',
@@ -140,12 +206,35 @@ window.__ModuleLoader__.load({
140
206
  logCopyFail: 'Copy failed. Please try again.',
141
207
  logClear: 'Clear logs',
142
208
  logCleared: 'Cleared',
209
+ logConfirmTitle: 'Clear logs',
210
+ logConfirm: 'Clear all logs? This cannot be undone.',
211
+ logConfirmYes: 'Clear logs',
212
+ logConfirmCancel: 'Cancel',
143
213
  logExportFail: 'Export failed. Please try again.',
214
+ wlTitle: 'Welcome to Settings Search',
215
+ wlSubtitle: 'Instant search and navigation for the DSH settings panel — find any setting in seconds.',
216
+ wlFeature1Title: 'Instant search',
217
+ wlFeature1Desc: 'Type in the top search box to instantly filter settings pages and in-page options.',
218
+ wlFeature2Title: 'Pinyin & intent hints',
219
+ wlFeature2Desc: 'Type pinyin initials (e.g. sz) or describe what you want to do to find settings.',
220
+ wlFeature3Title: 'AI hints (optional)',
221
+ wlFeature3Desc: 'When no local match exists, your own model can understand the intent and suggest settings.',
222
+ wlFeature4Title: 'Click to navigate',
223
+ wlFeature4Desc: 'Selecting a result jumps to its section and flashes the target option.',
224
+ wlFeature5Title: 'Built-in panel',
225
+ wlFeature5Desc: 'The Settings Search page in the sidebar checks updates, shows notes, and exports logs.',
226
+ wlStart: 'Get started',
227
+ wlSkip: "Don't show again",
228
+ wlOpenPanel: 'Open Settings Search panel',
229
+ wlReopen: 'Onboarding',
230
+ wlFeature6Title: 'Auto-index',
231
+ wlFeature6Desc: 'On first open it automatically passes through every settings section, so in-page options are searchable without opening each page manually.',
144
232
  }
145
233
 
146
234
  /** Inner-row slots that represent specific options inside one page. */
147
235
  const DEEP_SLOTS = [
148
236
  { slot: 'settings.plugins.tab', parent: 'plugins' },
237
+ { slot: 'settings.plugin.item', parent: 'plugins' },
149
238
  { slot: 'web-ui.plugin.item', parent: 'web-ui-plugins' },
150
239
  { slot: 'settings.general.item', parent: 'general' },
151
240
  ]
@@ -345,6 +434,21 @@ window.__ModuleLoader__.load({
345
434
  localStorage.setItem(AI_API_KEY_KEY, cfg.apiKey ?? '')
346
435
  window.dispatchEvent(new CustomEvent(AI_CONFIG_EVENT))
347
436
  }
437
+ /** First-run onboarding: remembered via localStorage so it shows once. */
438
+ const ONBOARD_KEY = 'dsh-settings-search:onboarded'
439
+ const WELCOME_EVENT = 'dsh-settings-search:welcome'
440
+ const welcomed = () => {
441
+ if (typeof localStorage === 'undefined') return true
442
+ return localStorage.getItem(ONBOARD_KEY) === '1'
443
+ }
444
+ const setWelcomed = () => {
445
+ if (typeof localStorage === 'undefined') return
446
+ localStorage.setItem(ONBOARD_KEY, '1')
447
+ window.dispatchEvent(new CustomEvent(WELCOME_EVENT, { detail: false }))
448
+ }
449
+ const openWelcome = () => {
450
+ window.dispatchEvent(new CustomEvent(WELCOME_EVENT, { detail: true }))
451
+ }
348
452
  const extractAiKeywords = (raw) => {
349
453
  const text = String(raw ?? '').replace(/^```(?:json)?/i, '').replace(/```$/, '').trim()
350
454
  const start = text.indexOf('{')
@@ -360,12 +464,40 @@ window.__ModuleLoader__.load({
360
464
  .slice(0, 6)
361
465
  }
362
466
  const AI_PROMPT = '你是设置搜索助手。只输出 JSON 对象,格式为 {"keywords":["..."]}。keywords 最多 6 个,每个最多 12 个字符,只能从用户提供的设置索引中选择或提取核心词,不要解释。'
467
+ const loopbackHost = (hostname) => {
468
+ const h = String(hostname || '').replace(/^\[|\]$/g, '').toLowerCase()
469
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '0.0.0.0'
470
+ }
471
+ /**
472
+ * Normalize and validate an AI service base URL. Prevents the API key
473
+ * from being sent to a dangerous scheme (javascript:, data:, …), to a URL
474
+ * with embedded credentials, or over cleartext HTTP to a non-loopback
475
+ * host. Returns a normalized origin+path (no trailing slash) or ''.
476
+ */
477
+ const safeBaseUrl = (baseUrl) => {
478
+ const value = String(baseUrl ?? '').trim()
479
+ if (!value) return ''
480
+ const candidate = /^[a-z][a-z0-9+.-]*:/i.test(value) ? value : `https://${value}`
481
+ let url
482
+ try { url = new URL(candidate) } catch { return '' }
483
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''
484
+ if (url.username || url.password) return ''
485
+ const isHttp = url.protocol === 'http:'
486
+ if (!url.hostname) return ''
487
+ if (isHttp && !loopbackHost(url.hostname)) return ''
488
+ const path = url.pathname === '/' ? '' : url.pathname
489
+ return url.origin + path.replace(/\/+$/, '')
490
+ }
363
491
  const chatCompletionsUrl = (baseUrl) => {
364
- const trimmed = String(baseUrl).trim().replace(/\/+$/, '')
492
+ const safe = safeBaseUrl(baseUrl)
493
+ if (!safe) return ''
494
+ const trimmed = safe.replace(/\/+$/, '')
365
495
  return /\/chat\/completions$/i.test(trimmed) ? trimmed : `${trimmed}/chat/completions`
366
496
  }
367
497
  const anthropicMessagesUrl = (baseUrl) => {
368
- const trimmed = String(baseUrl).trim().replace(/\/+$/, '')
498
+ const safe = safeBaseUrl(baseUrl)
499
+ if (!safe) return ''
500
+ const trimmed = safe.replace(/\/+$/, '')
369
501
  if (/\/v1\/messages$/i.test(trimmed)) return trimmed
370
502
  return /\/v1$/i.test(trimmed) ? `${trimmed}/messages` : `${trimmed}/v1/messages`
371
503
  }
@@ -373,6 +505,8 @@ window.__ModuleLoader__.load({
373
505
  const { provider, baseUrl, model, apiKey } = aiConfig()
374
506
  if (!baseUrl || !model || !apiKey) return []
375
507
  const isAnthropic = provider === 'anthropic'
508
+ const url = isAnthropic ? anthropicMessagesUrl(baseUrl) : chatCompletionsUrl(baseUrl)
509
+ if (!url) return []
376
510
  const startedAt = Date.now()
377
511
  log('info', 'AI 请求', { provider, model, query: String(query).slice(0, 50) })
378
512
  let timer
@@ -407,7 +541,7 @@ window.__ModuleLoader__.load({
407
541
  ],
408
542
  temperature: 0,
409
543
  }
410
- const response = await fetch(isAnthropic ? anthropicMessagesUrl(baseUrl) : chatCompletionsUrl(baseUrl), {
544
+ const response = await fetch(url, {
411
545
  method: 'POST',
412
546
  headers,
413
547
  signal,
@@ -443,6 +577,87 @@ window.__ModuleLoader__.load({
443
577
  return keywords
444
578
  }
445
579
 
580
+ /**
581
+ * Lightweight connectivity test for the configured model service. Sends a
582
+ * minimal chat request and reports whether the endpoint/auth/model work.
583
+ * Returns { ok, code, detail } where code is a stable error kind used to
584
+ * look up a localized hint in the UI.
585
+ */
586
+ const testHttpCode = (status) => {
587
+ const code = Number(status)
588
+ if (code === 401) return 'auth'
589
+ if (code === 403) return 'forbidden'
590
+ if (code === 404) return 'not-found'
591
+ if (code === 408) return 'timeout'
592
+ if (code === 429) return 'rate-limit'
593
+ if (code === 400) return 'bad-request'
594
+ if (code >= 500 && code <= 599) return 'server'
595
+ return 'http'
596
+ }
597
+ const testAiService = async (cfg) => {
598
+ const provider = cfg.provider === 'anthropic' ? 'anthropic' : 'openai'
599
+ const baseUrl = String(cfg.baseUrl ?? '').trim()
600
+ const model = String(cfg.model ?? '').trim()
601
+ const apiKey = String(cfg.apiKey ?? '').trim()
602
+ if (!baseUrl || !model || !apiKey) return { ok: false, code: 'need-config', detail: '' }
603
+ const isAnthropic = provider === 'anthropic'
604
+ const requestUrl = isAnthropic ? anthropicMessagesUrl(baseUrl) : chatCompletionsUrl(baseUrl)
605
+ if (!requestUrl) return { ok: false, code: 'unsafe-url', detail: '' }
606
+ const startedAt = Date.now()
607
+ log('info', '测试 API 服务', { provider, model })
608
+ let timer
609
+ const deadline = new Promise((_, reject) => {
610
+ timer = setTimeout(() => {
611
+ const error = new Error('AI request timed out')
612
+ error.name = 'AI_TIMEOUT'
613
+ reject(error)
614
+ }, 12000)
615
+ })
616
+ const work = (async () => {
617
+ const headers = {
618
+ 'content-type': 'application/json',
619
+ ...(isAnthropic
620
+ ? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true' }
621
+ : { authorization: `Bearer ${apiKey}` }),
622
+ }
623
+ const body = isAnthropic
624
+ ? { model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] }
625
+ : { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, temperature: 0 }
626
+ const response = await fetch(requestUrl, {
627
+ method: 'POST',
628
+ headers,
629
+ body: JSON.stringify(body),
630
+ })
631
+ if (!response.ok) return { ok: false, code: testHttpCode(response.status), detail: `HTTP ${response.status}` }
632
+ const data = await response.json().catch(() => ({}))
633
+ const valid = isAnthropic
634
+ ? (Array.isArray(data?.content) && data.content.length > 0)
635
+ : Boolean(data?.choices?.[0]?.message)
636
+ if (!valid) return { ok: false, code: 'bad-response', detail: '' }
637
+ return { ok: true }
638
+ })()
639
+ try {
640
+ const result = await Promise.race([work, deadline])
641
+ if (result.ok) log('info', '测试 API 成功', { duration: Date.now() - startedAt })
642
+ else log('error', '测试 API 失败', { code: result.code ?? 'http', detail: result.detail ?? '', duration: Date.now() - startedAt })
643
+ return result
644
+ } catch (error) {
645
+ if (error.name === 'AbortError') {
646
+ log('warn', '测试 API 取消', { duration: Date.now() - startedAt })
647
+ throw error
648
+ }
649
+ if (error.name === 'AI_TIMEOUT') {
650
+ log('error', '测试 API 超时', { duration: Date.now() - startedAt })
651
+ return { ok: false, code: 'timeout', detail: '' }
652
+ }
653
+ const reason = String(error?.message ?? error).slice(0, 160)
654
+ log('error', '测试 API 失败', { detail: reason, duration: Date.now() - startedAt })
655
+ return { ok: false, code: 'network', detail: reason }
656
+ } finally {
657
+ clearTimeout(timer)
658
+ }
659
+ }
660
+
446
661
  const CSS = `
447
662
  .sss-wrap { position: relative; display: flex; flex-direction: column; gap: 8px; }
448
663
  .sss-title { font-size: 16px; font-weight: 600; line-height: 24px; color: var(--dsw-alias-label-primary); }
@@ -452,6 +667,10 @@ window.__ModuleLoader__.load({
452
667
  .sss-input::placeholder { color: var(--dsw-alias-label-dimmed); }
453
668
  .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
669
  .sss-clear:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
670
+ .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; }
671
+ .sss-icon-btn:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
672
+ .sss-icon-btn:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; }
673
+ .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
674
  .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
675
  .sss-groups { display: flex; flex-direction: column; gap: 8px; }
457
676
  .sss-group-title { font-size: 11px; color: var(--dsw-alias-label-secondary); letter-spacing: .05em; padding: 2px 6px; }
@@ -460,7 +679,7 @@ window.__ModuleLoader__.load({
460
679
  .sss-item:hover { background: var(--dsw-alias-interactive-bg-hover); }
461
680
  .sss-item.is-active { background: var(--dsw-alias-interactive-bg-hover); }
462
681
  .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; }
682
+ .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
683
  .sss-item-label mark { background: transparent; color: inherit; font-weight: 600; }
465
684
  .sss-item-path { font-size: 10px; line-height: 14px; color: var(--dsw-alias-label-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
466
685
  .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; }
@@ -491,6 +710,7 @@ window.__ModuleLoader__.load({
491
710
  .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
711
  .sss-vr-input:focus-visible { border-color: var(--dsw-alias-brand-primary); outline: none; }
493
712
  .sss-vr-input::placeholder { color: var(--dsw-alias-label-dimmed); }
713
+ .sss-vr-input.sss-vr-mask { -webkit-text-security: disc; }
494
714
  .sss-vr-select { cursor: pointer; }
495
715
  .sss-vr-check { width: 16px; height: 16px; accent-color: var(--dsw-alias-brand-primary); cursor: pointer; margin: 0; }
496
716
  .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; }
@@ -514,6 +734,40 @@ window.__ModuleLoader__.load({
514
734
  .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
735
  .sss-vr-link:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
516
736
  .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; }
737
+ .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; }
738
+ .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; }
739
+ .sss-wl-header { display: flex; align-items: flex-start; gap: 12px; }
740
+ .sss-wl-headText { flex: 1; min-width: 0; }
741
+ .sss-wl-title { margin: 0; color: var(--dsw-alias-label-primary); font-size: 19px; font-weight: 650; line-height: 1.4; }
742
+ .sss-wl-subtitle { margin: 6px 0 0; color: var(--dsw-alias-label-secondary); font-size: 13px; line-height: 1.6; }
743
+ .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; }
744
+ .sss-wl-close:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
745
+ .sss-wl-features { list-style: none; margin: 18px 0 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
746
+ .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); }
747
+ .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; }
748
+ .sss-wl-feature-body { flex: 1; min-width: 0; }
749
+ .sss-wl-feature-title { color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 600; line-height: 1.5; }
750
+ .sss-wl-feature-desc { margin: 2px 0 0; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 1.6; }
751
+ .sss-wl-footer { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 20px; }
752
+ .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; }
753
+ .sss-wl-btn:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
754
+ .sss-wl-primary { background: var(--dsw-alias-label-primary); color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
755
+ .sss-wl-primary:hover { color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
756
+ .sss-wl-skip { margin-left: auto; border: none; background: transparent; color: var(--dsw-alias-label-tertiary); padding: 7px 4px; }
757
+ .sss-wl-skip:hover { color: var(--dsw-alias-label-primary); border-color: transparent; }
758
+ .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; }
759
+ .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; }
760
+ .sss-cf-header { display: flex; align-items: flex-start; gap: 12px; }
761
+ .sss-cf-headText { flex: 1; min-width: 0; }
762
+ .sss-cf-title { margin: 0; color: var(--dsw-alias-label-primary); font-size: 16px; font-weight: 650; line-height: 1.4; }
763
+ .sss-cf-desc { margin: 8px 0 0; color: var(--dsw-alias-label-secondary); font-size: 13px; line-height: 1.6; }
764
+ .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; }
765
+ .sss-cf-close:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
766
+ .sss-cf-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 18px; }
767
+ .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; }
768
+ .sss-cf-btn:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
769
+ .sss-cf-danger { background: #c0392b; color: #fff; border-color: transparent; }
770
+ .sss-cf-danger:hover { background: #a93226; color: #fff; border-color: transparent; }
517
771
  `
518
772
  let stylesInstalled = false
519
773
  function installStyles() {
@@ -558,7 +812,7 @@ window.__ModuleLoader__.load({
558
812
  if (typeof o.locale === 'string') {
559
813
  const t = callSafe(() => ctx.locale.bind(o.locale))
560
814
  if (typeof t === 'function') {
561
- for (const key of ['tab', 'title', 'card.title', 'name']) {
815
+ for (const key of ['tab', 'title', 'card.title', 'name', 'nav']) {
562
816
  const value = callSafe(() => t(key))
563
817
  if (typeof value === 'string' && value !== '' && value !== key) return value
564
818
  }
@@ -576,6 +830,77 @@ window.__ModuleLoader__.load({
576
830
  * stacked section): harvest each direct child as one row, keyed by
577
831
  * render order ("#i") since id pairing is impossible.
578
832
  */
833
+ /** Strip a trailing description (e.g. "关闭后恢复…") from a row label. */
834
+ const trimDescription = (text) => {
835
+ const t = (text ?? '').replace(/\s+/g, ' ').trim()
836
+ if (t === '') return ''
837
+ const re = /[\s,,。;;](关闭后|开启后|启用后|禁用后|创建后|使用时|点击后|勾选后|开启时|关闭时|完成后|启用时|禁用时|开始后|停止后)/
838
+ const m = re.exec(t)
839
+ if (m) {
840
+ const head = t.slice(0, m.index).trim()
841
+ if (head.length >= 2 && head.length <= 60) return head
842
+ }
843
+ const p = t.indexOf('。')
844
+ if (p > 0) {
845
+ const head = t.slice(0, p).trim()
846
+ if (head.length >= 2 && head.length <= 40 && head.length < t.length - p) return head
847
+ }
848
+ return t
849
+ }
850
+
851
+ const mineInnerOptions = (node) => {
852
+ try {
853
+ const seen = new Set()
854
+ const out = []
855
+ const textOf = (el) => (el.textContent ?? '').replace(/\s+/g, ' ').trim()
856
+ const titleOf = (el) => {
857
+ const t = textOf(el)
858
+ return t.length >= 2 && t.length <= 120 ? t : ''
859
+ }
860
+ const bestTitle = (row) => {
861
+ let best = ''
862
+ let bestLen = 1e9
863
+ const sels = ['h1,h2,h3,h4,h5', '[class*="title"]', '[class*="heading"]', '[class*="name"]', '[class*="label"]', 'label', 'strong', 'b']
864
+ for (const sel of sels) {
865
+ for (const el of row.querySelectorAll(sel)) {
866
+ if (el === row) continue
867
+ const t = titleOf(el)
868
+ if (t === '') continue
869
+ if (t.length < bestLen) { best = t; bestLen = t.length }
870
+ }
871
+ }
872
+ if (best !== '') return best
873
+ const raw = textOf(row)
874
+ const part = raw.split('·')[0].trim()
875
+ return part.length >= 2 && part.length <= 120 ? part : raw.slice(0, 120)
876
+ }
877
+ const push = (label, el) => {
878
+ const clean = trimDescription(label)
879
+ const text = textOf(el)
880
+ if (clean.length < 2 || el === node || seen.has(clean)) return
881
+ seen.add(clean)
882
+ out.push({ label: clean, text })
883
+ }
884
+ const ctrls = node.querySelectorAll('input[type="checkbox"], input[type="radio"], [role="switch"], [role="checkbox"], [role="radio"], button[aria-pressed], button[aria-checked]')
885
+ for (const ctl of ctrls) {
886
+ let row = ctl
887
+ for (let d = 0; d < 6 && row.parentElement; d += 1) {
888
+ row = row.parentElement
889
+ if (row === node) break
890
+ const t = textOf(row)
891
+ if (t.length >= 2 && t.length <= 220) break
892
+ }
893
+ push(bestTitle(row), row)
894
+ }
895
+ const heads = node.querySelectorAll('h1,h2,h3,h4,h5,label,[class*="title"],[class*="heading"],[class*="label"],[class*="name"]')
896
+ for (const h of heads) push(bestTitle(h) || titleOf(h), h)
897
+ return out.slice(0, 24)
898
+ } catch {
899
+ return []
900
+ }
901
+ }
902
+
903
+
579
904
  const runHarvest = () => {
580
905
  let grew = false
581
906
  if (typeof document === 'undefined') return false
@@ -601,20 +926,52 @@ window.__ModuleLoader__.load({
601
926
  if (map.has(key)) return
602
927
  const title = titleFromNode(node)
603
928
  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
929
+ const text = (node.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 500)
930
+ const record = { title, idx: i, text, inner: mineInnerOptions(node) }
931
+ map.set(key, record); grew = true
606
932
  })
607
933
  continue
608
934
  }
609
935
  regs.forEach((entry, i) => {
610
- const id = String(entry.options?.id ?? i)
936
+ const id = String(entry.options?.id ?? entry.options?.key ?? i)
611
937
  if (map.has(id)) return
612
938
  const title = titleFromNode(nodes[i])
613
939
  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
940
+ const text = (nodes[i].textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 500)
941
+ const record = { title, idx: i, text, inner: mineInnerOptions(nodes[i]) }
942
+ map.set(id, record); map.set('#' + i, record); grew = true
616
943
  })
617
944
  }
945
+ // Mine the active first-level section's own inline controls
946
+ // (e.g. 创意工坊 → 启用创意工坊卡片), which live outside DEEP_SLOTS.
947
+ try {
948
+ const sections = readSections()
949
+ const activeLabel = activeSectionLabel()
950
+ const active = activeLabel === '' ? undefined : sections.find((s) => s.label === activeLabel)
951
+ if (active && active.id !== '') {
952
+ const containers = [...document.querySelectorAll('[data-slot="settings.section"]')]
953
+ const node = containers.length === 1 && containers[0].childElementCount > 0
954
+ ? containers[0].children[0] ?? null
955
+ : containers[0] ?? null
956
+ if (node) {
957
+ const inner = mineInnerOptions(node)
958
+ const text = (node.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 500)
959
+ if (inner.length > 0 || text !== '') {
960
+ let map = harvest.get('settings.section')
961
+ if (map === undefined) { map = new Map(); harvest.set('settings.section', map) }
962
+ const rec = map.get(active.id)
963
+ if (!rec) {
964
+ map.set(active.id, { title: active.label, idx: 0, text, inner })
965
+ grew = true
966
+ } else if ((rec.inner?.length ?? 0) === 0 && inner.length > 0) {
967
+ rec.inner = inner
968
+ rec.text = text
969
+ grew = true
970
+ }
971
+ }
972
+ }
973
+ }
974
+ } catch { /* section mining is best-effort */ }
618
975
  if (grew) harvestSeq += 1
619
976
  return grew
620
977
  }
@@ -622,7 +979,8 @@ window.__ModuleLoader__.load({
622
979
  const titleFromNode = (node) => {
623
980
  const pick = (el) => {
624
981
  const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim()
625
- return text !== '' && text.length <= 60 ? text : ''
982
+ if (text === '' || text.length > 200) return ''
983
+ return trimDescription(text)
626
984
  }
627
985
  for (const sel of ['h1,h2,h3,h4,h5', '[class*="title"]', '[class*="heading"]', 'label', 'p']) {
628
986
  const hit = node.querySelector(sel)
@@ -632,7 +990,7 @@ window.__ModuleLoader__.load({
632
990
  if (first) {
633
991
  for (const child of first.children) { const text = pick(child); if (text !== '') return text }
634
992
  }
635
- return pick(node).split('·')[0].trim()
993
+ return trimDescription(pick(node).split('·')[0].trim())
636
994
  }
637
995
 
638
996
  const readSections = () => sortEntries(entriesOf('settings.section'))
@@ -653,7 +1011,7 @@ window.__ModuleLoader__.load({
653
1011
  for (const cfg of DEEP_SLOTS) {
654
1012
  const mapped = harvest.get(cfg.slot)
655
1013
  sortEntries(entriesOf(cfg.slot)).forEach((entry, i) => {
656
- const id = String(entry.options?.id ?? '')
1014
+ const id = String(entry.options?.id ?? entry.options?.key ?? '')
657
1015
  if (id === '') return
658
1016
  // static label first, then id-paired harvest, then loose index-keyed harvest
659
1017
  const hitId = mapped?.get(id)
@@ -663,6 +1021,7 @@ window.__ModuleLoader__.load({
663
1021
  if (label === '') return // unmapped, unharvested — invisible until it renders once
664
1022
  const key = cfg.slot + '#' + id
665
1023
  if (out.some((e) => e.key === key)) return
1024
+ const pageLabel = parentLabel(cfg.parent)
666
1025
  out.push({
667
1026
  kind: 'option',
668
1027
  key,
@@ -673,10 +1032,68 @@ window.__ModuleLoader__.load({
673
1032
  hay: label + ' ' + (hitObj?.text ?? ''),
674
1033
  rowIndex: hitObj?.idx ?? i,
675
1034
  parentId: cfg.parent,
676
- parentLabel: parentLabel(cfg.parent),
1035
+ parentLabel: pageLabel,
677
1036
  })
1037
+ if (cfg.slot === 'settings.plugin.item' && Array.isArray(hitObj?.inner)) {
1038
+ hitObj.inner.forEach((op, oi) => {
1039
+ if (op.label === '' || op.label === label) return
1040
+ const innerKey = key + '#o' + oi
1041
+ if (out.some((e) => e.key === innerKey)) return
1042
+ out.push({
1043
+ kind: 'option',
1044
+ key: innerKey,
1045
+ slot: cfg.slot,
1046
+ id: id + '#o' + oi,
1047
+ order: (entry.options?.order ?? 0) + oi / 1000,
1048
+ label: op.label,
1049
+ hay: `${op.text} ${label} ${pageLabel}`.toLowerCase(),
1050
+ rowIndex: hitObj?.idx ?? i,
1051
+ parentId: cfg.parent,
1052
+ parentLabel: label,
1053
+ pageLabel,
1054
+ inner: true,
1055
+ cardLabel: label,
1056
+ })
1057
+ })
1058
+ }
678
1059
  })
679
1060
  }
1061
+ // Expose inline options mined from active first-level sections.
1062
+ {
1063
+ const mapped = harvest.get('settings.section')
1064
+ if (mapped) {
1065
+ sections.forEach((sec) => {
1066
+ const id = sec.id
1067
+ if (id === '') return
1068
+ const rec = mapped.get(id)
1069
+ if (!rec || !Array.isArray(rec.inner)) return
1070
+ const label = sec.label
1071
+ if (label === '') return
1072
+ rec.inner.forEach((op, oi) => {
1073
+ if (op.label === '' || op.label === label) return
1074
+ const innerKey = 'settings.section#' + id + '#o' + oi
1075
+ if (out.some((e) => e.key === innerKey)) return
1076
+ const dup = out.some((e) => e.kind === 'option' && e.parentLabel === label && e.label === op.label)
1077
+ if (dup) return
1078
+ out.push({
1079
+ kind: 'option',
1080
+ key: innerKey,
1081
+ slot: 'settings.section',
1082
+ id: id + '#o' + oi,
1083
+ order: sec.order + oi / 1000,
1084
+ label: op.label,
1085
+ hay: `${op.text} ${label}`.toLowerCase(),
1086
+ rowIndex: rec.idx ?? 0,
1087
+ parentId: id,
1088
+ parentLabel: label,
1089
+ pageLabel: label,
1090
+ inner: true,
1091
+ cardLabel: label,
1092
+ })
1093
+ })
1094
+ })
1095
+ }
1096
+ }
680
1097
  return out.sort((a, b) => a.parentLabel !== b.parentLabel
681
1098
  ? a.parentLabel.localeCompare(b.parentLabel)
682
1099
  : a.order - b.order)
@@ -703,6 +1120,51 @@ window.__ModuleLoader__.load({
703
1120
  return true
704
1121
  }
705
1122
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
1123
+ /** Label of the currently-active first-level settings nav item. */
1124
+ const activeSectionLabel = () => {
1125
+ if (typeof document === 'undefined') return ''
1126
+ const root = document.querySelector('[role="dialog"]') ?? document.body
1127
+ const isTab = (b) => b.getAttribute('role') === 'tab'
1128
+ const buttons = [...root.querySelectorAll('button')].filter((b) => !isTab(b))
1129
+ const byCurrent = buttons.find((b) => b.getAttribute('aria-current') === 'true')
1130
+ const byClass = buttons.find((b) => /_navCell/.test(b.className || '') && /_active/.test(b.className || ''))
1131
+ const hit = byCurrent ?? byClass
1132
+ return (hit?.textContent ?? '').replace(/\s+/g, ' ').trim()
1133
+ }
1134
+
1135
+ let warmed = false
1136
+ /**
1137
+ * One-shot background pass: visit every first-level settings section once,
1138
+ * let each mount, harvest its options, then restore the previously-active one.
1139
+ */
1140
+ const warmAllSections = async (model, onStart, onDone) => {
1141
+ if (warmed || typeof document === 'undefined') return true
1142
+ const sections = model.readSections()
1143
+ if (sections.length === 0) return true
1144
+ const original = activeSectionLabel()
1145
+ if (original === '') return false // settings panel not open yet; retry on next mount
1146
+ warmed = true
1147
+ if (typeof onStart === 'function') onStart()
1148
+ let visited = 0
1149
+ try {
1150
+ for (const sec of sections.slice(0, 40)) {
1151
+ if (!clickNavButton(sec.label)) continue
1152
+ await delay(180)
1153
+ model.runHarvest()
1154
+ visited += 1
1155
+ }
1156
+ if (original !== '') {
1157
+ if (clickNavButton(original)) {
1158
+ await delay(160)
1159
+ model.runHarvest()
1160
+ }
1161
+ }
1162
+ } finally {
1163
+ if (typeof onDone === 'function') onDone()
1164
+ }
1165
+ log('info', '预载设置索引', { total: sections.length, visited })
1166
+ return true
1167
+ }
706
1168
 
707
1169
  function createNavigator(ctx) {
708
1170
  /** Jump to a page result; falls back to showing a manual hint. */
@@ -712,7 +1174,7 @@ window.__ModuleLoader__.load({
712
1174
  }
713
1175
  /** Jump to an in-page option: open its page, select its tab, flash its row. */
714
1176
  const jumpToOption = async (item) => {
715
- const opened = await Promise.resolve(clickNavButton(item.parentLabel))
1177
+ const opened = await Promise.resolve(clickNavButton(item.pageLabel ?? item.parentLabel))
716
1178
  if (opened !== true) { flashOptionRow(item); return false }
717
1179
  await delay(160)
718
1180
  const label = item.label
@@ -727,21 +1189,34 @@ window.__ModuleLoader__.load({
727
1189
  const flashOptionRow = (item) => {
728
1190
  try {
729
1191
  let target = null
1192
+ let card = null
730
1193
  if (typeof item.rowIndex === 'number') {
731
- // wrapper shape: pick the exact rendered row by index
732
1194
  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
- }
1195
+ card = container?.children?.[item.rowIndex] ?? null
1196
+ if (card && !(card.textContent ?? '').includes(item.label)) card = null
738
1197
  }
739
- if (!target) {
1198
+ if (!card) {
740
1199
  const nodes = [...document.querySelectorAll(`[data-slot="${item.slot}"]`)]
741
1200
  const rows = nodes.length === 1 && nodes[0].childElementCount > 0
742
1201
  ? [...nodes[0].children]
743
1202
  : nodes
744
- target = rows.find((n) => (n.textContent ?? '').includes(item.label)) ?? null
1203
+ card = rows.find((n) => (n.textContent ?? '').includes(item.label)) ?? null
1204
+ }
1205
+ if (card) {
1206
+ if (item.inner) {
1207
+ let best = null
1208
+ let bestLen = Infinity
1209
+ for (const el of card.querySelectorAll('*')) {
1210
+ const t = (el.textContent ?? '')
1211
+ if (t.includes(item.label) && t.length < bestLen) {
1212
+ best = el
1213
+ bestLen = t.length
1214
+ }
1215
+ }
1216
+ target = best ?? card
1217
+ } else {
1218
+ target = card
1219
+ }
745
1220
  }
746
1221
  if (!target) return
747
1222
  target.scrollIntoView({ block: 'nearest' })
@@ -785,10 +1260,75 @@ window.__ModuleLoader__.load({
785
1260
  }),
786
1261
  ])
787
1262
 
1263
+ /** Feature cards shown in the first-run welcome modal. */
1264
+ const WELCOME_FEATURES = [
1265
+ { icon: '🔍', titleKey: 'wlFeature1Title', descKey: 'wlFeature1Desc' },
1266
+ { icon: '🔤', titleKey: 'wlFeature2Title', descKey: 'wlFeature2Desc' },
1267
+ { icon: '🤖', titleKey: 'wlFeature3Title', descKey: 'wlFeature3Desc' },
1268
+ { icon: '🧭', titleKey: 'wlFeature4Title', descKey: 'wlFeature4Desc' },
1269
+ { icon: '🧾', titleKey: 'wlFeature5Title', descKey: 'wlFeature5Desc' },
1270
+ { icon: '⚡', titleKey: 'wlFeature6Title', descKey: 'wlFeature6Desc' },
1271
+ ]
1272
+
1273
+ /** First-run onboarding modal rendered as a fixed overlay. */
1274
+ function Welcome({ t, onClose, onOpenPanel }) {
1275
+ return React.createElement('div', {
1276
+ className: 'sss-wl-overlay',
1277
+ onMouseDown: (event) => { if (event.target === event.currentTarget) onClose() },
1278
+ }, [
1279
+ React.createElement('div', { className: 'sss-wl', role: 'dialog', 'aria-modal': 'true' }, [
1280
+ React.createElement('div', { className: 'sss-wl-header' }, [
1281
+ React.createElement('div', { className: 'sss-wl-headText' }, [
1282
+ React.createElement('h2', { className: 'sss-wl-title' }, t('wlTitle')),
1283
+ React.createElement('p', { className: 'sss-wl-subtitle' }, t('wlSubtitle')),
1284
+ ]),
1285
+ React.createElement('button', { className: 'sss-wl-close', type: 'button', 'aria-label': 'Close', onClick: onClose }, '✕'),
1286
+ ]),
1287
+ React.createElement('ul', { className: 'sss-wl-features' },
1288
+ WELCOME_FEATURES.map((f) => React.createElement('li', { className: 'sss-wl-feature', key: f.titleKey }, [
1289
+ React.createElement('span', { className: 'sss-wl-feature-icon', 'aria-hidden': 'true' }, f.icon),
1290
+ React.createElement('span', { className: 'sss-wl-feature-body' }, [
1291
+ React.createElement('span', { className: 'sss-wl-feature-title' }, t(f.titleKey)),
1292
+ React.createElement('p', { className: 'sss-wl-feature-desc' }, t(f.descKey)),
1293
+ ]),
1294
+ ]))),
1295
+ React.createElement('div', { className: 'sss-wl-footer' }, [
1296
+ React.createElement('button', { className: 'sss-wl-btn sss-wl-primary', type: 'button', onClick: onClose }, t('wlStart')),
1297
+ React.createElement('button', { className: 'sss-wl-btn', type: 'button', onClick: onOpenPanel }, t('wlOpenPanel')),
1298
+ React.createElement('button', { className: 'sss-wl-btn sss-wl-skip', type: 'button', onClick: onClose }, t('wlSkip')),
1299
+ ]),
1300
+ ]),
1301
+ ])
1302
+ }
1303
+
1304
+ /** Destructive-action confirmation modal (danger-styled confirm button). */
1305
+ function ConfirmModal({ t, title, desc, confirmLabel, cancelLabel, onConfirm, onCancel }) {
1306
+ return React.createElement('div', {
1307
+ className: 'sss-cf-overlay',
1308
+ onMouseDown: (event) => { if (event.target === event.currentTarget) onCancel() },
1309
+ }, [
1310
+ React.createElement('div', { className: 'sss-cf', role: 'dialog', 'aria-modal': 'true' }, [
1311
+ React.createElement('div', { className: 'sss-cf-header' }, [
1312
+ React.createElement('div', { className: 'sss-cf-headText' }, [
1313
+ React.createElement('h2', { className: 'sss-cf-title' }, title),
1314
+ React.createElement('p', { className: 'sss-cf-desc' }, desc),
1315
+ ]),
1316
+ React.createElement('button', { className: 'sss-cf-close', type: 'button', 'aria-label': 'Close', onClick: onCancel }, '✕'),
1317
+ ]),
1318
+ React.createElement('div', { className: 'sss-cf-actions' }, [
1319
+ React.createElement('button', { className: 'sss-cf-btn sss-cf-danger', type: 'button', onClick: onConfirm }, confirmLabel),
1320
+ React.createElement('button', { className: 'sss-cf-btn', type: 'button', onClick: onCancel }, cancelLabel),
1321
+ ]),
1322
+ ]),
1323
+ ])
1324
+ }
1325
+
788
1326
  function SettingsHeaderSearch() {
789
1327
  const [query, setQuery] = React.useState('')
790
1328
  const [open, setOpen] = React.useState(false)
1329
+ const [showWelcome, setShowWelcome] = React.useState(() => !welcomed())
791
1330
  const [tick, setTick] = React.useState(0)
1331
+ const [warming, setWarming] = React.useState(false)
792
1332
  const [, forceTick] = React.useReducer((x) => x + 1, 0)
793
1333
  void tick
794
1334
  const [aiEnabled, setAiEnabled] = React.useState(aiSearchEnabled)
@@ -803,20 +1343,47 @@ window.__ModuleLoader__.load({
803
1343
  ctx.locale.subscribe(forceTick),
804
1344
  ]
805
1345
  // Grow the index whenever the panel body re-renders (visits new pages).
1346
+ let warmAlive = true
1347
+ let warmAttempts = 0
806
1348
  const observer = typeof MutationObserver === 'function'
807
1349
  ? new MutationObserver(() => {
808
1350
  if (model.runHarvest()) setTick((x) => x + 1)
1351
+ if (!warmed && activeSectionLabel() !== '') {
1352
+ void warmAllSections(model, () => setWarming(true), () => setWarming(false)).then((done) => {
1353
+ if (!warmAlive) return
1354
+ if (done) setTick((x) => x + 1)
1355
+ })
1356
+ }
809
1357
  })
810
1358
  : null
811
1359
  observer?.observe(document.body, { childList: true, subtree: true })
812
- const warm = setTimeout(() => { model.runHarvest(); setTick((x) => x + 1) }, 800)
1360
+ const tryWarm = () => {
1361
+ void warmAllSections(model, () => setWarming(true), () => setWarming(false)).then((done) => {
1362
+ if (!warmAlive) return
1363
+ if (done) { setTick((x) => x + 1); return }
1364
+ warmAttempts += 1
1365
+ if (warmAttempts < 4) setTimeout(tryWarm, 700)
1366
+ })
1367
+ }
1368
+ const warm = setTimeout(() => {
1369
+ model.runHarvest()
1370
+ setTick((x) => x + 1)
1371
+ tryWarm()
1372
+ }, 800)
813
1373
  return () => {
1374
+ warmAlive = false
814
1375
  for (const off of offs) off()
815
1376
  observer?.disconnect()
816
1377
  clearTimeout(warm)
817
1378
  }
818
1379
  }, [])
819
1380
 
1381
+ React.useEffect(() => {
1382
+ const onWelcome = (event) => { if (event?.detail === true) setShowWelcome(true) }
1383
+ window.addEventListener(WELCOME_EVENT, onWelcome)
1384
+ return () => window.removeEventListener(WELCOME_EVENT, onWelcome)
1385
+ }, [])
1386
+
820
1387
  React.useEffect(() => {
821
1388
  const sync = () => setAiEnabled(aiSearchEnabled())
822
1389
  const onConfig = () => forceTick()
@@ -930,6 +1497,7 @@ window.__ModuleLoader__.load({
930
1497
  const [hint, setHint] = React.useState(null)
931
1498
  const [active, setActive] = React.useState(0)
932
1499
  const popRef = React.useRef(null)
1500
+ const inputRef = React.useRef(null)
933
1501
  const activeIndex = Math.min(active, Math.max(results.length - 1, 0))
934
1502
 
935
1503
  React.useEffect(() => { setActive(0) }, [query])
@@ -948,6 +1516,15 @@ window.__ModuleLoader__.load({
948
1516
  })
949
1517
  }
950
1518
 
1519
+ const dismissWelcome = () => {
1520
+ setWelcomed()
1521
+ setShowWelcome(false)
1522
+ }
1523
+ const openPanelFromWelcome = () => {
1524
+ dismissWelcome()
1525
+ clickNavButton(t('updn'))
1526
+ }
1527
+
951
1528
  const mark = (text) => React.createElement('mark', { className: 'sss-hit' }, text)
952
1529
 
953
1530
  /** Highlight the matched substring, or the pinyin-covered prefix. */
@@ -1013,12 +1590,19 @@ window.__ModuleLoader__.load({
1013
1590
  return React.createElement('div', { className: 'sss-wrap' }, [
1014
1591
  React.createElement('div', { className: 'sss-title' }, t('title')),
1015
1592
  React.createElement('div', { className: 'sss-box' }, [
1016
- SearchIcon,
1593
+ React.createElement('button', {
1594
+ className: 'sss-icon-btn',
1595
+ type: 'button',
1596
+ 'aria-label': t('searchBtn'),
1597
+ title: t('searchBtn'),
1598
+ onClick: () => inputRef.current?.focus(),
1599
+ }, [SearchIcon]),
1017
1600
  React.createElement('input', {
1018
1601
  className: 'sss-input',
1019
1602
  value: query,
1020
1603
  placeholder: t('placeholder'),
1021
1604
  spellCheck: false,
1605
+ ref: inputRef,
1022
1606
  onKeyDown: (e) => {
1023
1607
  if (e.key === 'ArrowDown') {
1024
1608
  e.preventDefault()
@@ -1043,6 +1627,7 @@ window.__ModuleLoader__.load({
1043
1627
  onClick: () => { setQuery(''); setHint(null); setOpen(false) },
1044
1628
  }, '✕') : null,
1045
1629
  ]),
1630
+ warming ? React.createElement('p', { className: 'sss-warm-note' }, t('warming')) : null,
1046
1631
  open && q !== '' ? React.createElement('div', { className: 'sss-pop', ref: popRef }, [
1047
1632
  none
1048
1633
  ? (aiStatus === 'loading'
@@ -1075,6 +1660,9 @@ window.__ModuleLoader__.load({
1075
1660
  hint !== null
1076
1661
  ? React.createElement('div', { className: 'sss-guide' }, t('jumpFail').replace('{path}', hint))
1077
1662
  : null,
1663
+ showWelcome
1664
+ ? React.createElement(Welcome, { t, onClose: dismissWelcome, onOpenPanel: openPanelFromWelcome })
1665
+ : null,
1078
1666
  ])
1079
1667
  }
1080
1668
 
@@ -1102,11 +1690,15 @@ window.__ModuleLoader__.load({
1102
1690
  const [model, setModel] = React.useState(cfg.model)
1103
1691
  const [apiKey, setApiKey] = React.useState(cfg.apiKey)
1104
1692
  const [aiSaved, setAiSaved] = React.useState(false)
1693
+ const [testState, setTestState] = React.useState('idle') // idle | loading | ok | fail
1694
+ const [testMsg, setTestMsg] = React.useState('')
1105
1695
  const [, setLogVersion] = React.useState(0)
1106
1696
  const [logCopied, setLogCopied] = React.useState(false)
1107
1697
  const [logCopyFail, setLogCopyFail] = React.useState(false)
1108
1698
  const [logExportFail, setLogExportFail] = React.useState(false)
1109
1699
  const [logCleared, setLogCleared] = React.useState(false)
1700
+ const [confirmClear, setConfirmClear] = React.useState(false)
1701
+ const [confirmClearAi, setConfirmClearAi] = React.useState(false)
1110
1702
 
1111
1703
  React.useEffect(() => {
1112
1704
  const sync = () => setAiOn(aiSearchEnabled())
@@ -1161,6 +1753,52 @@ window.__ModuleLoader__.load({
1161
1753
  log('info', '保存 AI 配置', { provider, model: model.trim() })
1162
1754
  }
1163
1755
 
1756
+ const requestClearAi = () => setConfirmClearAi(true)
1757
+ const cancelClearAi = () => setConfirmClearAi(false)
1758
+ const clearAiConfig = () => {
1759
+ setConfirmClearAi(false)
1760
+ setAiConfig({ provider: 'openai', baseUrl: '', model: '', apiKey: '' })
1761
+ setProvider('openai')
1762
+ setBaseUrl('')
1763
+ setModel('')
1764
+ setApiKey('')
1765
+ setAiSaved(false)
1766
+ log('info', '清除 API 配置', {})
1767
+ }
1768
+
1769
+ const testService = async () => {
1770
+ if (testState === 'loading') return
1771
+ if (!baseUrl.trim() || !model.trim() || !apiKey.trim()) {
1772
+ setTestState('fail')
1773
+ setTestMsg(t('aiNeedConfig'))
1774
+ return
1775
+ }
1776
+ setTestState('loading')
1777
+ setTestMsg('')
1778
+ const result = await testAiService({ provider, baseUrl, model, apiKey })
1779
+ if (result.ok) {
1780
+ setTestState('ok')
1781
+ setTestMsg(t('aiTestOk'))
1782
+ } else {
1783
+ setTestState('fail')
1784
+ const hintKey = {
1785
+ auth: 'aiTestErrAuth',
1786
+ forbidden: 'aiTestErrForbidden',
1787
+ 'not-found': 'aiTestErrNotFound',
1788
+ 'rate-limit': 'aiTestErrRateLimit',
1789
+ 'bad-request': 'aiTestErrBadRequest',
1790
+ server: 'aiTestErrServer',
1791
+ timeout: 'aiTestErrTimeout',
1792
+ network: 'aiTestErrNetwork',
1793
+ 'bad-response': 'aiTestErrBadResponse',
1794
+ 'unsafe-url': 'aiTestErrUnsafeUrl',
1795
+ }[result.code] ?? 'aiTestErrHttp'
1796
+ const hint = t(hintKey)
1797
+ const detail = result.detail ? `(${result.detail})` : ''
1798
+ setTestMsg(`${t('aiTestFail')}:${hint}${detail}`)
1799
+ }
1800
+ }
1801
+
1164
1802
  const exportLogs = () => {
1165
1803
  setLogExportFail(false)
1166
1804
  try {
@@ -1188,7 +1826,10 @@ window.__ModuleLoader__.load({
1188
1826
  }
1189
1827
  }
1190
1828
 
1829
+ const requestClear = () => setConfirmClear(true)
1830
+ const cancelClear = () => setConfirmClear(false)
1191
1831
  const clearLogs = () => {
1832
+ setConfirmClear(false)
1192
1833
  LOGS.length = 0
1193
1834
  setLogVersion((v) => v + 1)
1194
1835
  setLogCleared(true)
@@ -1204,6 +1845,11 @@ window.__ModuleLoader__.load({
1204
1845
  const data = await res.json()
1205
1846
  const ver = typeof data?.version === 'string' ? data.version : ''
1206
1847
  if (ver === '') throw new Error('no version in registry response')
1848
+ // Only accept a plain semver string so it can never be interpreted
1849
+ // as shell syntax or injected into a URL opened via location.href.
1850
+ if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(ver)) {
1851
+ throw new Error('invalid semver in registry response')
1852
+ }
1207
1853
  setLatest(ver)
1208
1854
  const newer = compareSemver(ver, PKG_VERSION) > 0
1209
1855
  setState(newer ? 'outdated' : 'newest')
@@ -1291,7 +1937,11 @@ window.__ModuleLoader__.load({
1291
1937
  ]),
1292
1938
  React.createElement('select', {
1293
1939
  className: 'sss-vr-input sss-vr-select',
1940
+ name: 'dsh-settings-search-provider',
1294
1941
  value: provider,
1942
+ autoComplete: 'off',
1943
+ 'data-lpignore': 'true',
1944
+ 'data-form-type': 'other',
1295
1945
  onChange: (event) => setProvider(event.target.value),
1296
1946
  }, [
1297
1947
  React.createElement('option', { value: 'openai' }, t('aiOpenAI')),
@@ -1303,37 +1953,59 @@ window.__ModuleLoader__.load({
1303
1953
  React.createElement('input', {
1304
1954
  className: 'sss-vr-input',
1305
1955
  type: 'url',
1956
+ name: 'dsh-settings-search-base-url',
1306
1957
  value: baseUrl,
1307
1958
  placeholder: provider === 'anthropic' ? 'https://api.anthropic.com' : 'https://api.openai.com/v1',
1308
1959
  spellCheck: false,
1960
+ autoComplete: 'off',
1961
+ 'data-lpignore': 'true',
1962
+ 'data-1p-ignore': 'true',
1963
+ 'data-form-type': 'other',
1309
1964
  onChange: (event) => setBaseUrl(event.target.value),
1310
1965
  }),
1966
+ React.createElement('p', { className: 'sss-vr-hint' }, t('aiBaseUrlHint')),
1311
1967
  React.createElement('div', { className: 'sss-vr-field-head' }, [
1312
1968
  React.createElement('label', { className: 'sss-vr-label' }, t('aiModel')),
1313
1969
  ]),
1314
1970
  React.createElement('input', {
1315
1971
  className: 'sss-vr-input',
1316
1972
  type: 'text',
1973
+ name: 'dsh-settings-search-model',
1317
1974
  value: model,
1318
1975
  placeholder: provider === 'anthropic' ? 'claude-sonnet-4-20250514' : 'gpt-4o-mini',
1319
1976
  spellCheck: false,
1977
+ autoComplete: 'off',
1978
+ 'data-lpignore': 'true',
1979
+ 'data-1p-ignore': 'true',
1980
+ 'data-form-type': 'other',
1320
1981
  onChange: (event) => setModel(event.target.value),
1321
1982
  }),
1322
1983
  React.createElement('div', { className: 'sss-vr-field-head' }, [
1323
1984
  React.createElement('label', { className: 'sss-vr-label' }, t('aiApiKey')),
1324
1985
  ]),
1325
1986
  React.createElement('input', {
1326
- className: 'sss-vr-input',
1327
- type: 'password',
1987
+ className: 'sss-vr-input sss-vr-mask',
1988
+ type: 'text',
1989
+ name: 'dsh-settings-search-api-key',
1328
1990
  value: apiKey,
1329
1991
  placeholder: 'sk-…',
1330
1992
  spellCheck: false,
1331
1993
  autoComplete: 'off',
1994
+ 'data-lpignore': 'true',
1995
+ 'data-1p-ignore': 'true',
1996
+ 'data-form-type': 'other',
1332
1997
  onChange: (event) => setApiKey(event.target.value),
1333
1998
  }),
1334
- React.createElement('div', { className: 'sss-vr-actions' },
1999
+ React.createElement('div', { className: 'sss-vr-actions' }, [
1335
2000
  React.createElement('button', { className: 'sss-vr-btn sss-vr-primary', type: 'button', onClick: () => { void saveAi() } },
1336
- aiSaved ? t('aiConfigSaved') : t('aiConfigSave'))),
2001
+ aiSaved ? t('aiConfigSaved') : t('aiConfigSave')),
2002
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: testState === 'loading', onClick: () => { void testService() } },
2003
+ testState === 'loading' ? t('aiTesting') : t('aiTest')),
2004
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => requestClearAi() }, t('aiClear')),
2005
+ ]),
2006
+ testMsg
2007
+ ? React.createElement('p', { className: 'sss-vr-note', style: { color: testState === 'ok' ? '#1e8e3e' : 'var(--dsw-alias-label-danger)' } }, testMsg)
2008
+ : null,
1337
2009
  ]),
1338
2010
  React.createElement('div', { className: 'sss-vr-field' }, [
1339
2011
  React.createElement('div', { className: 'sss-vr-row' }, [
@@ -1366,6 +2038,7 @@ window.__ModuleLoader__.load({
1366
2038
  state === 'loading' ? t('updChecking') : t('updCheck')),
1367
2039
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: notesState === 'loading', onClick: () => { void fetchNotes() } },
1368
2040
  notesState === 'loading' ? t('updFetchingNotes') : t('updReleaseNotes')),
2041
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => openWelcome() }, t('wlReopen')),
1369
2042
  ]),
1370
2043
  notesState === 'loaded'
1371
2044
  ? React.createElement('div', { className: 'sss-vr-notes' }, notesBody)
@@ -1384,13 +2057,35 @@ window.__ModuleLoader__.load({
1384
2057
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void exportLogs() } }, t('logExport')),
1385
2058
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void copyLogs() } },
1386
2059
  logCopied ? t('logCopied') : t('logCopy')),
1387
- React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void clearLogs() } },
2060
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: LOGS.length === 0, onClick: () => requestClear() },
1388
2061
  logCleared ? t('logCleared') : t('logClear')),
1389
2062
  ]),
1390
2063
  logCopyFail ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-label-danger)' } }, t('logCopyFail')) : null,
1391
2064
  logExportFail ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-label-danger)' } }, t('logExportFail')) : null,
1392
2065
  ]),
1393
2066
  ]) : null,
2067
+ confirmClear
2068
+ ? React.createElement(ConfirmModal, {
2069
+ t,
2070
+ title: t('logConfirmTitle'),
2071
+ desc: t('logConfirm'),
2072
+ confirmLabel: t('logConfirmYes'),
2073
+ cancelLabel: t('logConfirmCancel'),
2074
+ onConfirm: () => { void clearLogs() },
2075
+ onCancel: () => cancelClear(),
2076
+ })
2077
+ : null,
2078
+ confirmClearAi
2079
+ ? React.createElement(ConfirmModal, {
2080
+ t,
2081
+ title: t('aiClearTitle'),
2082
+ desc: t('aiClearConfirm'),
2083
+ confirmLabel: t('aiClearYes'),
2084
+ cancelLabel: t('logConfirmCancel'),
2085
+ onConfirm: () => { void clearAiConfig() },
2086
+ onCancel: () => cancelClearAi(),
2087
+ })
2088
+ : null,
1394
2089
  ])
1395
2090
  }
1396
2091
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectivex666/dsh-settings-search",
3
- "version": "1.7.2",
3
+ "version": "1.8.0",
4
4
  "description": "DSH settings panel search plugin – instantly filter setting sections and general items with navigation guidance. Installable via `dsh plugin add`.",
5
5
  "keywords": [
6
6
  "dsh",