@objectivex666/dsh-settings-search 1.6.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -16,9 +16,12 @@
16
16
  - 📂 **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
17
  - 🔤 **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
18
  - 🧠 **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
+ - 🤖 **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
+ - 🧾 **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.
19
21
  - 🧭 **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.
20
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.
21
23
  - 🛠 **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
+ - 🧭 **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.
22
25
  - 🌐 **i18n** — built-in Chinese/English dictionaries that follow the DSH localization system and are easy to extend.
23
26
  - 🎨 **Theme-aware** — styled entirely with CSS variables, automatically following the DSH theme (light/dark).
24
27
  - ⚡ **Reactive** — subscribes to settings entries being added, removed, or changed, and refreshes results automatically.
@@ -94,3 +97,21 @@ npm test # validates the host half, the dsh.client manifest, and the client bu
94
97
  > in-session `cordis_define` dynamic-plugin source). Because keeping it in
95
98
  > sync with the shipping package kept drifting silently, it was removed in
96
99
  > v1.4.1; retrieve it from git history (tag `v1.4.0`) if you need a reference.
100
+
101
+ ## Feedback & contributions
102
+
103
+ Issues are welcome — please report bugs, suggest features, or ask questions in
104
+ [GitHub Issues](https://github.com/objectivex666/dsh-settings-search/issues).
105
+ Pull requests are equally welcome via
106
+ [GitHub Pull Requests](https://github.com/objectivex666/dsh-settings-search/pulls).
107
+
108
+ ## Changelog & releases
109
+
110
+ Every version bump keeps git tags and GitHub Releases in sync, with a bilingual body that lists both new features and fixes:
111
+
112
+ - Update [CHANGELOG.md](./CHANGELOG.md) (Chinese) and [CHANGELOG.en.md](./CHANGELOG.en.md) (English), each with `### ✨ Features` and `### 🐛 Fixes` sections.
113
+ - Bump the version in `package.json` and the README, then tag and push as `vX.Y.Z`; GitHub Actions publishes to npm (`npm-publish.yml`) and creates a bilingual GitHub Release (`release.yml`).
114
+ - Preview the release body locally: `node scripts/release.mjs --version <ver>`
115
+ - Tag and publish for real: `node scripts/release.mjs --version <ver> --publish` (requires `gh` CLI, logged in)
116
+
117
+ > Without `--publish` the script only validates and prints the release body; it does not create a tag, push, or release.
package/README.md CHANGED
@@ -16,9 +16,12 @@
16
16
  - 📂 **页内选项搜索(v1.2.0)**:不只搜页面,还能直达每个设置页内部的具体选项——插件页的标签页(`settings.plugins.tab`)、Web UI 插件的卡片(`web-ui.plugin.item`)、通用设置行等,结果以「页面 › 选项」面包屑展示。
17
17
  - 🔤 **拼音联想搜索(v1.5.0)**:输入拼音首字母(如 `sz`)或全拼(如 `shezhi`)即可联想中文设置项;支持 `↑`/`↓` 选择、`Enter` 跳转、`Esc` 关闭,命中的文字自动高亮。
18
18
  - 🧠 **意图联想搜索(v1.6.0)**:直接描述想做的事(如「太亮了」「字太小」「忘记密码」),无需知道选项名即可联想真实设置项,结果在「猜你想找」分组展示。
19
+ - 🤖 **AI 联想搜索(v1.7.0)**:没有本地匹配时,用你配置的模型(OpenAI 兼容或 Anthropic:Base URL / 模型名 / API Key)理解搜索意图并推荐设置项。默认关闭,可在「设置搜索」页开启并配置。
20
+ - 🧾 **日志与导出(v1.7.0)**:记录插件运行、搜索与 AI 请求的关键动作,可在「设置搜索」页一键导出、复制或清空,便于排查问题。不会记录 API Key。
19
21
  - 🧭 **点击即达**:选中结果后自动点击对应的左侧导航项打开所在分区;若是页内标签页会继续点开对应标签,并高亮闪烁目标选项行。无法自动跳转时显示手动路径提示。
20
22
  - 🌱 **渐进索引**:自绘界面的设置行会在其所在页面首次被访问时从渲染 DOM 中收割标题并加入索引(此后始终可搜)。
21
23
  - 🛠 **自带设置页(v1.4.0)**:左侧导航新增「设置搜索」页——展示当前版本,一键检查 npm 注册表上的最新版本;有更新时直接给出复制就用的 `dsh plugin update` 命令。
24
+ - 🧭 **更新说明查看(v1.7.1)**:在「设置搜索」页「检查更新」旁新增「查看更新说明」按钮,可拉取 GitHub Release 更新内容并就地展示;拉取失败时自动跳转 Release 页面。
22
25
  - 🌐 **多语言支持**:内置中英文国际化,可跟随 DSH 本地化系统扩展。
23
26
  - 🎨 **主题适配**:完全使用 CSS 变量,自动跟随 DSH 主题(亮/暗)。
24
27
  - ⚡ **动态响应**:实时订阅设置项的新增/删除/变更,搜索结果自动刷新。
@@ -95,3 +98,20 @@ npm test # 校验 host 半、dsh.client 清单、client bundle handoff
95
98
  > 历史说明:v1.1 及之前仓库曾附带 `dsh-settings-search.js`(会话内
96
99
  > `cordis_define` 动态插件源)。因与正式包代码长期双份维护漂移,已于 v1.4.1
97
100
  > 移除;如需动态用法可从 git 历史(tag `v1.4.0`)取回参考。
101
+
102
+ ## 反馈与贡献
103
+
104
+ 欢迎提交 **Issue** 反馈 Bug、功能建议或使用疑问:
105
+ [GitHub Issues](https://github.com/objectivex666/dsh-settings-search/issues),
106
+ 也欢迎直接发起 [Pull Request](https://github.com/objectivex666/dsh-settings-search/pulls)。
107
+
108
+ ## 更新日志与发布
109
+
110
+ 每次版本更新需同时更新 GitHub Release 与 git tag,正文为双语,并写明「新功能」与「修复」:
111
+
112
+ - 更新 [CHANGELOG.md](./CHANGELOG.md)(中文)与 [CHANGELOG.en.md](./CHANGELOG.en.md)(英文),两者各含 `### ✨ 新功能 / Features` 与 `### 🐛 修复 / Fixes`。
113
+ - 同步 `package.json` 与 README 的版本号,按 `vX.Y.Z` 打 tag 并推送;GitHub Actions 会同时发布到 npm(`npm-publish.yml`)并创建双语 GitHub Release(`release.yml`)。
114
+ - 本地预览 release 正文:`node scripts/release.mjs --version <ver>`
115
+ - 实际打 tag 并发布:`node scripts/release.mjs --version <ver> --publish`(需 `gh` CLI 且已登录)
116
+
117
+ > 未带 `--publish` 时脚本只校验并打印 release 正文,不会创建 tag、推送或发布。
package/lib/client.js CHANGED
@@ -30,7 +30,7 @@ window.__ModuleLoader__.load({
30
30
 
31
31
  const NS = 'settings-search-v5'
32
32
  /** Single source of truth is package.json — scripts/check.mjs asserts this matches. */
33
- const PKG_VERSION = '1.6.0'
33
+ const PKG_VERSION = '1.7.1'
34
34
  const NPM_NAME = '@objectivex666/dsh-settings-search'
35
35
  const zh = {
36
36
  title: '设置',
@@ -54,6 +54,38 @@ window.__ModuleLoader__.load({
54
54
  updCopy: '复制命令',
55
55
  updCopied: '已复制 ✓',
56
56
  updCopyFail: '复制失败,请长按上方命令手动复制。',
57
+ updCLI: 'DSH CLI',
58
+ updNpx: 'npx',
59
+ updRelease: '查看 release notes',
60
+ updReleaseNotes: '查看更新说明',
61
+ updFetchingNotes: '正在获取…',
62
+ updRestart: '更新完成后重启 DSH,新版本才会生效。',
63
+ aiSearch: 'AI 联想搜索',
64
+ aiDesc: '没有直接匹配时,用你配置的模型(OpenAI 兼容或 Anthropic)理解目标并推荐设置项。',
65
+ aiPrivacy: 'AI 请求会发送到你配置的模型服务;API Key 仅保存在本机浏览器。',
66
+ aiOn: '已开启',
67
+ aiOff: '已关闭',
68
+ aiThinking: '正在用 AI 理解搜索意图…',
69
+ aiUnavailable: 'AI 联想暂不可用',
70
+ aiNeedConfig: '请先在「设置搜索」页配置 Base URL、模型名与 API Key。',
71
+ aiProvider: '接口类型',
72
+ aiOpenAI: 'OpenAI 兼容',
73
+ aiAnthropic: 'Anthropic',
74
+ aiBaseUrl: 'Base URL',
75
+ aiModel: '模型名',
76
+ aiApiKey: 'API Key',
77
+ aiConfigSave: '保存配置',
78
+ aiConfigSaved: '已保存 ✓',
79
+ logTitle: '日志',
80
+ logDesc: '记录插件运行、搜索与 AI 请求的关键动作,便于排查问题。',
81
+ logEmpty: '暂无日志',
82
+ logExport: '导出日志',
83
+ logCopy: '复制日志',
84
+ logCopied: '已复制 ✓',
85
+ logCopyFail: '复制失败,请稍后重试。',
86
+ logClear: '清空日志',
87
+ logCleared: '已清空',
88
+ logExportFail: '导出失败,请重试。',
57
89
  }
58
90
  const en = {
59
91
  title: 'Settings',
@@ -77,6 +109,38 @@ window.__ModuleLoader__.load({
77
109
  updCopy: 'Copy command',
78
110
  updCopied: 'Copied ✓',
79
111
  updCopyFail: 'Copy failed — please long-press the command above.',
112
+ updCLI: 'DSH CLI',
113
+ updNpx: 'npx',
114
+ updRelease: 'View release notes',
115
+ updReleaseNotes: 'View changelog',
116
+ updFetchingNotes: 'Fetching…',
117
+ updRestart: 'Restart DSH after updating for the new version to take effect.',
118
+ aiSearch: 'AI-assisted search',
119
+ aiDesc: 'When no direct match exists, your own model (OpenAI-compatible or Anthropic) understands your goal and suggests settings.',
120
+ aiPrivacy: 'AI requests are sent to the model service you configure; the API key stays only in your browser.',
121
+ aiOn: 'On',
122
+ aiOff: 'Off',
123
+ aiThinking: 'Understanding your intent with AI…',
124
+ aiUnavailable: 'AI suggestions are unavailable',
125
+ aiNeedConfig: 'Configure Base URL, model, and API key in the Settings Search panel first.',
126
+ aiProvider: 'API type',
127
+ aiOpenAI: 'OpenAI-compatible',
128
+ aiAnthropic: 'Anthropic',
129
+ aiBaseUrl: 'Base URL',
130
+ aiModel: 'Model',
131
+ aiApiKey: 'API Key',
132
+ aiConfigSave: 'Save config',
133
+ aiConfigSaved: 'Saved ✓',
134
+ logTitle: 'Logs',
135
+ logDesc: 'Logs key plugin actions, searches, and AI requests to help troubleshoot.',
136
+ logEmpty: 'No logs yet',
137
+ logExport: 'Export logs',
138
+ logCopy: 'Copy logs',
139
+ logCopied: 'Copied ✓',
140
+ logCopyFail: 'Copy failed. Please try again.',
141
+ logClear: 'Clear logs',
142
+ logCleared: 'Cleared',
143
+ logExportFail: 'Export failed. Please try again.',
80
144
  }
81
145
 
82
146
  /** Inner-row slots that represent specific options inside one page. */
@@ -223,6 +287,162 @@ window.__ModuleLoader__.load({
223
287
  return hits
224
288
  }
225
289
 
290
+ const AI_TOGGLE_EVENT = 'dsh-settings-search:ai-toggle'
291
+ const AI_CONFIG_EVENT = 'dsh-settings-search:ai-config'
292
+ /** Simple in-memory log ring used by the settings panel's export/log UI. */
293
+ const LOG_EVENT = 'dsh-settings-search:log'
294
+ const LOG_MAX = 500
295
+ const LOGS = []
296
+ const log = (level, msg, meta) => {
297
+ if (typeof window === 'undefined') return
298
+ LOGS.push({ time: new Date().toISOString(), level, msg, meta })
299
+ if (LOGS.length > LOG_MAX) LOGS.splice(0, LOGS.length - LOG_MAX)
300
+ try { window.dispatchEvent(new CustomEvent(LOG_EVENT, { detail: LOGS.length })) } catch {}
301
+ }
302
+ const formatLogs = (logs) => {
303
+ const lines = [
304
+ 'dsh-settings-search log',
305
+ `version: ${PKG_VERSION}`,
306
+ `exported: ${new Date().toISOString()}`,
307
+ `count: ${logs.length}`,
308
+ '',
309
+ ]
310
+ for (const entry of logs) {
311
+ lines.push(`[${entry.time}] [${entry.level}] ${entry.msg}${entry.meta ? ' ' + JSON.stringify(entry.meta) : ''}`)
312
+ }
313
+ return lines.join('\n')
314
+ }
315
+ const AI_CACHE = new Map()
316
+ const AI_STORAGE_KEY = 'dsh-settings-search:ai-enabled'
317
+ const AI_PROVIDER_KEY = 'dsh-settings-search:ai-provider'
318
+ const AI_BASE_URL_KEY = 'dsh-settings-search:ai-base-url'
319
+ const AI_MODEL_KEY = 'dsh-settings-search:ai-model'
320
+ const AI_API_KEY_KEY = 'dsh-settings-search:ai-api-key'
321
+ const aiSearchEnabled = () => {
322
+ if (typeof localStorage === 'undefined') return false
323
+ return localStorage.getItem(AI_STORAGE_KEY) === '1'
324
+ }
325
+ const setAiSearchEnabled = (enabled) => {
326
+ if (typeof localStorage === 'undefined') return
327
+ localStorage.setItem(AI_STORAGE_KEY, enabled ? '1' : '0')
328
+ window.dispatchEvent(new CustomEvent(AI_TOGGLE_EVENT, { detail: enabled }))
329
+ }
330
+ const aiConfig = () => {
331
+ if (typeof localStorage === 'undefined') return { provider: 'openai', baseUrl: '', model: '', apiKey: '' }
332
+ const providerRaw = (localStorage.getItem(AI_PROVIDER_KEY) ?? 'openai').trim()
333
+ return {
334
+ 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(),
338
+ }
339
+ }
340
+ const setAiConfig = (cfg) => {
341
+ if (typeof localStorage === 'undefined') return
342
+ localStorage.setItem(AI_PROVIDER_KEY, cfg.provider === 'anthropic' ? 'anthropic' : 'openai')
343
+ localStorage.setItem(AI_BASE_URL_KEY, cfg.baseUrl ?? '')
344
+ localStorage.setItem(AI_MODEL_KEY, cfg.model ?? '')
345
+ localStorage.setItem(AI_API_KEY_KEY, cfg.apiKey ?? '')
346
+ window.dispatchEvent(new CustomEvent(AI_CONFIG_EVENT))
347
+ }
348
+ const extractAiKeywords = (raw) => {
349
+ const text = String(raw ?? '').replace(/^```(?:json)?/i, '').replace(/```$/, '').trim()
350
+ const start = text.indexOf('{')
351
+ const end = text.lastIndexOf('}')
352
+ if (start === -1 || end <= start) return []
353
+ const parsed = JSON.parse(text.slice(start, end + 1))
354
+ const values = Array.isArray(parsed) ? parsed : parsed?.keywords
355
+ if (!Array.isArray(values)) return []
356
+ return [...new Set(values
357
+ .filter((value) => typeof value === 'string')
358
+ .map((value) => value.trim().slice(0, 24))
359
+ .filter(Boolean))]
360
+ .slice(0, 6)
361
+ }
362
+ const AI_PROMPT = '你是设置搜索助手。只输出 JSON 对象,格式为 {"keywords":["..."]}。keywords 最多 6 个,每个最多 12 个字符,只能从用户提供的设置索引中选择或提取核心词,不要解释。'
363
+ const chatCompletionsUrl = (baseUrl) => {
364
+ const trimmed = String(baseUrl).trim().replace(/\/+$/, '')
365
+ return /\/chat\/completions$/i.test(trimmed) ? trimmed : `${trimmed}/chat/completions`
366
+ }
367
+ 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`
371
+ }
372
+ const requestUserAi = async (query, labels, signal) => {
373
+ const { provider, baseUrl, model, apiKey } = aiConfig()
374
+ if (!baseUrl || !model || !apiKey) return []
375
+ const isAnthropic = provider === 'anthropic'
376
+ const startedAt = Date.now()
377
+ log('info', 'AI 请求', { provider, model, query: String(query).slice(0, 50) })
378
+ let timer
379
+ const deadline = new Promise((_, reject) => {
380
+ timer = setTimeout(() => {
381
+ const error = new Error('AI request timed out')
382
+ error.name = 'AI_TIMEOUT'
383
+ reject(error)
384
+ }, 12000)
385
+ })
386
+ const work = (async () => {
387
+ const userContent = `用户目标:${query}\n设置索引:\n${labels}`
388
+ const headers = {
389
+ 'content-type': 'application/json',
390
+ ...(isAnthropic
391
+ ? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true' }
392
+ : { authorization: `Bearer ${apiKey}` }),
393
+ }
394
+ const body = isAnthropic
395
+ ? {
396
+ model,
397
+ max_tokens: 1024,
398
+ system: AI_PROMPT,
399
+ messages: [{ role: 'user', content: userContent }],
400
+ temperature: 0,
401
+ }
402
+ : {
403
+ model,
404
+ messages: [
405
+ { role: 'system', content: AI_PROMPT },
406
+ { role: 'user', content: userContent },
407
+ ],
408
+ temperature: 0,
409
+ }
410
+ const response = await fetch(isAnthropic ? anthropicMessagesUrl(baseUrl) : chatCompletionsUrl(baseUrl), {
411
+ method: 'POST',
412
+ headers,
413
+ signal,
414
+ body: JSON.stringify(body),
415
+ })
416
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
417
+ const data = await response.json()
418
+ const content = isAnthropic
419
+ ? (data?.content ?? []).filter((block) => block?.type === 'text').map((block) => block.text).join('')
420
+ : data?.choices?.[0]?.message?.content ?? ''
421
+ return extractAiKeywords(content)
422
+ })()
423
+ try {
424
+ const keywords = await Promise.race([work, deadline])
425
+ log('info', 'AI 结果', { count: keywords.length, duration: Date.now() - startedAt })
426
+ return keywords
427
+ } catch (error) {
428
+ if (error.name === 'AbortError') {
429
+ log('warn', 'AI 请求取消', { duration: Date.now() - startedAt })
430
+ throw error
431
+ }
432
+ log('error', 'AI 请求失败', { message: String(error?.message ?? error).slice(0, 160), duration: Date.now() - startedAt })
433
+ throw error
434
+ } finally {
435
+ clearTimeout(timer)
436
+ }
437
+ }
438
+ const requestAiKeywords = async (query, labels, signal) => {
439
+ const cacheKey = query.toLowerCase()
440
+ if (AI_CACHE.has(cacheKey)) return AI_CACHE.get(cacheKey)
441
+ const keywords = await requestUserAi(query, labels, signal)
442
+ if (keywords.length > 0) AI_CACHE.set(cacheKey, keywords)
443
+ return keywords
444
+ }
445
+
226
446
  const CSS = `
227
447
  .sss-wrap { position: relative; display: flex; flex-direction: column; gap: 8px; }
228
448
  .sss-title { font-size: 16px; font-weight: 600; line-height: 24px; color: var(--dsw-alias-label-primary); }
@@ -245,23 +465,55 @@ window.__ModuleLoader__.load({
245
465
  .sss-item-path { font-size: 10px; line-height: 14px; color: var(--dsw-alias-label-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
246
466
  .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; }
247
467
  .sss-item-kind.sss-via { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
468
+ .sss-ai-note { margin: 0; padding: 6px 8px; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; }
248
469
  .sss-empty { margin: 0; padding: 10px 8px; color: var(--dsw-alias-label-secondary); font-size: 13px; }
249
470
  .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; }
250
471
  @keyframes sssFlashBg { 0% { background-color: var(--dsw-alias-interactive-bg-hover); } 100% { background-color: transparent; } }
251
472
  .sss-flash { animation: sssFlashBg 1.8s ease-out; border-radius: 8px; }
252
- .sss-upd { display: flex; flex-direction: column; gap: 12px; padding: 16px; max-width: 560px; }
253
- .sss-upd-title { font-size: 15px; font-weight: 600; line-height: 22px; color: var(--dsw-alias-label-primary); margin: 0; }
254
- .sss-upd-desc { margin: 0; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
255
- .sss-upd-card { display: flex; flex-direction: column; gap: 10px; padding: 14px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; background: var(--dsw-alias-bg-layer-1); }
256
- .sss-upd-row { display: flex; align-items: center; gap: 8px; font-size: 13px; line-height: 20px; }
257
- .sss-upd-key { flex-shrink: 0; width: 88px; color: var(--dsw-alias-label-secondary); }
258
- .sss-upd-val { color: var(--dsw-alias-label-primary); }
259
- .sss-upd-badge { flex-shrink: 0; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 18px; }
260
- .sss-btn { height: 28px; padding: 0 12px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font-size: 12px; cursor: pointer; }
261
- .sss-btn:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
262
- .sss-btn:disabled { opacity: .55; cursor: default; }
263
- .sss-cmd { user-select: all; font-family: ui-monospace, Consolas, 'Courier New', monospace; font-size: 11.5px; line-height: 20px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; padding: 4px 8px; word-break: break-all; }
264
- .sss-upd-note { margin: 0; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
473
+ .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; }
474
+ .sss-vr:hover { border-color: var(--dsw-alias-label-dimmed); }
475
+ .sss-vr-open { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-label-dimmed); }
476
+ .sss-vr-header { appearance: none; width: 100%; font: inherit; color: inherit; text-align: left; cursor: pointer; background: 0 0; border: 0; border-radius: 12px; align-items: center; gap: 12px; padding: 14px 16px; display: flex; }
477
+ .sss-vr-header:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; }
478
+ .sss-vr-headText { flex-direction: column; flex: 1; gap: 4px; min-width: 0; display: flex; }
479
+ .sss-vr-name { color: var(--dsw-alias-label-primary); font-size: 15px; font-weight: 600; line-height: 1.4; }
480
+ .sss-vr-desc { color: var(--dsw-alias-label-tertiary); font-size: 13px; line-height: 1.5; }
481
+ .sss-vr-chevron { color: var(--dsw-alias-label-tertiary); flex: none; transition: transform .16s; font-size: 12px; line-height: 1; }
482
+ .sss-vr-chevron-open { transform: rotate(180deg); }
483
+ .sss-vr-body { border-top: 1px solid var(--dsw-alias-border-l2); margin: 0 16px; padding-bottom: 8px; }
484
+ .sss-vr-field { content-visibility: auto; contain-intrinsic-size: auto 96px; flex-direction: column; gap: 6px; padding: 12px 0; display: flex; }
485
+ .sss-vr-field + .sss-vr-field { border-top: 1px solid var(--dsw-alias-border-l2); }
486
+ .sss-vr-form { gap: 6px; }
487
+ .sss-vr-field-head { align-items: center; gap: 8px; display: flex; }
488
+ .sss-vr-label { min-width: 0; color: var(--dsw-alias-label-primary); flex: 1; font-size: 13px; font-weight: 500; line-height: 1.5; }
489
+ .sss-vr-hint { color: var(--dsw-alias-label-tertiary); margin: 0; font-size: 12px; line-height: 1.5; }
490
+ .sss-vr-value { color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 500; line-height: 1.5; }
491
+ .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
+ .sss-vr-input:focus-visible { border-color: var(--dsw-alias-brand-primary); outline: none; }
493
+ .sss-vr-input::placeholder { color: var(--dsw-alias-label-dimmed); }
494
+ .sss-vr-select { cursor: pointer; }
495
+ .sss-vr-check { width: 16px; height: 16px; accent-color: var(--dsw-alias-brand-primary); cursor: pointer; margin: 0; }
496
+ .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
+ .sss-vr-btn:hover:not(:disabled) { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
498
+ .sss-vr-btn:disabled { opacity: .4; cursor: default; }
499
+ .sss-vr-btn:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
500
+ .sss-vr-primary { background: var(--dsw-alias-label-primary); color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
501
+ .sss-vr-primary:hover:not(:disabled) { color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
502
+ .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; }
503
+ .sss-vr-code { user-select: all; display: block; width: 100%; box-sizing: border-box; overflow-x: auto; white-space: pre; padding: 9px 11px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-layer-3); color: var(--dsw-alias-label-primary); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; line-height: 1.55; }
504
+ .sss-vr-note { color: var(--dsw-alias-label-tertiary); margin: 0; font-size: 12px; line-height: 1.5; }
505
+ .sss-vr-row { display: flex; align-items: center; gap: 8px; font-size: 13px; line-height: 20px; flex-wrap: wrap; }
506
+ .sss-vr-rowname { color: var(--dsw-alias-label-tertiary); font-size: 13px; line-height: 1.5; }
507
+ .sss-vr-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding-top: 1px; }
508
+ .sss-vr-update { width: 100%; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; background: var(--dsw-alias-bg-module-platform); padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; margin-top: 6px; }
509
+ .sss-vr-update-title { font-size: 13px; font-weight: 650; color: var(--dsw-alias-label-primary); line-height: 1.5; }
510
+ .sss-vr-update-command { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
511
+ .sss-vr-update-command-label { font-size: 11px; font-weight: 500; color: var(--dsw-alias-label-tertiary); line-height: 1.4; }
512
+ .sss-vr-update-note { margin: 0; padding: 7px 9px; border-left: 2px solid var(--dsw-alias-label-dimmed); background: var(--dsw-alias-bg-layer-3); border-radius: 0 7px 7px 0; color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 1.55; }
513
+ .sss-vr-update-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding-top: 1px; }
514
+ .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
+ .sss-vr-link:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
516
+ .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; }
265
517
  `
266
518
  let stylesInstalled = false
267
519
  function installStyles() {
@@ -504,6 +756,7 @@ window.__ModuleLoader__.load({
504
756
  }
505
757
 
506
758
  function apply(ctx) {
759
+ log('info', '应用装载', { version: PKG_VERSION })
507
760
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'settings-search: dictionaries')
508
761
  const t = ctx.locale.bind(NS)
509
762
  ctx.effect(() => {
@@ -538,6 +791,10 @@ window.__ModuleLoader__.load({
538
791
  const [tick, setTick] = React.useState(0)
539
792
  const [, forceTick] = React.useReducer((x) => x + 1, 0)
540
793
  void tick
794
+ const [aiEnabled, setAiEnabled] = React.useState(aiSearchEnabled)
795
+ const [aiKeywords, setAiKeywords] = React.useState([])
796
+ const [aiStatus, setAiStatus] = React.useState('idle')
797
+ const aiAbortRef = React.useRef(null)
541
798
 
542
799
  React.useEffect(() => {
543
800
  const watched = ['settings.section', ...DEEP_SLOTS.map((c) => c.slot)]
@@ -560,6 +817,18 @@ window.__ModuleLoader__.load({
560
817
  }
561
818
  }, [])
562
819
 
820
+ React.useEffect(() => {
821
+ const sync = () => setAiEnabled(aiSearchEnabled())
822
+ const onConfig = () => forceTick()
823
+ window.addEventListener(AI_TOGGLE_EVENT, sync)
824
+ window.addEventListener(AI_CONFIG_EVENT, onConfig)
825
+ return () => {
826
+ window.removeEventListener(AI_TOGGLE_EVENT, sync)
827
+ window.removeEventListener(AI_CONFIG_EVENT, onConfig)
828
+ aiAbortRef.current?.abort()
829
+ }
830
+ }, [])
831
+
563
832
  const q = query.trim().toLowerCase()
564
833
  const tokens = q.split(/\s+/).filter(Boolean)
565
834
 
@@ -584,6 +853,9 @@ window.__ModuleLoader__.load({
584
853
  if (kwMatch(item, haystackLower, kw)) return kw
585
854
  }
586
855
  }
856
+ for (const kw of aiKeywords) {
857
+ if (kwMatch(item, haystackLower, kw)) return `AI · ${kw}`
858
+ }
587
859
  return null
588
860
  }
589
861
  const mappedSections = q === '' ? [] : sections.map((s) => ({
@@ -598,6 +870,8 @@ window.__ModuleLoader__.load({
598
870
  }))
599
871
  const shownPages = mappedSections.filter((s) => directMatch(s, s.hay))
600
872
  const shownOptions = mappedOptions.filter((o) => directMatch(o, o.hay))
873
+ const noLocalMatch = shownPages.length === 0 && shownOptions.length === 0 &&
874
+ tokens.every((tok) => (expandedKeywords.get(tok) ?? []).length === 0)
601
875
  const related = [
602
876
  ...mappedSections
603
877
  .map((s) => ({ item: s, kind: 'page', via: relatedKeyword(s, s.hay) }))
@@ -609,6 +883,50 @@ window.__ModuleLoader__.load({
609
883
  const none = q !== '' && shownPages.length === 0 && shownOptions.length === 0 && related.length === 0
610
884
  const results = [...shownPages, ...shownOptions, ...related.map((r) => r.item)]
611
885
 
886
+ const cfg = aiConfig()
887
+ const aiConfigured = Boolean(cfg.baseUrl && cfg.model && cfg.apiKey)
888
+ const aiReady = aiEnabled && aiConfigured && q.length >= 2 && noLocalMatch
889
+ React.useEffect(() => {
890
+ if (!aiReady) return
891
+ setAiKeywords([])
892
+ setAiStatus('idle')
893
+ let alive = true
894
+ const controller = new AbortController()
895
+ const timer = setTimeout(() => {
896
+ aiAbortRef.current?.abort()
897
+ aiAbortRef.current = controller
898
+ setAiStatus('loading')
899
+ const labels = [...new Set([
900
+ ...sections.map((item) => item.label),
901
+ ...deeps.map((item) => `${item.parentLabel} ${item.label}`),
902
+ ].map((label) => label.trim()).filter(Boolean))].slice(0, 48).join('\n')
903
+ requestAiKeywords(q, labels, controller.signal).then((keywords) => {
904
+ if (!alive) return
905
+ setAiKeywords(keywords)
906
+ setAiStatus(keywords.length > 0 ? 'ready' : 'idle')
907
+ }).catch((error) => {
908
+ if (!alive || error.name === 'AbortError') return
909
+ setAiKeywords([])
910
+ setAiStatus('error')
911
+ }).finally(() => {
912
+ if (aiAbortRef.current === controller) aiAbortRef.current = null
913
+ })
914
+ }, 700)
915
+ return () => {
916
+ alive = false
917
+ clearTimeout(timer)
918
+ controller.abort()
919
+ }
920
+ }, [aiReady, aiEnabled, q])
921
+
922
+ const loggedQueryRef = React.useRef('')
923
+ React.useEffect(() => {
924
+ if (q === '') return
925
+ if (loggedQueryRef.current === q) return
926
+ loggedQueryRef.current = q
927
+ log('info', '搜索', { query: query.slice(0, 50), pages: shownPages.length, options: shownOptions.length, related: related.length })
928
+ }, [q, shownPages.length, shownOptions.length, related.length])
929
+
612
930
  const [hint, setHint] = React.useState(null)
613
931
  const [active, setActive] = React.useState(0)
614
932
  const popRef = React.useRef(null)
@@ -621,6 +939,7 @@ window.__ModuleLoader__.load({
621
939
  }, [activeIndex, q])
622
940
  const pick = (item) => {
623
941
  setOpen(false)
942
+ log('info', '打开设置', { kind: item.kind, id: item.id || item.key || '', label: String(item.label).slice(0, 60) })
624
943
  const jumped = item.kind === 'page'
625
944
  ? navigator.jumpToPage(item)
626
945
  : navigator.jumpToOption(item)
@@ -726,7 +1045,13 @@ window.__ModuleLoader__.load({
726
1045
  ]),
727
1046
  open && q !== '' ? React.createElement('div', { className: 'sss-pop', ref: popRef }, [
728
1047
  none
729
- ? React.createElement('p', { className: 'sss-empty' }, t('empty'))
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')))
730
1055
  : React.createElement('div', { className: 'sss-groups' }, [
731
1056
  shownPages.length > 0 ? React.createElement('div', { className: 'sss-group' }, [
732
1057
  React.createElement('div', { className: 'sss-group-title' }, t('pages')),
@@ -763,10 +1088,31 @@ window.__ModuleLoader__.load({
763
1088
  }
764
1089
 
765
1090
  function UpdatePanel() {
1091
+ const [open, setOpen] = React.useState(true)
766
1092
  const [state, setState] = React.useState('idle') // idle | loading | newest | outdated | error
767
1093
  const [latest, setLatest] = React.useState('')
768
1094
  const [copied, setCopied] = React.useState(false)
769
1095
  const [copyFailed, setCopyFailed] = React.useState(false)
1096
+ const [notesState, setNotesState] = React.useState('idle') // idle | loading | loaded
1097
+ const [notesBody, setNotesBody] = React.useState('')
1098
+ const [aiOn, setAiOn] = React.useState(aiSearchEnabled)
1099
+ const cfg = aiConfig()
1100
+ const [provider, setProvider] = React.useState(cfg.provider)
1101
+ const [baseUrl, setBaseUrl] = React.useState(cfg.baseUrl)
1102
+ const [model, setModel] = React.useState(cfg.model)
1103
+ const [apiKey, setApiKey] = React.useState(cfg.apiKey)
1104
+ const [aiSaved, setAiSaved] = React.useState(false)
1105
+ const [, setLogVersion] = React.useState(0)
1106
+ const [logCopied, setLogCopied] = React.useState(false)
1107
+ const [logCopyFail, setLogCopyFail] = React.useState(false)
1108
+ const [logExportFail, setLogExportFail] = React.useState(false)
1109
+ const [logCleared, setLogCleared] = React.useState(false)
1110
+
1111
+ React.useEffect(() => {
1112
+ const sync = () => setAiOn(aiSearchEnabled())
1113
+ window.addEventListener(AI_TOGGLE_EVENT, sync)
1114
+ return () => window.removeEventListener(AI_TOGGLE_EVENT, sync)
1115
+ }, [])
770
1116
 
771
1117
  React.useEffect(() => {
772
1118
  if (!copied) return
@@ -774,6 +1120,80 @@ window.__ModuleLoader__.load({
774
1120
  return () => clearTimeout(off)
775
1121
  }, [copied])
776
1122
 
1123
+ React.useEffect(() => {
1124
+ if (!aiSaved) return
1125
+ const off = setTimeout(() => setAiSaved(false), 1600)
1126
+ return () => clearTimeout(off)
1127
+ }, [aiSaved])
1128
+
1129
+ React.useEffect(() => {
1130
+ const sync = () => setLogVersion((v) => v + 1)
1131
+ window.addEventListener(LOG_EVENT, sync)
1132
+ return () => window.removeEventListener(LOG_EVENT, sync)
1133
+ }, [])
1134
+
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])
1152
+
1153
+ const saveAi = () => {
1154
+ setAiConfig({
1155
+ provider,
1156
+ baseUrl: baseUrl.trim(),
1157
+ model: model.trim(),
1158
+ apiKey: apiKey.trim(),
1159
+ })
1160
+ setAiSaved(true)
1161
+ log('info', '保存 AI 配置', { provider, model: model.trim() })
1162
+ }
1163
+
1164
+ const exportLogs = () => {
1165
+ setLogExportFail(false)
1166
+ try {
1167
+ const blob = new Blob([formatLogs(LOGS)], { type: 'text/plain;charset=utf-8' })
1168
+ const url = URL.createObjectURL(blob)
1169
+ const a = document.createElement('a')
1170
+ a.href = url
1171
+ a.download = `dsh-settings-search-${Date.now()}.log`
1172
+ document.body.appendChild(a)
1173
+ a.click()
1174
+ a.remove()
1175
+ URL.revokeObjectURL(url)
1176
+ } catch {
1177
+ setLogExportFail(true)
1178
+ }
1179
+ }
1180
+
1181
+ const copyLogs = async () => {
1182
+ try {
1183
+ await navigator.clipboard.writeText(formatLogs(LOGS))
1184
+ setLogCopied(true)
1185
+ setLogCopyFail(false)
1186
+ } catch {
1187
+ setLogCopyFail(true)
1188
+ }
1189
+ }
1190
+
1191
+ const clearLogs = () => {
1192
+ LOGS.length = 0
1193
+ setLogVersion((v) => v + 1)
1194
+ setLogCleared(true)
1195
+ }
1196
+
777
1197
  const check = async () => {
778
1198
  setState('loading')
779
1199
  setCopied(false)
@@ -785,54 +1205,192 @@ window.__ModuleLoader__.load({
785
1205
  const ver = typeof data?.version === 'string' ? data.version : ''
786
1206
  if (ver === '') throw new Error('no version in registry response')
787
1207
  setLatest(ver)
788
- setState(compareSemver(ver, PKG_VERSION) > 0 ? 'outdated' : 'newest')
1208
+ const newer = compareSemver(ver, PKG_VERSION) > 0
1209
+ setState(newer ? 'outdated' : 'newest')
1210
+ log('info', '检查更新', { result: newer ? 'outdated' : 'newest', latest: ver })
789
1211
  } catch {
790
1212
  setState('error')
1213
+ log('error', '检查更新失败', {})
791
1214
  }
792
1215
  }
793
1216
 
1217
+ const updateCommand = latest ? `dsh plugin --profile web add ${NPM_NAME}@${latest}` : ''
1218
+ const npxUpdateCommand = latest ? `npx @deepseek-ai/dsh plugin --profile web add ${NPM_NAME}@${latest}` : ''
1219
+ const releasesUrl = latest
1220
+ ? `https://github.com/objectivex666/dsh-settings-search/releases/tag/v${latest}`
1221
+ : 'https://github.com/objectivex666/dsh-settings-search/releases/latest'
1222
+ const fetchNotes = async () => {
1223
+ setNotesState('loading')
1224
+ setNotesBody('')
1225
+ try {
1226
+ const res = await fetch('https://api.github.com/repos/objectivex666/dsh-settings-search/releases/latest', {
1227
+ headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'dsh-settings-search' },
1228
+ })
1229
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
1230
+ const data = await res.json()
1231
+ const body = typeof data?.body === 'string' ? data.body.trim() : ''
1232
+ if (body === '') throw new Error('empty release body')
1233
+ setNotesBody(body)
1234
+ setNotesState('loaded')
1235
+ log('info', '查看更新说明', { ok: true, tag: typeof data?.tag_name === 'string' ? data.tag_name : '' })
1236
+ } catch {
1237
+ setNotesState('idle')
1238
+ setNotesBody('')
1239
+ log('error', '查看更新说明失败(跳转 release)', {})
1240
+ const opened = window.open(releasesUrl, '_blank', 'noopener,noreferrer')
1241
+ if (!opened) window.location.href = releasesUrl
1242
+ }
1243
+ }
1244
+ const commandBlock = (label, command) => React.createElement('div', { className: 'sss-vr-update-command', key: command }, [
1245
+ React.createElement('div', { className: 'sss-vr-update-command-label' }, label),
1246
+ React.createElement('code', { className: 'sss-vr-code' }, command),
1247
+ ])
794
1248
  const copyCmd = async () => {
795
- const cmd = `dsh plugin --profile web update ${NPM_NAME}`
796
- try { await navigator.clipboard.writeText(cmd); setCopied(true); setCopyFailed(false) } catch { setCopyFailed(true) }
1249
+ try { await navigator.clipboard.writeText(updateCommand); setCopied(true); setCopyFailed(false) } catch { setCopyFailed(true) }
797
1250
  }
798
1251
 
799
- const statusNode = {
800
- idle: React.createElement('span', { className: 'sss-upd-val' }, '—'),
801
- loading: React.createElement('span', { className: 'sss-upd-val' }, t('updChecking')),
802
- newest: React.createElement('span', { className: 'sss-upd-val' }, t('updNewest')),
803
- error: React.createElement('span', { className: 'sss-upd-val' }, t('updErr')),
804
- outdated: latest === '' ? null : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } }, [
805
- React.createElement('p', { className: 'sss-upd-note' }, t('updFound').replace('{ver}', `v${latest}`)),
806
- React.createElement('code', { className: 'sss-cmd' }, `dsh plugin --profile web update ${NPM_NAME}`),
807
- React.createElement('div', null,
808
- React.createElement('button', { className: 'sss-btn', type: 'button', onClick: () => { void copyCmd() } },
809
- copied ? t('updCopied') : t('updCopy')),
810
- copyFailed ? React.createElement('p', { className: 'sss-upd-note', style: { color: 'var(--dsw-alias-label-danger)' } }, t('updCopyFail')) : null),
811
- ]),
812
- }
1252
+ // Mirror Vision Router: check for updates automatically when the panel opens.
1253
+ React.useEffect(() => {
1254
+ if (state === 'idle') void check()
1255
+ }, [state]) // eslint-disable-line react-hooks/exhaustive-deps
813
1256
 
814
- return React.createElement('div', { className: 'sss-upd' }, [
815
- React.createElement('h3', { className: 'sss-upd-title' }, `${t('updn')} v${PKG_VERSION}`),
816
- React.createElement('p', { className: 'sss-upd-desc' }, t('updDesc')),
817
- React.createElement('div', { className: 'sss-upd-card' }, [
818
- React.createElement('div', { className: 'sss-upd-row' }, [
819
- React.createElement('span', { className: 'sss-upd-key' }, t('updCurrent')),
820
- React.createElement('code', { className: 'sss-cmd' }, PKG_VERSION),
821
- state === 'newest' ? React.createElement('span', { className: 'sss-upd-badge' }, 'npm ✓') : null,
1257
+ return React.createElement('div', { className: 'sss-vr' + (open ? ' sss-vr-open' : '') }, [
1258
+ React.createElement('button', {
1259
+ type: 'button',
1260
+ className: 'sss-vr-header',
1261
+ 'aria-expanded': open,
1262
+ onClick: () => setOpen(!open),
1263
+ }, [
1264
+ React.createElement('span', { className: 'sss-vr-headText' }, [
1265
+ React.createElement('span', { className: 'sss-vr-name' }, t('updn')),
1266
+ React.createElement('span', { className: 'sss-vr-desc' }, t('updDesc')),
822
1267
  ]),
823
- state === 'outdated' || state === 'loading'
824
- ? React.createElement('div', { className: 'sss-upd-row' }, [
825
- React.createElement('span', { className: 'sss-upd-key' }, t('updLatestKnown')),
826
- state === 'loading' ? React.createElement('span', { className: 'sss-upd-val' }, '…') : React.createElement('code', { className: 'sss-cmd' }, latest),
827
- ])
828
- : null,
829
- state === 'outdated' || state === 'loading' || state === 'error' || state === 'idle' || state === 'newest'
830
- ? (state === 'outdated' ? statusNode.outdated : statusNode[state])
831
- : null,
832
- React.createElement('div', { className: 'sss-upd-row' },
833
- React.createElement('button', { className: 'sss-btn', type: 'button', disabled: state === 'loading', onClick: () => { void check() } },
834
- state === 'loading' ? t('updChecking') : t('updCheck'))),
1268
+ React.createElement('span', { className: 'sss-vr-chevron' + (open ? ' sss-vr-chevron-open' : '') }, '▾'),
835
1269
  ]),
1270
+ open ? React.createElement('div', { className: 'sss-vr-body' }, [
1271
+ React.createElement('div', { className: 'sss-vr-field' }, [
1272
+ React.createElement('div', { className: 'sss-vr-field-head' }, [
1273
+ React.createElement('span', { className: 'sss-vr-label' }, t('aiSearch')),
1274
+ React.createElement('input', {
1275
+ className: 'sss-vr-check',
1276
+ type: 'checkbox',
1277
+ checked: aiOn,
1278
+ onChange: (event) => {
1279
+ const next = event.target.checked
1280
+ setAiSearchEnabled(next)
1281
+ setAiOn(next)
1282
+ },
1283
+ }),
1284
+ ]),
1285
+ React.createElement('p', { className: 'sss-vr-hint' }, t('aiDesc')),
1286
+ React.createElement('p', { className: 'sss-vr-hint' }, t('aiPrivacy')),
1287
+ ]),
1288
+ React.createElement('div', { className: 'sss-vr-field sss-vr-form' }, [
1289
+ React.createElement('div', { className: 'sss-vr-field-head' }, [
1290
+ React.createElement('label', { className: 'sss-vr-label' }, t('aiProvider')),
1291
+ ]),
1292
+ React.createElement('select', {
1293
+ className: 'sss-vr-input sss-vr-select',
1294
+ value: provider,
1295
+ onChange: (event) => setProvider(event.target.value),
1296
+ }, [
1297
+ React.createElement('option', { value: 'openai' }, t('aiOpenAI')),
1298
+ React.createElement('option', { value: 'anthropic' }, t('aiAnthropic')),
1299
+ ]),
1300
+ React.createElement('div', { className: 'sss-vr-field-head' }, [
1301
+ React.createElement('label', { className: 'sss-vr-label' }, t('aiBaseUrl')),
1302
+ ]),
1303
+ React.createElement('input', {
1304
+ className: 'sss-vr-input',
1305
+ type: 'url',
1306
+ value: baseUrl,
1307
+ placeholder: provider === 'anthropic' ? 'https://api.anthropic.com' : 'https://api.openai.com/v1',
1308
+ spellCheck: false,
1309
+ onChange: (event) => setBaseUrl(event.target.value),
1310
+ }),
1311
+ React.createElement('div', { className: 'sss-vr-field-head' }, [
1312
+ React.createElement('label', { className: 'sss-vr-label' }, t('aiModel')),
1313
+ ]),
1314
+ React.createElement('input', {
1315
+ className: 'sss-vr-input',
1316
+ type: 'text',
1317
+ value: model,
1318
+ placeholder: provider === 'anthropic' ? 'claude-sonnet-4-20250514' : 'gpt-4o-mini',
1319
+ spellCheck: false,
1320
+ onChange: (event) => setModel(event.target.value),
1321
+ }),
1322
+ React.createElement('div', { className: 'sss-vr-field-head' }, [
1323
+ React.createElement('label', { className: 'sss-vr-label' }, t('aiApiKey')),
1324
+ ]),
1325
+ React.createElement('input', {
1326
+ className: 'sss-vr-input',
1327
+ type: 'password',
1328
+ value: apiKey,
1329
+ placeholder: 'sk-…',
1330
+ spellCheck: false,
1331
+ autoComplete: 'off',
1332
+ onChange: (event) => setApiKey(event.target.value),
1333
+ }),
1334
+ React.createElement('div', { className: 'sss-vr-actions' },
1335
+ React.createElement('button', { className: 'sss-vr-btn sss-vr-primary', type: 'button', onClick: () => { void saveAi() } },
1336
+ aiSaved ? t('aiConfigSaved') : t('aiConfigSave'))),
1337
+ ]),
1338
+ React.createElement('div', { className: 'sss-vr-field' }, [
1339
+ React.createElement('div', { className: 'sss-vr-row' }, [
1340
+ React.createElement('span', { className: 'sss-vr-rowname' }, t('updCurrent')),
1341
+ React.createElement('span', { className: 'sss-vr-value' }, PKG_VERSION),
1342
+ state === 'newest' ? React.createElement('span', { className: 'sss-vr-badge' }, 'npm ✓') : null,
1343
+ ]),
1344
+ state === 'loading'
1345
+ ? React.createElement('p', { className: 'sss-vr-note' }, t('updChecking'))
1346
+ : null,
1347
+ state === 'error'
1348
+ ? React.createElement('p', { className: 'sss-vr-note' }, t('updErr'))
1349
+ : null,
1350
+ state === 'outdated'
1351
+ ? React.createElement('div', { className: 'sss-vr-update' }, [
1352
+ React.createElement('div', { className: 'sss-vr-update-title' }, t('updFound').replace('{ver}', `v${latest}`)),
1353
+ commandBlock(t('updCLI'), updateCommand),
1354
+ commandBlock(t('updNpx'), npxUpdateCommand),
1355
+ React.createElement('p', { className: 'sss-vr-update-note' }, t('updRestart')),
1356
+ React.createElement('div', { className: 'sss-vr-update-actions' }, [
1357
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void copyCmd() } },
1358
+ copied ? t('updCopied') : t('updCopy')),
1359
+ React.createElement('a', { className: 'sss-vr-link', href: releasesUrl, target: '_blank', rel: 'noopener noreferrer' }, t('updRelease')),
1360
+ ]),
1361
+ copyFailed ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-label-danger)' } }, t('updCopyFail')) : null,
1362
+ ])
1363
+ : null,
1364
+ React.createElement('div', { className: 'sss-vr-actions' }, [
1365
+ React.createElement('button', { className: 'sss-vr-btn sss-vr-primary', type: 'button', disabled: state === 'loading', onClick: () => { void check() } },
1366
+ state === 'loading' ? t('updChecking') : t('updCheck')),
1367
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: notesState === 'loading', onClick: () => { void fetchNotes() } },
1368
+ notesState === 'loading' ? t('updFetchingNotes') : t('updReleaseNotes')),
1369
+ ]),
1370
+ notesState === 'loaded'
1371
+ ? React.createElement('div', { className: 'sss-vr-notes' }, notesBody)
1372
+ : null,
1373
+ ]),
1374
+ React.createElement('div', { className: 'sss-vr-field' }, [
1375
+ React.createElement('div', { className: 'sss-vr-field-head' }, [
1376
+ React.createElement('span', { className: 'sss-vr-label' }, t('logTitle')),
1377
+ LOGS.length > 0 ? React.createElement('span', { className: 'sss-vr-badge' }, String(LOGS.length)) : null,
1378
+ ]),
1379
+ React.createElement('p', { className: 'sss-vr-hint' }, t('logDesc')),
1380
+ LOGS.length === 0
1381
+ ? React.createElement('p', { className: 'sss-vr-note' }, t('logEmpty'))
1382
+ : null,
1383
+ React.createElement('div', { className: 'sss-vr-actions' }, [
1384
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void exportLogs() } }, t('logExport')),
1385
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void copyLogs() } },
1386
+ logCopied ? t('logCopied') : t('logCopy')),
1387
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void clearLogs() } },
1388
+ logCleared ? t('logCleared') : t('logClear')),
1389
+ ]),
1390
+ logCopyFail ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-label-danger)' } }, t('logCopyFail')) : null,
1391
+ logExportFail ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-label-danger)' } }, t('logExportFail')) : null,
1392
+ ]),
1393
+ ]) : null,
836
1394
  ])
