@objectivex666/dsh-settings-search 1.5.0 → 1.7.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
@@ -15,6 +15,9 @@
15
15
  - 🔍 **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.
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
+ - 🧠 **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.
18
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.
19
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.
20
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.
@@ -93,3 +96,14 @@ npm test # validates the host half, the dsh.client manifest, and the client bu
93
96
  > in-session `cordis_define` dynamic-plugin source). Because keeping it in
94
97
  > sync with the shipping package kept drifting silently, it was removed in
95
98
  > v1.4.1; retrieve it from git history (tag `v1.4.0`) if you need a reference.
99
+
100
+ ## Changelog & releases
101
+
102
+ Every version bump keeps git tags and GitHub Releases in sync, with a bilingual body that lists both new features and fixes:
103
+
104
+ - Update [CHANGELOG.md](./CHANGELOG.md) (Chinese) and [CHANGELOG.en.md](./CHANGELOG.en.md) (English), each with `### ✨ Features` and `### 🐛 Fixes` sections.
105
+ - 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`).
106
+ - Preview the release body locally: `node scripts/release.mjs --version <ver>`
107
+ - Tag and publish for real: `node scripts/release.mjs --version <ver> --publish` (requires `gh` CLI, logged in)
108
+
109
+ > Without `--publish` the script only validates and prints the release body; it does not create a tag, push, or release.
package/README.md CHANGED
@@ -15,6 +15,9 @@
15
15
  - 🔍 **实时搜索**:在设置面板左侧导航顶部嵌入搜索框,输入关键词立即过滤所有已注册的设置页(`settings.section`)与通用设置项(`settings.general.item`)。
16
16
  - 📂 **页内选项搜索(v1.2.0)**:不只搜页面,还能直达每个设置页内部的具体选项——插件页的标签页(`settings.plugins.tab`)、Web UI 插件的卡片(`web-ui.plugin.item`)、通用设置行等,结果以「页面 › 选项」面包屑展示。
17
17
  - 🔤 **拼音联想搜索(v1.5.0)**:输入拼音首字母(如 `sz`)或全拼(如 `shezhi`)即可联想中文设置项;支持 `↑`/`↓` 选择、`Enter` 跳转、`Esc` 关闭,命中的文字自动高亮。
18
+ - 🧠 **意图联想搜索(v1.6.0)**:直接描述想做的事(如「太亮了」「字太小」「忘记密码」),无需知道选项名即可联想真实设置项,结果在「猜你想找」分组展示。
19
+ - 🤖 **AI 联想搜索(v1.7.0)**:没有本地匹配时,用你配置的模型(OpenAI 兼容或 Anthropic:Base URL / 模型名 / API Key)理解搜索意图并推荐设置项。默认关闭,可在「设置搜索」页开启并配置。
20
+ - 🧾 **日志与导出(v1.7.0)**:记录插件运行、搜索与 AI 请求的关键动作,可在「设置搜索」页一键导出、复制或清空,便于排查问题。不会记录 API Key。
18
21
  - 🧭 **点击即达**:选中结果后自动点击对应的左侧导航项打开所在分区;若是页内标签页会继续点开对应标签,并高亮闪烁目标选项行。无法自动跳转时显示手动路径提示。
19
22
  - 🌱 **渐进索引**:自绘界面的设置行会在其所在页面首次被访问时从渲染 DOM 中收割标题并加入索引(此后始终可搜)。
20
23
  - 🛠 **自带设置页(v1.4.0)**:左侧导航新增「设置搜索」页——展示当前版本,一键检查 npm 注册表上的最新版本;有更新时直接给出复制就用的 `dsh plugin update` 命令。
@@ -94,3 +97,14 @@ npm test # 校验 host 半、dsh.client 清单、client bundle handoff
94
97
  > 历史说明:v1.1 及之前仓库曾附带 `dsh-settings-search.js`(会话内
95
98
  > `cordis_define` 动态插件源)。因与正式包代码长期双份维护漂移,已于 v1.4.1
96
99
  > 移除;如需动态用法可从 git 历史(tag `v1.4.0`)取回参考。
100
+
101
+ ## 更新日志与发布
102
+
103
+ 每次版本更新需同时更新 GitHub Release 与 git tag,正文为双语,并写明「新功能」与「修复」:
104
+
105
+ - 更新 [CHANGELOG.md](./CHANGELOG.md)(中文)与 [CHANGELOG.en.md](./CHANGELOG.en.md)(英文),两者各含 `### ✨ 新功能 / Features` 与 `### 🐛 修复 / Fixes`。
106
+ - 同步 `package.json` 与 README 的版本号,按 `vX.Y.Z` 打 tag 并推送;GitHub Actions 会同时发布到 npm(`npm-publish.yml`)并创建双语 GitHub Release(`release.yml`)。
107
+ - 本地预览 release 正文:`node scripts/release.mjs --version <ver>`
108
+ - 实际打 tag 并发布:`node scripts/release.mjs --version <ver> --publish`(需 `gh` CLI 且已登录)
109
+
110
+ > 未带 `--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.5.0'
33
+ const PKG_VERSION = '1.7.0'
34
34
  const NPM_NAME = '@objectivex666/dsh-settings-search'
35
35
  const zh = {
36
36
  title: '设置',
@@ -38,6 +38,7 @@ window.__ModuleLoader__.load({
38
38
  empty: '没有匹配的设置',
39
39
  pages: '设置页',
40
40
  options: '页内选项',
41
+ guess: '猜你想找',
41
42
  page: '页面',
42
43
  option: '选项',
43
44
  jumpFail: '未能自动跳转,请手动打开:{path}',
@@ -53,6 +54,36 @@ window.__ModuleLoader__.load({
53
54
  updCopy: '复制命令',
54
55
  updCopied: '已复制 ✓',
55
56
  updCopyFail: '复制失败,请长按上方命令手动复制。',
57
+ updCLI: 'DSH CLI',
58
+ updNpx: 'npx',
59
+ updRelease: '查看 release notes',
60
+ updRestart: '更新完成后重启 DSH,新版本才会生效。',
61
+ aiSearch: 'AI 联想搜索',
62
+ aiDesc: '没有直接匹配时,用你配置的模型(OpenAI 兼容或 Anthropic)理解目标并推荐设置项。',
63
+ aiPrivacy: 'AI 请求会发送到你配置的模型服务;API Key 仅保存在本机浏览器。',
64
+ aiOn: '已开启',
65
+ aiOff: '已关闭',
66
+ aiThinking: '正在用 AI 理解搜索意图…',
67
+ aiUnavailable: 'AI 联想暂不可用',
68
+ aiNeedConfig: '请先在「设置搜索」页配置 Base URL、模型名与 API Key。',
69
+ aiProvider: '接口类型',
70
+ aiOpenAI: 'OpenAI 兼容',
71
+ aiAnthropic: 'Anthropic',
72
+ aiBaseUrl: 'Base URL',
73
+ aiModel: '模型名',
74
+ aiApiKey: 'API Key',
75
+ aiConfigSave: '保存配置',
76
+ aiConfigSaved: '已保存 ✓',
77
+ logTitle: '日志',
78
+ logDesc: '记录插件运行、搜索与 AI 请求的关键动作,便于排查问题。',
79
+ logEmpty: '暂无日志',
80
+ logExport: '导出日志',
81
+ logCopy: '复制日志',
82
+ logCopied: '已复制 ✓',
83
+ logCopyFail: '复制失败,请稍后重试。',
84
+ logClear: '清空日志',
85
+ logCleared: '已清空',
86
+ logExportFail: '导出失败,请重试。',
56
87
  }
57
88
  const en = {
58
89
  title: 'Settings',
@@ -60,6 +91,7 @@ window.__ModuleLoader__.load({
60
91
  empty: 'No matching settings',
61
92
  pages: 'Settings pages',
62
93
  options: 'In-page options',
94
+ guess: 'You may want',
63
95
  page: 'Page',
64
96
  option: 'Option',
65
97
  jumpFail: 'Could not navigate automatically — open it here: {path}',
@@ -75,6 +107,36 @@ window.__ModuleLoader__.load({
75
107
  updCopy: 'Copy command',
76
108
  updCopied: 'Copied ✓',
77
109
  updCopyFail: 'Copy failed — please long-press the command above.',
110
+ updCLI: 'DSH CLI',
111
+ updNpx: 'npx',
112
+ updRelease: 'View release notes',
113
+ updRestart: 'Restart DSH after updating for the new version to take effect.',
114
+ aiSearch: 'AI-assisted search',
115
+ aiDesc: 'When no direct match exists, your own model (OpenAI-compatible or Anthropic) understands your goal and suggests settings.',
116
+ aiPrivacy: 'AI requests are sent to the model service you configure; the API key stays only in your browser.',
117
+ aiOn: 'On',
118
+ aiOff: 'Off',
119
+ aiThinking: 'Understanding your intent with AI…',
120
+ aiUnavailable: 'AI suggestions are unavailable',
121
+ aiNeedConfig: 'Configure Base URL, model, and API key in the Settings Search panel first.',
122
+ aiProvider: 'API type',
123
+ aiOpenAI: 'OpenAI-compatible',
124
+ aiAnthropic: 'Anthropic',
125
+ aiBaseUrl: 'Base URL',
126
+ aiModel: 'Model',
127
+ aiApiKey: 'API Key',
128
+ aiConfigSave: 'Save config',
129
+ aiConfigSaved: 'Saved ✓',
130
+ logTitle: 'Logs',
131
+ logDesc: 'Logs key plugin actions, searches, and AI requests to help troubleshoot.',
132
+ logEmpty: 'No logs yet',
133
+ logExport: 'Export logs',
134
+ logCopy: 'Copy logs',
135
+ logCopied: 'Copied ✓',
136
+ logCopyFail: 'Copy failed. Please try again.',
137
+ logClear: 'Clear logs',
138
+ logCleared: 'Cleared',
139
+ logExportFail: 'Export failed. Please try again.',
78
140
  }
79
141
 
80
142
  /** Inner-row slots that represent specific options inside one page. */
@@ -114,6 +176,10 @@ window.__ModuleLoader__.load({
114
176
  中zhong 英ying 韩han 法fa 德de 西xi 俄e 翻fan 译yi 简jian 繁fan 历li 史shi 聊liao
115
177
  天tian 消xiao 息xi 醒xing 静jing 震zhen 红hong 点dian 角jiao 未wei 读du 部bu
116
178
  为wei 已yi 好 hao 坏huai 多duo 少shao
179
+ 太tai 刺ci 眼yan 夜ye 暗an 护hu 不bu 调tiao 吵chao 没mei 勿wu 想xiang 被bei 忘wang
180
+ 资zi 料liao 解jie 垃la 圾ji 占zhan 缓huan 卡ka 顿dun 慢man 断duan 代dai 理li 别bie
181
+ 闭bi 省sheng 耗hao 耐nai 注zhu 退tui 款kuan 到dao 取qu 回hui 送song 按an 短duan
182
+ 长chang 收shou 我wo 了le 足zu 烦fan 像xiang 住zhu
117
183
  `