837
1395
  }
838
1396
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectivex666/dsh-settings-search",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
4
4
  "description": "DSH settings panel search plugin – instantly filter setting sections and general items with navigation guidance. Installable via `dsh plugin add`.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-settings-search release helper.
4
+ *
5
+ * Keeps git tags and GitHub Releases in sync on every version bump, with a
6
+ * bilingual (Chinese / English) body built from CHANGELOG.md + CHANGELOG.en.md.
7
+ * Always include both "新功能 / Features" and "修复 / Fixes" sections.
8
+ *
9
+ * Usage:
10
+ * node scripts/release.mjs --version 1.8.0 # validate + print preview
11
+ * node scripts/release.mjs --version 1.8.0 --publish # tag + push + gh release
12
+ * node scripts/release.mjs --version 1.8.0 --dry-run # same as default, explicit
13
+ *
14
+ * --publish requires `gh` CLI and a git remote named `origin`.
15
+ */
16
+ import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'
17
+ import { execFileSync } from 'node:child_process'
18
+ import { resolve, dirname, join } from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+ import { tmpdir } from 'node:os'
21
+
22
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
23
+
24
+ function arg(name) {
25
+ const at = process.argv.indexOf(name)
26
+ return at >= 0 ? process.argv[at + 1] : undefined
27
+ }
28
+
29
+ const version = process.argv.includes('--version') ? arg('--version') : undefined
30
+ const publish = process.argv.includes('--publish')
31
+ const dryRun = process.argv.includes('--dry-run')
32
+
33
+ const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'))
34
+ const target = version ?? pkg.version
35
+ if (pkg.version !== target) {
36
+ console.error(`version mismatch: package.json=${pkg.version}, requested=${target}`)
37
+ process.exit(1)
38
+ }
39
+ const tag = `v${target}`
40
+ const repo = pkg.repository?.url?.replace(/^git\+/, '').replace(/\.git$/, '') ?? 'https://github.com/objectivex666/dsh-settings-search'
41
+
42
+ function section(file, version) {
43
+ const text = readFileSync(join(ROOT, file), 'utf8')
44
+ const target = `## v${version}`
45
+ const lines = text.split(/\r?\n/)
46
+ let start = -1
47
+ for (let i = 0; i < lines.length; i += 1) {
48
+ if (lines[i].trim() === target) { start = i; break }
49
+ }
50
+ if (start === -1) return ''
51
+ const out = []
52
+ for (let i = start + 1; i < lines.length; i += 1) {
53
+ const line = lines[i]
54
+ if (/^## v/.test(line) || line.trim() === '---') break
55
+ out.push(line)
56
+ }
57
+ return out.join('\n').trim()
58
+ }
59
+
60
+ const zh = section('CHANGELOG.md', target)
61
+ const en = section('CHANGELOG.en.md', target)
62
+ if (!zh || !en) {
63
+ console.error(`missing changelog section for v${target} in CHANGELOG.md / CHANGELOG.en.md`)
64
+ process.exit(1)
65
+ }
66
+ if (!/新功能|修复/.test(zh) || !/Features|Fixes/.test(en)) {
67
+ console.error('changelog must include both Features and Fixes sections (新功能/修复, Features/Fixes)')
68
+ process.exit(1)
69
+ }
70
+
71
+ const body = [
72
+ `# v${target}`,
73
+ '',
74
+ `> ${repo}`,
75
+ '',
76
+ zh,
77
+ '',
78
+ '---',
79
+ '',
80
+ en,
81
+ '',
82
+ ].join('\n')
83
+
84
+ console.log('===== release body =====')
85
+ console.log(body)
86
+ console.log('========================')
87
+
88
+ function run(cmd, args) {
89
+ if (!publish || dryRun) {
90
+ console.log(`[preview] ${cmd} ${args.join(' ')} (not executed)`)
91
+ return undefined
92
+ }
93
+ console.log(`+ ${cmd} ${args.join(' ')}`)
94
+ return execFileSync(cmd, args, { cwd: ROOT, stdio: 'inherit' })
95
+ }
96
+
97
+ let tagExists = false
98
+ try {
99
+ const out = execFileSync('git', ['tag', '--list', tag], { cwd: ROOT, encoding: 'utf8' })
100
+ tagExists = out.split('\n').map((line) => line.trim()).includes(tag)
101
+ } catch {}
102
+ if (!tagExists) {
103
+ run('git', ['tag', tag])
104
+ } else {
105
+ console.log(`tag ${tag} already exists`)
106
+ }
107
+
108
+ if (publish && !dryRun) {
109
+ if (!tagExists) run('git', ['push', 'origin', tag])
110
+ const notes = join(mkdtempSync(join(tmpdir(), 'sss-release-')), 'notes.md')
111
+ writeFileSync(notes, body, 'utf8')
112
+ run('gh', ['release', 'create', tag, '--title', `v${target}`, '--notes-file', notes])
113
+ rmSync(dirname(notes), { recursive: true, force: true })
114
+ } else if (!publish) {
115
+ console.log('\n(no --publish: nothing was created/pushed. Re-run with --publish to tag + release.)')
116
+ }
117
+
118
+ console.log(`ok: release preview for ${tag} (tagExists=${tagExists}, publish=${publish})`)