118
184
  const PINYIN = new Map(
119
185
  PINYIN_TABLE.split(/\s+/).filter(Boolean).map((pair) => [pair[0], pair.slice(1)])
@@ -167,6 +233,212 @@ window.__ModuleLoader__.load({
167
233
  return consumed
168
234
  }
169
235
 
236
+ /**
237
+ * Intent table for "describe the goal" search: users who know what they
238
+ * want to change but not which option does it. Format: 描述>联想词,逗号分隔.
239
+ * A query token matching a phrase (text or pinyin) additionally matches
240
+ * every listed keyword; expanded-only hits surface in the guess group.
241
+ */
242
+ const INTENT_TABLE = `
243
+ 太亮>深色,主题,亮度,护眼 太暗>亮度,主题,显示 刺眼>深色,主题,亮度 夜间>深色,主题,护眼
244
+ 护眼>深色,主题,亮度 看不清>字体,大小,缩放 字太小>字体,大小,缩放 字太大>字体,大小
245
+ 调字体>字体,大小 太吵>声音,提示音,静音,音量 没声音>声音,音量,提示音 静音>声音,提示音,音量
246
+ 声音太>声音,音量,提示音 提示音>声音,通知 免打扰>通知,免打扰,提醒 勿扰>通知,免打扰
247
+ 不想被打扰>通知,免打扰,提醒 提醒>通知,消息 忘记密码>密码,安全,验证,账户 改密码>密码,安全,账户
248
+ 密码忘了>密码,安全,验证 换头像>头像,账户,资料 换手机号>手机,绑定,账户 换邮箱>邮箱,绑定,账户
249
+ 解绑>绑定,账户 清垃圾>缓存,清除,存储 占空间>缓存,存储,清除 空间不足>存储,缓存,清除
250
+ 太卡>缓存,速度,网络 卡顿>缓存,速度 加载慢>网络,速度 太慢>速度,网络
251
+ 断网>网络,连接 连不上>网络,连接,代理 换语言>语言 英文界面>语言,英文
252
+ 中文界面>语言,中文 切换语言>语言 换设备>同步,备份,账户 新手机>同步,备份,账户
253
+ 备份数据>备份,导出,数据 导出聊天>聊天,导出,备份,历史 聊天记录>聊天,历史,备份 不想被推荐>推荐,个性化,隐私
254
+ 推荐太烦>推荐,个性化 隐私保护>隐私,安全 别人看到>隐私,安全 自动更新>更新,自动
255
+ 关闭更新>更新,自动 省电>电池,节能,省电 耗电>电池,节能 电池不耐用>电池,节能
256
+ 注销账号>注销,账户,删除 删除账号>注销,删除,账户 退出登录>登录,注销,账户 退款>支付,退款
257
+ 会员到期>会员,订阅,支付 取消订阅>订阅,会员,支付 回车发送>发送,回车,enter,输入,快捷键 按回车>回车,enter,发送,输入
258
+ 打字>输入,回车,发送 换行>换行,输入 没有记忆>记忆,上下文,对话 记不住>记忆,上下文
259
+ 上下文太短>上下文,记忆,长度 消息不提醒>通知,消息,提醒,声音 收不到通知>通知,提醒,消息 看不到消息>消息,通知,提醒
260
+ 主题>外观,皮肤,颜色,深色 换肤>皮肤,外观 颜色>皮肤,外观,主题
261
+ 看不懂>语言,中文,英文 翻译>语言,中文,英文 乱改文件>权限,确认 权限太松>权限,确认
262
+ 发图片>视觉,识图,自动,图片 看图>视觉,识图,图片 聊天记录>归档,会话,历史,聊天 删会话>会话,删除,归档
263
+ `
264
+ const INTENTS = INTENT_TABLE.split(/\s+/).filter(Boolean).map((entry) => {
265
+ const at = entry.indexOf('>')
266
+ const phrase = entry.slice(0, at)
267
+ return { phrase, keywords: entry.slice(at + 1).split(','), keys: pinyinKeys(phrase) }
268
+ })
269
+
270
+ /** Keywords a query token should additionally search after an intent hit. */
271
+ const intentKeywords = (tok) => {
272
+ const hits = []
273
+ for (const intent of INTENTS) {
274
+ const phraseHit = intent.phrase.includes(tok) || tok.includes(intent.phrase) ||
275
+ (intent.keys !== null &&
276
+ (intent.keys.full.startsWith(tok) || intent.keys.initials === tok))
277
+ if (phraseHit) {
278
+ for (const kw of intent.keywords) {
279
+ if (!hits.includes(kw)) hits.push(kw)
280
+ }
281
+ }
282
+ }
283
+ return hits
284
+ }
285
+
286
+ const AI_TOGGLE_EVENT = 'dsh-settings-search:ai-toggle'
287
+ const AI_CONFIG_EVENT = 'dsh-settings-search:ai-config'
288
+ /** Simple in-memory log ring used by the settings panel's export/log UI. */
289
+ const LOG_EVENT = 'dsh-settings-search:log'
290
+ const LOG_MAX = 500
291
+ const LOGS = []
292
+ const log = (level, msg, meta) => {
293
+ if (typeof window === 'undefined') return
294
+ LOGS.push({ time: new Date().toISOString(), level, msg, meta })
295
+ if (LOGS.length > LOG_MAX) LOGS.splice(0, LOGS.length - LOG_MAX)
296
+ try { window.dispatchEvent(new CustomEvent(LOG_EVENT, { detail: LOGS.length })) } catch {}
297
+ }
298
+ const formatLogs = (logs) => {
299
+ const lines = [
300
+ 'dsh-settings-search log',
301
+ `version: ${PKG_VERSION}`,
302
+ `exported: ${new Date().toISOString()}`,
303
+ `count: ${logs.length}`,
304
+ '',
305
+ ]
306
+ for (const entry of logs) {
307
+ lines.push(`[${entry.time}] [${entry.level}] ${entry.msg}${entry.meta ? ' ' + JSON.stringify(entry.meta) : ''}`)
308
+ }
309
+ return lines.join('\n')
310
+ }
311
+ const AI_CACHE = new Map()
312
+ const AI_STORAGE_KEY = 'dsh-settings-search:ai-enabled'
313
+ const AI_PROVIDER_KEY = 'dsh-settings-search:ai-provider'
314
+ const AI_BASE_URL_KEY = 'dsh-settings-search:ai-base-url'
315
+ const AI_MODEL_KEY = 'dsh-settings-search:ai-model'
316
+ const AI_API_KEY_KEY = 'dsh-settings-search:ai-api-key'
317
+ const aiSearchEnabled = () => {
318
+ if (typeof localStorage === 'undefined') return false
319
+ return localStorage.getItem(AI_STORAGE_KEY) === '1'
320
+ }
321
+ const setAiSearchEnabled = (enabled) => {
322
+ if (typeof localStorage === 'undefined') return
323
+ localStorage.setItem(AI_STORAGE_KEY, enabled ? '1' : '0')
324
+ window.dispatchEvent(new CustomEvent(AI_TOGGLE_EVENT, { detail: enabled }))
325
+ }
326
+ const aiConfig = () => {
327
+ if (typeof localStorage === 'undefined') return { provider: 'openai', baseUrl: '', model: '', apiKey: '' }
328
+ const providerRaw = (localStorage.getItem(AI_PROVIDER_KEY) ?? 'openai').trim()
329
+ return {
330
+ provider: providerRaw === 'anthropic' ? 'anthropic' : 'openai',
331
+ baseUrl: (localStorage.getItem(AI_BASE_URL_KEY) ?? '').trim(),
332
+ model: (localStorage.getItem(AI_MODEL_KEY) ?? '').trim(),
333
+ apiKey: (localStorage.getItem(AI_API_KEY_KEY) ?? '').trim(),
334
+ }
335
+ }
336
+ const setAiConfig = (cfg) => {
337
+ if (typeof localStorage === 'undefined') return
338
+ localStorage.setItem(AI_PROVIDER_KEY, cfg.provider === 'anthropic' ? 'anthropic' : 'openai')
339
+ localStorage.setItem(AI_BASE_URL_KEY, cfg.baseUrl ?? '')
340
+ localStorage.setItem(AI_MODEL_KEY, cfg.model ?? '')
341
+ localStorage.setItem(AI_API_KEY_KEY, cfg.apiKey ?? '')
342
+ window.dispatchEvent(new CustomEvent(AI_CONFIG_EVENT))
343
+ }
344
+ const extractAiKeywords = (raw) => {
345
+ const text = String(raw ?? '').replace(/^```(?:json)?/i, '').replace(/```$/, '').trim()
346
+ const start = text.indexOf('{')
347
+ const end = text.lastIndexOf('}')
348
+ if (start === -1 || end <= start) return []
349
+ const parsed = JSON.parse(text.slice(start, end + 1))
350
+ const values = Array.isArray(parsed) ? parsed : parsed?.keywords
351
+ if (!Array.isArray(values)) return []
352
+ return [...new Set(values
353
+ .filter((value) => typeof value === 'string')
354
+ .map((value) => value.trim().slice(0, 24))
355
+ .filter(Boolean))]
356
+ .slice(0, 6)
357
+ }
358
+ const AI_PROMPT = '你是设置搜索助手。只输出 JSON 对象,格式为 {"keywords":["..."]}。keywords 最多 6 个,每个最多 12 个字符,只能从用户提供的设置索引中选择或提取核心词,不要解释。'
359
+ const chatCompletionsUrl = (baseUrl) => {
360
+ const trimmed = String(baseUrl).trim().replace(/\/+$/, '')
361
+ return /\/chat\/completions$/i.test(trimmed) ? trimmed : `${trimmed}/chat/completions`
362
+ }
363
+ const anthropicMessagesUrl = (baseUrl) => {
364
+ const trimmed = String(baseUrl).trim().replace(/\/+$/, '')
365
+ if (/\/v1\/messages$/i.test(trimmed)) return trimmed
366
+ return /\/v1$/i.test(trimmed) ? `${trimmed}/messages` : `${trimmed}/v1/messages`
367
+ }
368
+ const requestUserAi = async (query, labels, signal) => {
369
+ const { provider, baseUrl, model, apiKey } = aiConfig()
370
+ if (!baseUrl || !model || !apiKey) return []
371
+ const isAnthropic = provider === 'anthropic'
372
+ const startedAt = Date.now()
373
+ log('info', 'AI 请求', { provider, model, query: String(query).slice(0, 50) })
374
+ let timer
375
+ const deadline = new Promise((_, reject) => {
376
+ timer = setTimeout(() => {
377
+ const error = new Error('AI request timed out')
378
+ error.name = 'AI_TIMEOUT'
379
+ reject(error)
380
+ }, 12000)
381
+ })
382
+ const work = (async () => {
383
+ const userContent = `用户目标:${query}\n设置索引:\n${labels}`
384
+ const headers = {
385
+ 'content-type': 'application/json',
386
+ ...(isAnthropic
387
+ ? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true' }
388
+ : { authorization: `Bearer ${apiKey}` }),
389
+ }
390
+ const body = isAnthropic
391
+ ? {
392
+ model,
393
+ max_tokens: 1024,
394
+ system: AI_PROMPT,
395
+ messages: [{ role: 'user', content: userContent }],
396
+ temperature: 0,
397
+ }
398
+ : {
399
+ model,
400
+ messages: [
401
+ { role: 'system', content: AI_PROMPT },
402
+ { role: 'user', content: userContent },
403
+ ],
404
+ temperature: 0,
405
+ }
406
+ const response = await fetch(isAnthropic ? anthropicMessagesUrl(baseUrl) : chatCompletionsUrl(baseUrl), {
407
+ method: 'POST',
408
+ headers,
409
+ signal,
410
+ body: JSON.stringify(body),
411
+ })
412
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
413
+ const data = await response.json()
414
+ const content = isAnthropic
415
+ ? (data?.content ?? []).filter((block) => block?.type === 'text').map((block) => block.text).join('')
416
+ : data?.choices?.[0]?.message?.content ?? ''
417
+ return extractAiKeywords(content)
418
+ })()
419
+ try {
420
+ const keywords = await Promise.race([work, deadline])
421
+ log('info', 'AI 结果', { count: keywords.length, duration: Date.now() - startedAt })
422
+ return keywords
423
+ } catch (error) {
424
+ if (error.name === 'AbortError') {
425
+ log('warn', 'AI 请求取消', { duration: Date.now() - startedAt })
426
+ throw error
427
+ }
428
+ log('error', 'AI 请求失败', { message: String(error?.message ?? error).slice(0, 160), duration: Date.now() - startedAt })
429
+ throw error
430
+ } finally {
431
+ clearTimeout(timer)
432
+ }
433
+ }
434
+ const requestAiKeywords = async (query, labels, signal) => {
435
+ const cacheKey = query.toLowerCase()
436
+ if (AI_CACHE.has(cacheKey)) return AI_CACHE.get(cacheKey)
437
+ const keywords = await requestUserAi(query, labels, signal)
438
+ if (keywords.length > 0) AI_CACHE.set(cacheKey, keywords)
439
+ return keywords
440
+ }
441
+
170
442
  const CSS = `
171
443
  .sss-wrap { position: relative; display: flex; flex-direction: column; gap: 8px; }
172
444
  .sss-title { font-size: 16px; font-weight: 600; line-height: 24px; color: var(--dsw-alias-label-primary); }
@@ -188,23 +460,55 @@ window.__ModuleLoader__.load({
188
460
  .sss-item-label mark { background: transparent; color: inherit; font-weight: 600; }
189
461
  .sss-item-path { font-size: 10px; line-height: 14px; color: var(--dsw-alias-label-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
190
462
  .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; }
463
+ .sss-item-kind.sss-via { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
464
+ .sss-ai-note { margin: 0; padding: 6px 8px; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; }
191
465
  .sss-empty { margin: 0; padding: 10px 8px; color: var(--dsw-alias-label-secondary); font-size: 13px; }
192
466
  .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; }
193
467
  @keyframes sssFlashBg { 0% { background-color: var(--dsw-alias-interactive-bg-hover); } 100% { background-color: transparent; } }
194
468
  .sss-flash { animation: sssFlashBg 1.8s ease-out; border-radius: 8px; }
195
- .sss-upd { display: flex; flex-direction: column; gap: 12px; padding: 16px; max-width: 560px; }
196
- .sss-upd-title { font-size: 15px; font-weight: 600; line-height: 22px; color: var(--dsw-alias-label-primary); margin: 0; }
197
- .sss-upd-desc { margin: 0; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
198
- .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); }
199
- .sss-upd-row { display: flex; align-items: center; gap: 8px; font-size: 13px; line-height: 20px; }
200
- .sss-upd-key { flex-shrink: 0; width: 88px; color: var(--dsw-alias-label-secondary); }
201
- .sss-upd-val { color: var(--dsw-alias-label-primary); }
202
- .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; }
203
- .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; }
204
- .sss-btn:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
205
- .sss-btn:disabled { opacity: .55; cursor: default; }
206
- .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; }
207
- .sss-upd-note { margin: 0; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
469
+ .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; }
470
+ .sss-vr:hover { border-color: var(--dsw-alias-label-dimmed); }
471
+ .sss-vr-open { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-label-dimmed); }
472
+ .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; }
473
+ .sss-vr-header:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; }
474
+ .sss-vr-headText { flex-direction: column; flex: 1; gap: 4px; min-width: 0; display: flex; }
475
+ .sss-vr-name { color: var(--dsw-alias-label-primary); font-size: 15px; font-weight: 600; line-height: 1.4; }
476
+ .sss-vr-desc { color: var(--dsw-alias-label-tertiary); font-size: 13px; line-height: 1.5; }
477
+ .sss-vr-chevron { color: var(--dsw-alias-label-tertiary); flex: none; transition: transform .16s; font-size: 12px; line-height: 1; }
478
+ .sss-vr-chevron-open { transform: rotate(180deg); }
479
+ .sss-vr-body { border-top: 1px solid var(--dsw-alias-border-l2); margin: 0 16px; padding-bottom: 8px; }
480
+ .sss-vr-field { content-visibility: auto; contain-intrinsic-size: auto 96px; flex-direction: column; gap: 6px; padding: 12px 0; display: flex; }
481
+ .sss-vr-field + .sss-vr-field { border-top: 1px solid var(--dsw-alias-border-l2); }
482
+ .sss-vr-form { gap: 6px; }
483
+ .sss-vr-field-head { align-items: center; gap: 8px; display: flex; }
484
+ .sss-vr-label { min-width: 0; color: var(--dsw-alias-label-primary); flex: 1; font-size: 13px; font-weight: 500; line-height: 1.5; }
485
+ .sss-vr-hint { color: var(--dsw-alias-label-tertiary); margin: 0; font-size: 12px; line-height: 1.5; }
486
+ .sss-vr-value { color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 500; line-height: 1.5; }
487
+ .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; }
488
+ .sss-vr-input:focus-visible { border-color: var(--dsw-alias-brand-primary); outline: none; }
489
+ .sss-vr-input::placeholder { color: var(--dsw-alias-label-dimmed); }
490
+ .sss-vr-select { cursor: pointer; }
491
+ .sss-vr-check { width: 16px; height: 16px; accent-color: var(--dsw-alias-brand-primary); cursor: pointer; margin: 0; }
492
+ .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; }
493
+ .sss-vr-btn:hover:not(:disabled) { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
494
+ .sss-vr-btn:disabled { opacity: .4; cursor: default; }
495
+ .sss-vr-btn:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
496
+ .sss-vr-primary { background: var(--dsw-alias-label-primary); color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
497
+ .sss-vr-primary:hover:not(:disabled) { color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
498
+ .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; }
499
+ .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; }
500
+ .sss-vr-note { color: var(--dsw-alias-label-tertiary); margin: 0; font-size: 12px; line-height: 1.5; }
501
+ .sss-vr-row { display: flex; align-items: center; gap: 8px; font-size: 13px; line-height: 20px; flex-wrap: wrap; }
502
+ .sss-vr-rowname { color: var(--dsw-alias-label-tertiary); font-size: 13px; line-height: 1.5; }
503
+ .sss-vr-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding-top: 1px; }
504
+ .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; }
505
+ .sss-vr-update-title { font-size: 13px; font-weight: 650; color: var(--dsw-alias-label-primary); line-height: 1.5; }
506
+ .sss-vr-update-command { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
507
+ .sss-vr-update-command-label { font-size: 11px; font-weight: 500; color: var(--dsw-alias-label-tertiary); line-height: 1.4; }
508
+ .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; }
509
+ .sss-vr-update-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding-top: 1px; }
510
+ .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; }
511
+ .sss-vr-link:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
208
512
  `
209
513
  let stylesInstalled = false
210
514
  function installStyles() {
@@ -447,6 +751,7 @@ window.__ModuleLoader__.load({
447
751
  }
448
752
 
449
753
  function apply(ctx) {
754
+ log('info', '应用装载', { version: PKG_VERSION })
450
755
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'settings-search: dictionaries')
451
756
  const t = ctx.locale.bind(NS)
452
757
  ctx.effect(() => {
@@ -481,6 +786,10 @@ window.__ModuleLoader__.load({
481
786
  const [tick, setTick] = React.useState(0)
482
787
  const [, forceTick] = React.useReducer((x) => x + 1, 0)
483
788
  void tick
789
+ const [aiEnabled, setAiEnabled] = React.useState(aiSearchEnabled)
790
+ const [aiKeywords, setAiKeywords] = React.useState([])
791
+ const [aiStatus, setAiStatus] = React.useState('idle')
792
+ const aiAbortRef = React.useRef(null)
484
793
 
485
794
  React.useEffect(() => {
486
795
  const watched = ['settings.section', ...DEEP_SLOTS.map((c) => c.slot)]
@@ -503,25 +812,115 @@ window.__ModuleLoader__.load({
503
812
  }
504
813
  }, [])
505
814
 
815
+ React.useEffect(() => {
816
+ const sync = () => setAiEnabled(aiSearchEnabled())
817
+ const onConfig = () => forceTick()
818
+ window.addEventListener(AI_TOGGLE_EVENT, sync)
819
+ window.addEventListener(AI_CONFIG_EVENT, onConfig)
820
+ return () => {
821
+ window.removeEventListener(AI_TOGGLE_EVENT, sync)
822
+ window.removeEventListener(AI_CONFIG_EVENT, onConfig)
823
+ aiAbortRef.current?.abort()
824
+ }
825
+ }, [])
826
+
506
827
  const q = query.trim().toLowerCase()
507
828
  const tokens = q.split(/\s+/).filter(Boolean)
508
829
 
509
830
  const sections = model.readSections()
510
831
  const deeps = q !== '' ? model.readDeep() : []
511
- const matchItem = (item, haystackLower) =>
832
+ const expandedKeywords = new Map(tokens.map((tok) => [tok, intentKeywords(tok)]))
833
+ const directMatch = (item, haystackLower) =>
512
834
  tokens.every((tok) => tokenMatch(haystackLower, item.keys, tok) !== null)
513
- const shownPages = q !== ''
514
- ? sections
515
- .map((s) => ({ ...s, keys: pinyinKeys(s.label) }))
516
- .filter((s) => matchItem(s, `${s.label} ${s.id}`.toLowerCase()))
517
- : []
518
- const shownOptions = q !== ''
519
- ? deeps
520
- .map((o) => ({ ...o, keys: pinyinKeys(`${o.label} ${o.parentLabel}`) }))
521
- .filter((o) => matchItem(o, `${o.hay} ${o.parentLabel} ${o.id} ${o.slot}`.toLowerCase()))
522
- : []
523
- const none = q !== '' && shownPages.length === 0 && shownOptions.length === 0
524
- const results = [...shownPages, ...shownOptions]
835
+ const kwMatch = (item, haystackLower, kw) => {
836
+ if (/^[\x00-\x7f]+$/.test(kw)) {
837
+ if (new RegExp(`\\b${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(haystackLower)) return true
838
+ } else if (haystackLower.includes(kw)) return true
839
+ if (item.keys === null) return false
840
+ const kwKeys = pinyinKeys(kw)
841
+ if (kwKeys === null) return false
842
+ return item.keys.full.includes(kwKeys.full) || item.keys.initials.startsWith(kwKeys.initials)
843
+ }
844
+ const relatedKeyword = (item, haystackLower) => {
845
+ if (directMatch(item, haystackLower)) return null
846
+ for (const tok of tokens) {
847
+ for (const kw of expandedKeywords.get(tok) ?? []) {
848
+ if (kwMatch(item, haystackLower, kw)) return kw
849
+ }
850
+ }
851
+ for (const kw of aiKeywords) {
852
+ if (kwMatch(item, haystackLower, kw)) return `AI · ${kw}`
853
+ }
854
+ return null
855
+ }
856
+ const mappedSections = q === '' ? [] : sections.map((s) => ({
857
+ ...s,
858
+ keys: pinyinKeys(s.label),
859
+ hay: `${s.label} ${s.id}`.toLowerCase(),
860
+ }))
861
+ const mappedOptions = q === '' ? [] : deeps.map((o) => ({
862
+ ...o,
863
+ keys: pinyinKeys(`${o.label} ${o.parentLabel}`),
864
+ hay: `${o.hay} ${o.parentLabel} ${o.id} ${o.slot}`.toLowerCase(),
865
+ }))
866
+ const shownPages = mappedSections.filter((s) => directMatch(s, s.hay))
867
+ const shownOptions = mappedOptions.filter((o) => directMatch(o, o.hay))
868
+ const noLocalMatch = shownPages.length === 0 && shownOptions.length === 0 &&
869
+ tokens.every((tok) => (expandedKeywords.get(tok) ?? []).length === 0)
870
+ const related = [
871
+ ...mappedSections
872
+ .map((s) => ({ item: s, kind: 'page', via: relatedKeyword(s, s.hay) }))
873
+ .filter((r) => r.via !== null),
874
+ ...mappedOptions
875
+ .map((o) => ({ item: o, kind: 'option', via: relatedKeyword(o, o.hay) }))
876
+ .filter((r) => r.via !== null),
877
+ ].slice(0, 8)
878
+ const none = q !== '' && shownPages.length === 0 && shownOptions.length === 0 && related.length === 0
879
+ const results = [...shownPages, ...shownOptions, ...related.map((r) => r.item)]
880
+
881
+ const cfg = aiConfig()
882
+ const aiConfigured = Boolean(cfg.baseUrl && cfg.model && cfg.apiKey)
883
+ const aiReady = aiEnabled && aiConfigured && q.length >= 2 && noLocalMatch
884
+ React.useEffect(() => {
885
+ if (!aiReady) return
886
+ setAiKeywords([])
887
+ setAiStatus('idle')
888
+ let alive = true
889
+ const controller = new AbortController()
890
+ const timer = setTimeout(() => {
891
+ aiAbortRef.current?.abort()
892
+ aiAbortRef.current = controller
893
+ setAiStatus('loading')
894
+ const labels = [...new Set([
895
+ ...sections.map((item) => item.label),
896
+ ...deeps.map((item) => `${item.parentLabel} ${item.label}`),
897
+ ].map((label) => label.trim()).filter(Boolean))].slice(0, 48).join('\n')
898
+ requestAiKeywords(q, labels, controller.signal).then((keywords) => {
899
+ if (!alive) return
900
+ setAiKeywords(keywords)
901
+ setAiStatus(keywords.length > 0 ? 'ready' : 'idle')
902
+ }).catch((error) => {
903
+ if (!alive || error.name === 'AbortError') return
904
+ setAiKeywords([])
905
+ setAiStatus('error')
906
+ }).finally(() => {
907
+ if (aiAbortRef.current === controller) aiAbortRef.current = null
908
+ })
909
+ }, 700)
910
+ return () => {
911
+ alive = false
912
+ clearTimeout(timer)
913
+ controller.abort()
914
+ }
915
+ }, [aiReady, aiEnabled, q])
916
+
917
+ const loggedQueryRef = React.useRef('')
918
+ React.useEffect(() => {
919
+ if (q === '') return
920
+ if (loggedQueryRef.current === q) return
921
+ loggedQueryRef.current = q
922
+ log('info', '搜索', { query: query.slice(0, 50), pages: shownPages.length, options: shownOptions.length, related: related.length })
923
+ }, [q, shownPages.length, shownOptions.length, related.length])
525
924
 
526
925
  const [hint, setHint] = React.useState(null)
527
926
  const [active, setActive] = React.useState(0)
@@ -535,6 +934,7 @@ window.__ModuleLoader__.load({
535
934
  }, [activeIndex, q])
536
935
  const pick = (item) => {
537
936
  setOpen(false)
937
+ log('info', '打开设置', { kind: item.kind, id: item.id || item.key || '', label: String(item.label).slice(0, 60) })
538
938
  const jumped = item.kind === 'page'
539
939
  ? navigator.jumpToPage(item)
540
940
  : navigator.jumpToOption(item)
@@ -579,16 +979,20 @@ window.__ModuleLoader__.load({
579
979
  return [label]
580
980
  }
581
981
 
582
- const renderPage = (item, flatIndex) => React.createElement('li', {
982
+ const kindBadge = (via, fallback) => React.createElement('span', {
983
+ className: 'sss-item-kind' + (via ? ' sss-via' : ''),
984
+ }, via ?? fallback)
985
+
986
+ const renderPage = (item, flatIndex, via) => React.createElement('li', {
583
987
  key: 'page:' + item.id,
584
988
  className: 'sss-item' + (flatIndex === activeIndex ? ' is-active' : ''),
585
989
  onMouseDown: () => pick(item),
586
990
  onMouseEnter: () => setActive(flatIndex),
587
991
  }, [
588
992
  React.createElement('span', { className: 'sss-item-label' }, highlightLabel(item)),
589
- React.createElement('span', { className: 'sss-item-kind' }, t('page')),
993
+ kindBadge(via, t('page')),
590
994
  ])
591
- const renderOption = (item, flatIndex) => React.createElement('li', {
995
+ const renderOption = (item, flatIndex, via) => React.createElement('li', {
592
996
  key: item.key,
593
997
  className: 'sss-item' + (flatIndex === activeIndex ? ' is-active' : ''),
594
998
  onMouseDown: () => pick(item),
@@ -598,7 +1002,7 @@ window.__ModuleLoader__.load({
598
1002
  React.createElement('span', { className: 'sss-item-label' }, highlightLabel(item)),
599
1003
  React.createElement('span', { className: 'sss-item-path' }, item.parentLabel),
600
1004
  ]),
601
- React.createElement('span', { className: 'sss-item-kind' }, t('option')),
1005
+ kindBadge(via, t('option')),
602
1006
  ])
603
1007
 
604
1008
  return React.createElement('div', { className: 'sss-wrap' }, [
@@ -636,7 +1040,13 @@ window.__ModuleLoader__.load({
636
1040
  ]),
637
1041
  open && q !== '' ? React.createElement('div', { className: 'sss-pop', ref: popRef }, [
638
1042
  none
639
- ? React.createElement('p', { className: 'sss-empty' }, t('empty'))
1043
+ ? (aiStatus === 'loading'
1044
+ ? React.createElement('p', { className: 'sss-ai-note' }, t('aiThinking'))
1045
+ : aiStatus === 'error'
1046
+ ? React.createElement('p', { className: 'sss-ai-note' }, t('aiUnavailable'))
1047
+ : (aiEnabled && !aiConfigured)
1048
+ ? React.createElement('p', { className: 'sss-ai-note' }, t('aiNeedConfig'))
1049
+ : React.createElement('p', { className: 'sss-empty' }, t('empty')))
640
1050
  : React.createElement('div', { className: 'sss-groups' }, [
641
1051
  shownPages.length > 0 ? React.createElement('div', { className: 'sss-group' }, [
642
1052
  React.createElement('div', { className: 'sss-group-title' }, t('pages')),
@@ -648,6 +1058,13 @@ window.__ModuleLoader__.load({
648
1058
  React.createElement('ul', { className: 'sss-list' },
649
1059
  shownOptions.map((item, i) => renderOption(item, shownPages.length + i))),
650
1060
  ]) : null,
1061
+ related.length > 0 ? React.createElement('div', { className: 'sss-group' }, [
1062
+ React.createElement('div', { className: 'sss-group-title' }, t('guess')),
1063
+ React.createElement('ul', { className: 'sss-list' },
1064
+ related.map((r, i) => r.kind === 'page'
1065
+ ? renderPage(r.item, shownPages.length + shownOptions.length + i, r.via)
1066
+ : renderOption(r.item, shownPages.length + shownOptions.length + i, r.via))),
1067
+ ]) : null,
651
1068
  ]),
652
1069
  ]) : null,
653
1070
  hint !== null
@@ -666,10 +1083,29 @@ window.__ModuleLoader__.load({
666
1083
  }
667
1084
 
668
1085
  function UpdatePanel() {
1086
+ const [open, setOpen] = React.useState(true)
669
1087
  const [state, setState] = React.useState('idle') // idle | loading | newest | outdated | error
670
1088
  const [latest, setLatest] = React.useState('')
671
1089
  const [copied, setCopied] = React.useState(false)
672
1090
  const [copyFailed, setCopyFailed] = React.useState(false)
1091
+ const [aiOn, setAiOn] = React.useState(aiSearchEnabled)
1092
+ const cfg = aiConfig()
1093
+ const [provider, setProvider] = React.useState(cfg.provider)
1094
+ const [baseUrl, setBaseUrl] = React.useState(cfg.baseUrl)
1095
+ const [model, setModel] = React.useState(cfg.model)
1096
+ const [apiKey, setApiKey] = React.useState(cfg.apiKey)
1097
+ const [aiSaved, setAiSaved] = React.useState(false)
1098
+ const [, setLogVersion] = React.useState(0)
1099
+ const [logCopied, setLogCopied] = React.useState(false)
1100
+ const [logCopyFail, setLogCopyFail] = React.useState(false)
1101
+ const [logExportFail, setLogExportFail] = React.useState(false)
1102
+ const [logCleared, setLogCleared] = React.useState(false)
1103
+
1104
+ React.useEffect(() => {
1105
+ const sync = () => setAiOn(aiSearchEnabled())
1106
+ window.addEventListener(AI_TOGGLE_EVENT, sync)
1107
+ return () => window.removeEventListener(AI_TOGGLE_EVENT, sync)
1108
+ }, [])
673
1109
 
674
1110
  React.useEffect(() => {
675
1111
  if (!copied) return
@@ -677,6 +1113,80 @@ window.__ModuleLoader__.load({
677
1113
  return () => clearTimeout(off)
678
1114
  }, [copied])
679
1115
 
1116
+ React.useEffect(() => {
1117
+ if (!aiSaved) return
1118
+ const off = setTimeout(() => setAiSaved(false), 1600)
1119
+ return () => clearTimeout(off)
1120
+ }, [aiSaved])
1121
+
1122
+ React.useEffect(() => {
1123
+ const sync = () => setLogVersion((v) => v + 1)
1124
+ window.addEventListener(LOG_EVENT, sync)
1125
+ return () => window.removeEventListener(LOG_EVENT, sync)
1126
+ }, [])
1127
+
1128
+ React.useEffect(() => {
1129
+ if (!logCopied) return
1130
+ const off = setTimeout(() => setLogCopied(false), 1600)
1131
+ return () => clearTimeout(off)
1132
+ }, [logCopied])
1133
+
1134
+ React.useEffect(() => {
1135
+ if (!logCleared) return
1136
+ const off = setTimeout(() => setLogCleared(false), 1600)
1137
+ return () => clearTimeout(off)
1138
+ }, [logCleared])
1139
+
1140
+ React.useEffect(() => {
1141
+ if (!logExportFail) return
1142
+ const off = setTimeout(() => setLogExportFail(false), 1600)
1143
+ return () => clearTimeout(off)
1144
+ }, [logExportFail])
1145
+
1146
+ const saveAi = () => {
1147
+ setAiConfig({
1148
+ provider,
1149
+ baseUrl: baseUrl.trim(),
1150
+ model: model.trim(),
1151
+ apiKey: apiKey.trim(),
1152
+ })
1153
+ setAiSaved(true)
1154
+ log('info', '保存 AI 配置', { provider, model: model.trim() })
1155
+ }
1156
+
1157
+ const exportLogs = () => {
1158
+ setLogExportFail(false)
1159
+ try {
1160
+ const blob = new Blob([formatLogs(LOGS)], { type: 'text/plain;charset=utf-8' })
1161
+ const url = URL.createObjectURL(blob)
1162
+ const a = document.createElement('a')
1163
+ a.href = url
1164
+ a.download = `dsh-settings-search-${Date.now()}.log`
1165
+ document.body.appendChild(a)
1166
+ a.click()
1167
+ a.remove()
1168
+ URL.revokeObjectURL(url)
1169
+ } catch {
1170
+ setLogExportFail(true)
1171
+ }
1172
+ }
1173
+
1174
+ const copyLogs = async () => {
1175
+ try {
1176
+ await navigator.clipboard.writeText(formatLogs(LOGS))
1177
+ setLogCopied(true)
1178
+ setLogCopyFail(false)
1179
+ } catch {
1180
+ setLogCopyFail(true)
1181
+ }
1182
+ }
1183
+
1184
+ const clearLogs = () => {
1185
+ LOGS.length = 0
1186
+ setLogVersion((v) => v + 1)
1187
+ setLogCleared(true)
1188
+ }
1189
+
680
1190
  const check = async () => {
681
1191
  setState('loading')
682
1192
  setCopied(false)
@@ -688,54 +1198,164 @@ window.__ModuleLoader__.load({
688
1198
  const ver = typeof data?.version === 'string' ? data.version : ''
689
1199
  if (ver === '') throw new Error('no version in registry response')
690
1200
  setLatest(ver)
691
- setState(compareSemver(ver, PKG_VERSION) > 0 ? 'outdated' : 'newest')
1201
+ const newer = compareSemver(ver, PKG_VERSION) > 0
1202
+ setState(newer ? 'outdated' : 'newest')
1203
+ log('info', '检查更新', { result: newer ? 'outdated' : 'newest', latest: ver })
692
1204
  } catch {
693
1205
  setState('error')
1206
+ log('error', '检查更新失败', {})
694
1207
  }
695
1208
  }
696
1209
 
1210
+ const updateCommand = latest ? `dsh plugin --profile web add ${NPM_NAME}@${latest}` : ''
1211
+ const npxUpdateCommand = latest ? `npx @deepseek-ai/dsh plugin --profile web add ${NPM_NAME}@${latest}` : ''
1212
+ const releasesUrl = latest
1213
+ ? `https://github.com/objectivex666/dsh-settings-search/releases/tag/v${latest}`
1214
+ : 'https://github.com/objectivex666/dsh-settings-search/releases/latest'
1215
+ const commandBlock = (label, command) => React.createElement('div', { className: 'sss-vr-update-command', key: command }, [
1216
+ React.createElement('div', { className: 'sss-vr-update-command-label' }, label),
1217
+ React.createElement('code', { className: 'sss-vr-code' }, command),
1218
+ ])
697
1219
  const copyCmd = async () => {
698
- const cmd = `dsh plugin --profile web update ${NPM_NAME}`
699
- try { await navigator.clipboard.writeText(cmd); setCopied(true); setCopyFailed(false) } catch { setCopyFailed(true) }
1220
+ try { await navigator.clipboard.writeText(updateCommand); setCopied(true); setCopyFailed(false) } catch { setCopyFailed(true) }
700
1221
  }
701
1222
 
702
- const statusNode = {
703
- idle: React.createElement('span', { className: 'sss-upd-val' }, '—'),
704
- loading: React.createElement('span', { className: 'sss-upd-val' }, t('updChecking')),
705
- newest: React.createElement('span', { className: 'sss-upd-val' }, t('updNewest')),
706
- error: React.createElement('span', { className: 'sss-upd-val' }, t('updErr')),
707
- outdated: latest === '' ? null : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } }, [
708
- React.createElement('p', { className: 'sss-upd-note' }, t('updFound').replace('{ver}', `v${latest}`)),
709
- React.createElement('code', { className: 'sss-cmd' }, `dsh plugin --profile web update ${NPM_NAME}`),
710
- React.createElement('div', null,
711
- React.createElement('button', { className: 'sss-btn', type: 'button', onClick: () => { void copyCmd() } },
712
- copied ? t('updCopied') : t('updCopy')),
713
- copyFailed ? React.createElement('p', { className: 'sss-upd-note', style: { color: 'var(--dsw-alias-label-danger)' } }, t('updCopyFail')) : null),
714
- ]),
715
- }
1223
+ // Mirror Vision Router: check for updates automatically when the panel opens.
1224
+ React.useEffect(() => {
1225
+ if (state === 'idle') void check()
1226
+ }, [state]) // eslint-disable-line react-hooks/exhaustive-deps
716
1227
 
717
- return React.createElement('div', { className: 'sss-upd' }, [
718
- React.createElement('h3', { className: 'sss-upd-title' }, `${t('updn')} v${PKG_VERSION}`),
719
- React.createElement('p', { className: 'sss-upd-desc' }, t('updDesc')),
720
- React.createElement('div', { className: 'sss-upd-card' }, [
721
- React.createElement('div', { className: 'sss-upd-row' }, [
722
- React.createElement('span', { className: 'sss-upd-key' }, t('updCurrent')),
723
- React.createElement('code', { className: 'sss-cmd' }, PKG_VERSION),
724
- state === 'newest' ? React.createElement('span', { className: 'sss-upd-badge' }, 'npm ✓') : null,
1228
+ return React.createElement('div', { className: 'sss-vr' + (open ? ' sss-vr-open' : '') }, [
1229
+ React.createElement('button', {
1230
+ type: 'button',
1231
+ className: 'sss-vr-header',
1232
+ 'aria-expanded': open,
1233
+ onClick: () => setOpen(!open),
1234
+ }, [
1235
+ React.createElement('span', { className: 'sss-vr-headText' }, [
1236
+ React.createElement('span', { className: 'sss-vr-name' }, t('updn')),
1237
+ React.createElement('span', { className: 'sss-vr-desc' }, t('updDesc')),
725
1238
  ]),
726
- state === 'outdated' || state === 'loading'
727
- ? React.createElement('div', { className: 'sss-upd-row' }, [
728
- React.createElement('span', { className: 'sss-upd-key' }, t('updLatestKnown')),
729
- state === 'loading' ? React.createElement('span', { className: 'sss-upd-val' }, '…') : React.createElement('code', { className: 'sss-cmd' }, latest),
730
- ])
731
- : null,
732
- state === 'outdated' || state === 'loading' || state === 'error' || state === 'idle' || state === 'newest'
733
- ? (state === 'outdated' ? statusNode.outdated : statusNode[state])
734
- : null,
735
- React.createElement('div', { className: 'sss-upd-row' },
736
- React.createElement('button', { className: 'sss-btn', type: 'button', disabled: state === 'loading', onClick: () => { void check() } },
737
- state === 'loading' ? t('updChecking') : t('updCheck'))),
1239
+ React.createElement('span', { className: 'sss-vr-chevron' + (open ? ' sss-vr-chevron-open' : '') }, '▾'),
738
1240
  ]),
1241
+ open ? React.createElement('div', { className: 'sss-vr-body' }, [
1242
+ React.createElement('div', { className: 'sss-vr-field' }, [
1243
+ React.createElement('div', { className: 'sss-vr-field-head' }, [
1244
+ React.createElement('span', { className: 'sss-vr-label' }, t('aiSearch')),
1245
+ React.createElement('input', {
1246
+ className: 'sss-vr-check',
1247
+ type: 'checkbox',
1248
+ checked: aiOn,
1249
+ onChange: (event) => {
1250
+ const next = event.target.checked
1251
+ setAiSearchEnabled(next)
1252
+ setAiOn(next)
1253
+ },
1254
+ }),
1255
+ ]),
1256
+ React.createElement('p', { className: 'sss-vr-hint' }, t('aiDesc')),
1257
+ React.createElement('p', { className: 'sss-vr-hint' }, t('aiPrivacy')),
1258
+ ]),
1259
+ React.createElement('div', { className: 'sss-vr-field sss-vr-form' }, [
1260
+ React.createElement('div', { className: 'sss-vr-field-head' }, [
1261
+ React.createElement('label', { className: 'sss-vr-label' }, t('aiProvider')),
1262
+ ]),
1263
+ React.createElement('select', {
1264
+ className: 'sss-vr-input sss-vr-select',
1265
+ value: provider,
1266
+ onChange: (event) => setProvider(event.target.value),
1267
+ }, [
1268
+ React.createElement('option', { value: 'openai' }, t('aiOpenAI')),
1269
+ React.createElement('option', { value: 'anthropic' }, t('aiAnthropic')),
1270
+ ]),
1271
+ React.createElement('div', { className: 'sss-vr-field-head' }, [
1272
+ React.createElement('label', { className: 'sss-vr-label' }, t('aiBaseUrl')),
1273
+ ]),
1274
+ React.createElement('input', {
1275
+ className: 'sss-vr-input',
1276
+ type: 'url',
1277
+ value: baseUrl,
1278
+ placeholder: provider === 'anthropic' ? 'https://api.anthropic.com' : 'https://api.openai.com/v1',
1279
+ spellCheck: false,
1280
+ onChange: (event) => setBaseUrl(event.target.value),
1281
+ }),
1282
+ React.createElement('div', { className: 'sss-vr-field-head' }, [
1283
+ React.createElement('label', { className: 'sss-vr-label' }, t('aiModel')),
1284
+ ]),
1285
+ React.createElement('input', {
1286
+ className: 'sss-vr-input',
1287
+ type: 'text',
1288
+ value: model,
1289
+ placeholder: provider === 'anthropic' ? 'claude-sonnet-4-20250514' : 'gpt-4o-mini',
1290
+ spellCheck: false,
1291
+ onChange: (event) => setModel(event.target.value),
1292
+ }),
1293
+ React.createElement('div', { className: 'sss-vr-field-head' }, [
1294
+ React.createElement('label', { className: 'sss-vr-label' }, t('aiApiKey')),
1295
+ ]),
1296
+ React.createElement('input', {
1297
+ className: 'sss-vr-input',
1298
+ type: 'password',
1299
+ value: apiKey,
1300
+ placeholder: 'sk-…',
1301
+ spellCheck: false,
1302
+ autoComplete: 'off',
1303
+ onChange: (event) => setApiKey(event.target.value),
1304
+ }),
1305
+ React.createElement('div', { className: 'sss-vr-actions' },
1306
+ React.createElement('button', { className: 'sss-vr-btn sss-vr-primary', type: 'button', onClick: () => { void saveAi() } },
1307
+ aiSaved ? t('aiConfigSaved') : t('aiConfigSave'))),
1308
+ ]),
1309
+ React.createElement('div', { className: 'sss-vr-field' }, [
1310
+ React.createElement('div', { className: 'sss-vr-row' }, [
1311
+ React.createElement('span', { className: 'sss-vr-rowname' }, t('updCurrent')),
1312
+ React.createElement('span', { className: 'sss-vr-value' }, PKG_VERSION),
1313
+ state === 'newest' ? React.createElement('span', { className: 'sss-vr-badge' }, 'npm ✓') : null,
1314
+ ]),
1315
+ state === 'loading'
1316
+ ? React.createElement('p', { className: 'sss-vr-note' }, t('updChecking'))
1317
+ : null,
1318
+ state === 'error'
1319
+ ? React.createElement('p', { className: 'sss-vr-note' }, t('updErr'))
1320
+ : null,
1321
+ state === 'outdated'
1322
+ ? React.createElement('div', { className: 'sss-vr-update' }, [
1323
+ React.createElement('div', { className: 'sss-vr-update-title' }, t('updFound').replace('{ver}', `v${latest}`)),
1324
+ commandBlock(t('updCLI'), updateCommand),
1325
+ commandBlock(t('updNpx'), npxUpdateCommand),
1326
+ React.createElement('p', { className: 'sss-vr-update-note' }, t('updRestart')),
1327
+ React.createElement('div', { className: 'sss-vr-update-actions' }, [
1328
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void copyCmd() } },
1329
+ copied ? t('updCopied') : t('updCopy')),
1330
+ React.createElement('a', { className: 'sss-vr-link', href: releasesUrl, target: '_blank', rel: 'noopener noreferrer' }, t('updRelease')),
1331
+ ]),
1332
+ copyFailed ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-label-danger)' } }, t('updCopyFail')) : null,
1333
+ ])
1334
+ : null,
1335
+ React.createElement('div', { className: 'sss-vr-actions' },
1336
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: state === 'loading', onClick: () => { void check() } },
1337
+ state === 'loading' ? t('updChecking') : t('updCheck'))),
1338
+ ]),
1339
+ React.createElement('div', { className: 'sss-vr-field' }, [
1340
+ React.createElement('div', { className: 'sss-vr-field-head' }, [
1341
+ React.createElement('span', { className: 'sss-vr-label' }, t('logTitle')),
1342
+ LOGS.length > 0 ? React.createElement('span', { className: 'sss-vr-badge' }, String(LOGS.length)) : null,
1343
+ ]),
1344
+ React.createElement('p', { className: 'sss-vr-hint' }, t('logDesc')),
1345
+ LOGS.length === 0
1346
+ ? React.createElement('p', { className: 'sss-vr-note' }, t('logEmpty'))
1347
+ : null,
1348
+ React.createElement('div', { className: 'sss-vr-actions' }, [
1349
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void exportLogs() } }, t('logExport')),
1350
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void copyLogs() } },
1351
+ logCopied ? t('logCopied') : t('logCopy')),
1352
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void clearLogs() } },
1353
+ logCleared ? t('logCleared') : t('logClear')),
1354
+ ]),
1355
+ logCopyFail ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-label-danger)' } }, t('logCopyFail')) : null,
1356
+ logExportFail ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-label-danger)' } }, t('logExportFail')) : null,
1357
+ ]),
1358
+ ]) : null,
739
1359
  ])
740
1360
  }
741
1361
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectivex666/dsh-settings-search",
3
- "version": "1.5.0",
3
+ "version": "1.7.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",
@@ -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})`)