@objectivex666/dsh-settings-search 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -30,8 +30,10 @@ 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.10.0'
33
+ const PKG_VERSION = '1.12.0'
34
34
  const NPM_NAME = '@objectivex666/dsh-settings-search'
35
+ const REPO_URL = 'https://github.com/objectivex666/dsh-settings-search'
36
+ const ISSUES_URL = 'https://github.com/objectivex666/dsh-settings-search/issues/new/choose'
35
37
  const zh = {
36
38
  title: '设置',
37
39
  placeholder: '搜索设置…',
@@ -44,6 +46,11 @@ window.__ModuleLoader__.load({
44
46
  openSearch: '打开搜索',
45
47
  shortcutTitle: 'Ctrl+F / ⌘F 快捷键',
46
48
  shortcutHint: '在设置面板内按 Ctrl+F 或 ⌘F 聚焦搜索框;关闭后保留浏览器默认查找。',
49
+ indexTitle: '设置索引',
50
+ indexDesc: '索引保存在本机浏览器;只在首次安装或更新其它插件后重建,也可以手动重建。',
51
+ indexRebuild: '手动重建索引',
52
+ indexRebuilding: '正在重建索引…',
53
+ indexDone: '索引已重建 ✓',
47
54
  warming: '正在导入各分区选项…',
48
55
  empty: '没有匹配的设置',
49
56
  pages: '设置页',
@@ -69,22 +76,31 @@ window.__ModuleLoader__.load({
69
76
  updNpx: 'npx',
70
77
  updRelease: '查看 release notes',
71
78
  updReleaseNotes: '查看更新说明',
79
+ updRepo: 'GitHub 仓库',
72
80
  updFetchingNotes: '正在获取…',
73
81
  updRestart: '更新完成后重启 DSH,新版本才会生效。',
74
82
  aiSearch: 'AI 联想搜索',
75
- aiDesc: '没有直接匹配时,用你配置的模型(OpenAI 兼容或 Anthropic)理解目标并推荐设置项。',
83
+ aiDesc: '没有直接匹配时,用你配置的模型(OpenAI 兼容、Anthropic 或本机 Ollama)理解目标并推荐设置项。',
76
84
  aiPrivacy: 'AI 请求会发送到你配置的模型服务;API Key 仅保存在本机浏览器。',
77
85
  aiOn: '已开启',
78
86
  aiOff: '已关闭',
79
87
  aiThinking: '正在用 AI 理解搜索意图…',
80
88
  aiUnavailable: 'AI 联想暂不可用',
81
- aiNeedConfig: '请先在「设置搜索」页配置 Base URL、模型名与 API Key。',
89
+ aiNeedConfig: '请先在「设置搜索」页配置 Base URL、模型名与 API Key(Ollama 无需密钥)。',
82
90
  aiProvider: '接口类型',
83
91
  aiOpenAI: 'OpenAI 兼容',
84
92
  aiAnthropic: 'Anthropic',
93
+ aiOllama: 'Ollama(本机)',
85
94
  aiBaseUrl: 'Base URL',
86
95
  aiBaseUrlHint: '仅支持 http(s) 地址;非本机地址必须使用 https,以免泄露 API Key。',
87
96
  aiModel: '模型名',
97
+ aiModelHint: '从接口返回的模型中选择,或手动输入完整模型名。',
98
+ aiModelsLoad: '获取模型',
99
+ aiModelsLoading: '获取中…',
100
+ aiModelsLoaded: '已获取 {count} 个模型,可直接选择。',
101
+ aiModelsEmpty: '服务未返回模型,可手动输入。',
102
+ aiModelsFail: '获取模型失败:{hint}{detail}',
103
+ aiModelsNeedConfig: '先填写有效接口地址;云端接口还需 API Key。',
88
104
  aiApiKey: 'API Key',
89
105
  aiDshImport: '导入 DSH 模型配置',
90
106
  aiDshImporting: '正在导入…',
@@ -112,10 +128,14 @@ window.__ModuleLoader__.load({
112
128
  aiTestErrBadResponse: '响应格式异常,请确认接口类型与服务兼容',
113
129
  aiTestErrHttp: '请求失败(HTTP 状态码错误),请检查地址与配置',
114
130
  aiTestErrUnsafeUrl: '接口地址不安全:仅支持 http(s),非本机必须为 https,且不可包含账号密码',
131
+ aiOllamaErrNetwork: '无法连接 Ollama,请确认本机服务已启动、地址可达,且 OLLAMA_ORIGINS 允许当前 DSH 页面来源',
132
+ aiOllamaErrForbidden: 'Ollama 拒绝访问,请检查 OLLAMA_ORIGINS 是否允许当前 DSH 页面来源',
133
+ aiOllamaErrTimeout: 'Ollama 响应超时,请检查本机服务及模型加载状态',
115
134
  logTitle: '日志',
116
135
  logDesc: '记录插件运行、搜索与 AI 请求的关键动作,便于排查问题。',
117
136
  logEmpty: '暂无日志',
118
137
  logExport: '导出日志',
138
+ logIssues: '反馈问题',
119
139
  logCopy: '复制日志',
120
140
  logCopied: '已复制 ✓',
121
141
  logCopyFail: '复制失败,请稍后重试。',
@@ -156,6 +176,11 @@ window.__ModuleLoader__.load({
156
176
  shortcutTitle: 'Ctrl+F / ⌘F shortcut',
157
177
  shortcutHint: 'Focus the search box with Ctrl+F or ⌘F inside the settings panel; turn it off to keep the browser default.',
158
178
  warming: 'Importing section options…',
179
+ indexTitle: 'Settings index',
180
+ indexDesc: 'The index is stored locally; it rebuilds only after another plugin is installed or updated. You can also rebuild it manually.',
181
+ indexRebuild: 'Rebuild index',
182
+ indexRebuilding: 'Rebuilding…',
183
+ indexDone: 'Index rebuilt ✓',
159
184
  empty: 'No matching settings',
160
185
  pages: 'Settings pages',
161
186
  options: 'In-page options',
@@ -180,22 +205,31 @@ window.__ModuleLoader__.load({
180
205
  updNpx: 'npx',
181
206
  updRelease: 'View release notes',
182
207
  updReleaseNotes: 'View changelog',
208
+ updRepo: 'GitHub repository',
183
209
  updFetchingNotes: 'Fetching…',
184
210
  updRestart: 'Restart DSH after updating for the new version to take effect.',
185
211
  aiSearch: 'AI-assisted search',
186
- aiDesc: 'When no direct match exists, your own model (OpenAI-compatible or Anthropic) understands your goal and suggests settings.',
212
+ aiDesc: 'When no direct match exists, your own model (OpenAI-compatible, Anthropic, or local Ollama) understands your goal and suggests settings.',
187
213
  aiPrivacy: 'AI requests are sent to the model service you configure; the API key stays only in your browser.',
188
214
  aiOn: 'On',
189
215
  aiOff: 'Off',
190
216
  aiThinking: 'Understanding your intent with AI…',
191
217
  aiUnavailable: 'AI suggestions are unavailable',
192
- aiNeedConfig: 'Configure Base URL, model, and API key in the Settings Search panel first.',
218
+ aiNeedConfig: 'Configure Base URL, model, and API key in the Settings Search panel first (Ollama needs no key).',
193
219
  aiProvider: 'API type',
194
220
  aiOpenAI: 'OpenAI-compatible',
195
221
  aiAnthropic: 'Anthropic',
222
+ aiOllama: 'Ollama (local)',
196
223
  aiBaseUrl: 'Base URL',
197
224
  aiBaseUrlHint: 'Only http(s) URLs are allowed; remote hosts must use https to avoid exposing your API key.',
198
225
  aiModel: 'Model',
226
+ aiModelHint: 'Choose a model returned by the API, or enter a complete model name.',
227
+ aiModelsLoad: 'Get models',
228
+ aiModelsLoading: 'Loading…',
229
+ aiModelsLoaded: 'Loaded {count} models; choose one below.',
230
+ aiModelsEmpty: 'The service returned no models; enter one manually.',
231
+ aiModelsFail: 'Failed to load models: {hint}{detail}',
232
+ aiModelsNeedConfig: 'Enter a valid base URL first; cloud APIs also need an API key.',
199
233
  aiApiKey: 'API Key',
200
234
  aiDshImport: 'Import DSH model config',
201
235
  aiDshImporting: 'Importing…',
@@ -223,10 +257,14 @@ window.__ModuleLoader__.load({
223
257
  aiTestErrBadResponse: 'Unexpected response — confirm the API type matches the service',
224
258
  aiTestErrHttp: 'Request failed (HTTP status error) — check the URL and config',
225
259
  aiTestErrUnsafeUrl: 'Unsafe URL — only http(s) is allowed, remote hosts must use https, and embedded credentials are not permitted',
260
+ aiOllamaErrNetwork: 'Cannot connect to Ollama. Check that the local service is running, the URL is reachable, and OLLAMA_ORIGINS allows this DSH page origin',
261
+ aiOllamaErrForbidden: 'Ollama denied access. Check that OLLAMA_ORIGINS allows this DSH page origin',
262
+ aiOllamaErrTimeout: 'Ollama timed out. Check the local service and model loading status',
226
263
  logTitle: 'Logs',
227
264
  logDesc: 'Logs key plugin actions, searches, and AI requests to help troubleshoot.',
228
265
  logEmpty: 'No logs yet',
229
266
  logExport: 'Export logs',
267
+ logIssues: 'Report issue',
230
268
  logCopy: 'Copy logs',
231
269
  logCopied: 'Copied ✓',
232
270
  logCopyFail: 'Copy failed. Please try again.',
@@ -463,6 +501,20 @@ window.__ModuleLoader__.load({
463
501
  const AI_BASE_URL_KEY = 'dsh-settings-search:ai-base-url'
464
502
  const AI_MODEL_KEY = 'dsh-settings-search:ai-model'
465
503
  const AI_API_KEY_KEY = 'dsh-settings-search:ai-api-key'
504
+ const OLLAMA_BASE_URL = 'http://127.0.0.1:11434'
505
+ const normalizeAiConfig = (cfg) => {
506
+ const provider = ['anthropic', 'ollama'].includes(cfg.provider) ? cfg.provider : 'openai'
507
+ return {
508
+ provider,
509
+ baseUrl: String(cfg.baseUrl ?? '').trim() || (provider === 'ollama' ? OLLAMA_BASE_URL : ''),
510
+ model: String(cfg.model ?? '').trim(),
511
+ apiKey: String(cfg.apiKey ?? '').trim(),
512
+ }
513
+ }
514
+ const aiConfigReady = (cfg) => {
515
+ const { provider, baseUrl, model, apiKey } = normalizeAiConfig(cfg)
516
+ return Boolean(baseUrl && model && (provider === 'ollama' || apiKey))
517
+ }
466
518
  /** Session-only API key storage avoids durable browser storage exposure. */
467
519
  const readApiKey = () => {
468
520
  const legacy = typeof localStorage !== 'undefined' ? (localStorage.getItem(AI_API_KEY_KEY) ?? '').trim() : ''
@@ -491,24 +543,23 @@ window.__ModuleLoader__.load({
491
543
  window.dispatchEvent(new CustomEvent(AI_TOGGLE_EVENT, { detail: enabled }))
492
544
  }
493
545
  const aiConfig = () => {
494
- if (typeof localStorage === 'undefined' && typeof sessionStorage === 'undefined') {
495
- return { provider: 'openai', baseUrl: '', model: '', apiKey: '' }
496
- }
497
- const providerRaw = (localStorage?.getItem(AI_PROVIDER_KEY) ?? 'openai').trim()
498
- return {
499
- provider: providerRaw === 'anthropic' ? 'anthropic' : 'openai',
500
- baseUrl: (localStorage?.getItem(AI_BASE_URL_KEY) ?? '').trim(),
501
- model: (localStorage?.getItem(AI_MODEL_KEY) ?? '').trim(),
546
+ const storage = typeof localStorage === 'undefined' ? null : localStorage
547
+ return normalizeAiConfig({
548
+ provider: (storage?.getItem(AI_PROVIDER_KEY) ?? 'openai').trim(),
549
+ baseUrl: storage?.getItem(AI_BASE_URL_KEY),
550
+ model: storage?.getItem(AI_MODEL_KEY),
502
551
  apiKey: readApiKey(),
503
- }
552
+ })
504
553
  }
505
554
  const setAiConfig = (cfg) => {
555
+ AI_CACHE.clear()
506
556
  if (typeof localStorage === 'undefined' && typeof sessionStorage === 'undefined') return
507
- writeApiKey(cfg.apiKey)
557
+ const normalized = normalizeAiConfig(cfg)
558
+ writeApiKey(normalized.apiKey)
508
559
  if (typeof localStorage === 'undefined') return
509
- localStorage.setItem(AI_PROVIDER_KEY, cfg.provider === 'anthropic' ? 'anthropic' : 'openai')
510
- localStorage.setItem(AI_BASE_URL_KEY, cfg.baseUrl ?? '')
511
- localStorage.setItem(AI_MODEL_KEY, cfg.model ?? '')
560
+ localStorage.setItem(AI_PROVIDER_KEY, normalized.provider)
561
+ localStorage.setItem(AI_BASE_URL_KEY, normalized.baseUrl)
562
+ localStorage.setItem(AI_MODEL_KEY, normalized.model)
512
563
  window.dispatchEvent(new CustomEvent(AI_CONFIG_EVENT))
513
564
  }
514
565
  /** First-run onboarding: remembered via localStorage so it shows once. */
@@ -518,6 +569,10 @@ window.__ModuleLoader__.load({
518
569
  const SEARCH_STYLE_EVENT = 'dsh-settings-search:style-change'
519
570
  const SEARCH_SHORTCUT_KEY = 'dsh-settings-search:shortcut-enabled'
520
571
  const SEARCH_SHORTCUT_EVENT = 'dsh-settings-search:shortcut-change'
572
+ const INDEX_STORAGE_KEY = 'dsh-settings-search:index:v1'
573
+ const INDEX_INVALID_EVENT = 'dsh-settings-search:index-invalid'
574
+ const INDEX_REBUILD_EVENT = 'dsh-settings-search:index-rebuild'
575
+ const INDEX_REBUILD_DONE_EVENT = 'dsh-settings-search:index-rebuild-done'
521
576
  const SEARCH_STYLES = ['sidebar', 'top', 'page']
522
577
  const readSearchStyle = () => {
523
578
  if (typeof localStorage === 'undefined') return 'sidebar'
@@ -599,62 +654,204 @@ window.__ModuleLoader__.load({
599
654
  if (/\/v1\/messages$/i.test(safe)) return safe
600
655
  return /\/v1$/i.test(safe) ? `${safe}/messages` : `${safe}/v1/messages`
601
656
  }
602
- const requestUserAi = async (query, labels, signal) => {
603
- const { provider, baseUrl, model, apiKey } = aiConfig()
604
- if (!baseUrl || !model || !apiKey) return []
605
- const isAnthropic = provider === 'anthropic'
606
- const url = isAnthropic ? anthropicMessagesUrl(baseUrl) : chatCompletionsUrl(baseUrl)
607
- if (!url) return []
608
- const startedAt = Date.now()
609
- log('info', 'AI 请求', { provider, model, query: String(query).slice(0, 50) })
657
+ const ollamaChatUrl = (baseUrl) => {
658
+ const safe = safeBaseUrl(baseUrl)
659
+ if (!safe) return ''
660
+ if (/\/api\/chat$/i.test(safe)) return safe
661
+ if (/\/api$/i.test(safe)) return `${safe}/chat`
662
+ // Accept the base URL used by Ollama's OpenAI-compatible endpoint too.
663
+ return `${safe.replace(/\/v1(?:\/chat\/completions)?$/i, '')}/api/chat`
664
+ }
665
+ const ollamaModelsUrl = (baseUrl) => {
666
+ const safe = safeBaseUrl(baseUrl)
667
+ if (!safe) return ''
668
+ const root = safe.replace(/\/v1(?:\/models|\/chat\/completions)?$/i, '').replace(/\/api(?:\/tags|\/chat)?$/i, '')
669
+ return `${root}/api/tags`
670
+ }
671
+ const chatModelsUrl = (baseUrl) => {
672
+ const safe = safeBaseUrl(baseUrl)
673
+ if (!safe) return ''
674
+ const root = safe.replace(/\/(?:models|chat\/completions)$/i, '')
675
+ return /\/v1$/i.test(root) ? `${root}/models` : `${root}/v1/models`
676
+ }
677
+ const anthropicModelsUrl = (baseUrl) => {
678
+ const safe = safeBaseUrl(baseUrl)
679
+ if (!safe) return ''
680
+ const root = safe.replace(/\/v1\/(?:models|messages)$/i, '')
681
+ return `${root}/v1/models`
682
+ }
683
+ const modelsUrl = (provider, baseUrl) => provider === 'ollama'
684
+ ? ollamaModelsUrl(baseUrl)
685
+ : provider === 'anthropic' ? anthropicModelsUrl(baseUrl) : chatModelsUrl(baseUrl)
686
+ const extractAiModels = (provider, data) => {
687
+ const rows = provider === 'ollama'
688
+ ? (Array.isArray(data?.models) ? data.models : [])
689
+ : (Array.isArray(data?.data) ? data.data : Array.isArray(data?.models) ? data.models : [])
690
+ return [...new Set(rows
691
+ .map((row) => typeof row === 'string' ? row : row?.id ?? row?.name ?? row?.model)
692
+ .filter((value) => typeof value === 'string')
693
+ .map((value) => value.trim())
694
+ .filter(Boolean))].slice(0, 500)
695
+ }
696
+ const fetchAiModels = async (cfg, signal) => {
697
+ cfg = normalizeAiConfig(cfg)
698
+ const { provider, baseUrl, apiKey } = cfg
699
+ const url = modelsUrl(provider, baseUrl)
700
+ if (!url) return { ok: false, code: 'unsafe-url', models: [], detail: '' }
701
+ if (provider !== 'ollama' && !apiKey) return { ok: false, code: 'need-config', models: [], detail: '' }
702
+ const headers = provider === 'anthropic'
703
+ ? {
704
+ 'x-api-key': apiKey,
705
+ 'anthropic-version': '2023-06-01',
706
+ 'anthropic-dangerous-direct-browser-access': 'true',
707
+ }
708
+ : provider === 'openai' ? { authorization: `Bearer ${apiKey}` } : {}
709
+ const controller = new AbortController()
710
+ const abort = () => controller.abort()
711
+ if (signal?.aborted) abort()
712
+ else signal?.addEventListener('abort', abort, { once: true })
610
713
  let timer
611
714
  const deadline = new Promise((_, reject) => {
612
715
  timer = setTimeout(() => {
613
- const error = new Error('AI request timed out')
716
+ const error = new Error('AI model list timed out')
614
717
  error.name = 'AI_TIMEOUT'
615
718
  reject(error)
719
+ controller.abort()
616
720
  }, 12000)
617
721
  })
618
- const work = (async () => {
619
- const userContent = `用户目标:${query}\n设置索引:\n${labels}`
620
- const headers = {
621
- 'content-type': 'application/json',
622
- ...(isAnthropic
623
- ? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true' }
624
- : { authorization: `Bearer ${apiKey}` }),
625
- }
626
- const body = isAnthropic
627
- ? {
628
- model,
629
- max_tokens: 1024,
630
- system: AI_PROMPT,
631
- messages: [{ role: 'user', content: userContent }],
632
- temperature: 0,
722
+ try {
723
+ const models = await Promise.race([
724
+ (async () => {
725
+ const response = await fetch(url, {
726
+ method: 'GET',
727
+ headers,
728
+ signal: controller.signal,
729
+ redirect: 'error',
730
+ })
731
+ if (!response.ok) {
732
+ const error = new Error(`HTTP ${response.status}`)
733
+ error.status = response.status
734
+ throw error
633
735
  }
634
- : {
635
- model,
636
- messages: [
637
- { role: 'system', content: AI_PROMPT },
638
- { role: 'user', content: userContent },
639
- ],
640
- temperature: 0,
736
+ const data = await response.json().catch((error) => {
737
+ if (error.name === 'AbortError') throw error
738
+ return null
739
+ })
740
+ if (data?.error) {
741
+ const error = new Error('Unexpected model list response')
742
+ error.name = 'AI_BAD_RESPONSE'
743
+ throw error
641
744
  }
642
- const response = await fetch(url, {
643
- method: 'POST',
745
+ return extractAiModels(provider, data)
746
+ })(),
747
+ deadline,
748
+ ])
749
+ return { ok: true, models, code: models.length === 0 ? 'empty' : 'ok', detail: '' }
750
+ } catch (error) {
751
+ if (error.name === 'AbortError') return { ok: false, code: 'cancelled', models: [], detail: '' }
752
+ if (error.name === 'AI_TIMEOUT') return { ok: false, code: 'timeout', models: [], detail: '' }
753
+ const code = error.status ? testHttpCode(error.status) : error.name === 'AI_BAD_RESPONSE' ? 'bad-response' : 'network'
754
+ return { ok: false, code, models: [], detail: String(error?.message ?? error).slice(0, 160) }
755
+ } finally {
756
+ clearTimeout(timer)
757
+ signal?.removeEventListener('abort', abort)
758
+ }
759
+ }
760
+ const aiRequest = (cfg, userContent, testing = false) => {
761
+ const { provider, baseUrl, model, apiKey } = normalizeAiConfig(cfg)
762
+ const messages = [{ role: 'user', content: userContent }]
763
+ const headers = { 'content-type': 'application/json' }
764
+ if (provider === 'anthropic') {
765
+ return {
766
+ url: anthropicMessagesUrl(baseUrl),
767
+ headers: { ...headers, 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true' },
768
+ body: { model, messages, max_tokens: testing ? 1 : 1024, ...(testing ? {} : { system: AI_PROMPT, temperature: 0 }) },
769
+ }
770
+ }
771
+ if (!testing) messages.unshift({ role: 'system', content: AI_PROMPT })
772
+ if (provider === 'ollama') {
773
+ return {
774
+ url: ollamaChatUrl(baseUrl),
644
775
  headers,
645
- signal,
776
+ body: {
777
+ model,
778
+ messages,
779
+ stream: false,
780
+ ...(testing ? {} : { format: 'json' }),
781
+ options: { temperature: 0, num_predict: testing ? 1 : 1024 },
782
+ },
783
+ }
784
+ }
785
+ return {
786
+ url: chatCompletionsUrl(baseUrl),
787
+ headers: { ...headers, authorization: `Bearer ${apiKey}` },
788
+ body: { model, messages, temperature: 0, ...(testing ? { max_tokens: 1 } : {}) },
789
+ }
790
+ }
791
+ const aiResponseContent = (provider, data) => {
792
+ if (provider === 'ollama') return data?.message?.content
793
+ if (provider === 'anthropic') {
794
+ if (!Array.isArray(data?.content) || data.content.length === 0) return undefined
795
+ return data.content.filter((block) => block?.type === 'text').map((block) => block.text).join('')
796
+ }
797
+ return data?.choices?.[0]?.message?.content
798
+ }
799
+ const fetchAiData = async (request, provider, signal) => {
800
+ const controller = new AbortController()
801
+ const abort = () => controller.abort()
802
+ if (signal?.aborted) abort()
803
+ else signal?.addEventListener('abort', abort, { once: true })
804
+ let timer
805
+ const deadline = new Promise((_, reject) => {
806
+ timer = setTimeout(() => {
807
+ const error = new Error('AI request timed out')
808
+ error.name = 'AI_TIMEOUT'
809
+ reject(error)
810
+ controller.abort()
811
+ }, provider === 'ollama' ? 120000 : 12000)
812
+ })
813
+ const work = (async () => {
814
+ const response = await fetch(request.url, {
815
+ method: 'POST',
816
+ headers: request.headers,
817
+ signal: controller.signal,
646
818
  redirect: 'error',
647
- body: JSON.stringify(body),
819
+ body: JSON.stringify(request.body),
648
820
  })
649
- if (!response.ok) throw new Error(`HTTP ${response.status}`)
650
- const data = await response.json()
651
- const content = isAnthropic
652
- ? (data?.content ?? []).filter((block) => block?.type === 'text').map((block) => block.text).join('')
653
- : data?.choices?.[0]?.message?.content ?? ''
654
- return extractAiKeywords(content)
821
+ if (!response.ok) {
822
+ const error = new Error(`HTTP ${response.status}`)
823
+ error.status = response.status
824
+ throw error
825
+ }
826
+ const data = await response.json().catch((error) => {
827
+ if (error.name === 'AbortError') throw error
828
+ return null
829
+ })
830
+ if (data?.error || typeof aiResponseContent(provider, data) !== 'string') {
831
+ const error = new Error('Unexpected AI response')
832
+ error.name = 'AI_BAD_RESPONSE'
833
+ throw error
834
+ }
835
+ return data
655
836
  })()
656
837
  try {
657
- const keywords = await Promise.race([work, deadline])
838
+ return await Promise.race([work, deadline])
839
+ } finally {
840
+ clearTimeout(timer)
841
+ signal?.removeEventListener('abort', abort)
842
+ }
843
+ }
844
+ const requestUserAi = async (query, labels, signal) => {
845
+ const cfg = aiConfig()
846
+ if (!aiConfigReady(cfg)) return []
847
+ const { provider, model } = cfg
848
+ const request = aiRequest(cfg, `用户目标:${query}\n设置索引:\n${labels}`)
849
+ if (!request.url) return []
850
+ const startedAt = Date.now()
851
+ log('info', 'AI 请求', { provider, model, query: String(query).slice(0, 50) })
852
+ try {
853
+ const data = await fetchAiData(request, provider, signal)
854
+ const keywords = extractAiKeywords(aiResponseContent(provider, data))
658
855
  log('info', 'AI 结果', { count: keywords.length, duration: Date.now() - startedAt })
659
856
  return keywords
660
857
  } catch (error) {
@@ -664,15 +861,14 @@ window.__ModuleLoader__.load({
664
861
  }
665
862
  log('error', 'AI 请求失败', { message: String(error?.message ?? error).slice(0, 160), duration: Date.now() - startedAt })
666
863
  throw error
667
- } finally {
668
- clearTimeout(timer)
669
864
  }
670
865
  }
671
866
  const requestAiKeywords = async (query, labels, signal) => {
672
- const cacheKey = query.toLowerCase()
867
+ const { provider, baseUrl, model } = aiConfig()
868
+ const cacheKey = JSON.stringify([provider, baseUrl, model, query.toLowerCase(), labels])
673
869
  if (AI_CACHE.has(cacheKey)) return AI_CACHE.get(cacheKey)
674
870
  const keywords = await requestUserAi(query, labels, signal)
675
- if (keywords.length > 0) {
871
+ if (!signal?.aborted && keywords.length > 0) {
676
872
  // bound the cache so a long session cannot grow it without limit
677
873
  if (AI_CACHE.size >= 100) AI_CACHE.clear()
678
874
  AI_CACHE.set(cacheKey, keywords)
@@ -698,68 +894,31 @@ window.__ModuleLoader__.load({
698
894
  const code = Number(status)
699
895
  return HTTP_TEST_CODES[code] ?? (code >= 500 && code <= 599 ? 'server' : 'http')
700
896
  }
701
- const testAiService = async (cfg) => {
702
- const provider = cfg.provider === 'anthropic' ? 'anthropic' : 'openai'
703
- const baseUrl = String(cfg.baseUrl ?? '').trim()
704
- const model = String(cfg.model ?? '').trim()
705
- const apiKey = String(cfg.apiKey ?? '').trim()
706
- if (!baseUrl || !model || !apiKey) return { ok: false, code: 'need-config', detail: '' }
707
- const isAnthropic = provider === 'anthropic'
708
- const requestUrl = isAnthropic ? anthropicMessagesUrl(baseUrl) : chatCompletionsUrl(baseUrl)
709
- if (!requestUrl) return { ok: false, code: 'unsafe-url', detail: '' }
897
+ const testAiService = async (cfg, signal) => {
898
+ cfg = normalizeAiConfig(cfg)
899
+ const { provider, model } = cfg
900
+ if (!aiConfigReady(cfg)) return { ok: false, code: 'need-config', detail: '' }
901
+ const request = aiRequest(cfg, 'ping', true)
902
+ if (!request.url) return { ok: false, code: 'unsafe-url', detail: '' }
710
903
  const startedAt = Date.now()
711
904
  log('info', '测试 API 服务', { provider, model })
712
- let timer
713
- const deadline = new Promise((_, reject) => {
714
- timer = setTimeout(() => {
715
- const error = new Error('AI request timed out')
716
- error.name = 'AI_TIMEOUT'
717
- reject(error)
718
- }, 12000)
719
- })
720
- const work = (async () => {
721
- const headers = {
722
- 'content-type': 'application/json',
723
- ...(isAnthropic
724
- ? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true' }
725
- : { authorization: `Bearer ${apiKey}` }),
726
- }
727
- const body = isAnthropic
728
- ? { model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] }
729
- : { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, temperature: 0 }
730
- const response = await fetch(requestUrl, {
731
- method: 'POST',
732
- headers,
733
- redirect: 'error',
734
- body: JSON.stringify(body),
735
- })
736
- if (!response.ok) return { ok: false, code: testHttpCode(response.status), detail: `HTTP ${response.status}` }
737
- const data = await response.json().catch(() => ({}))
738
- const valid = isAnthropic
739
- ? (Array.isArray(data?.content) && data.content.length > 0)
740
- : Boolean(data?.choices?.[0]?.message)
741
- if (!valid) return { ok: false, code: 'bad-response', detail: '' }
742
- return { ok: true }
743
- })()
744
905
  try {
745
- const result = await Promise.race([work, deadline])
746
- if (result.ok) log('info', '测试 API 成功', { duration: Date.now() - startedAt })
747
- else log('error', '测试 API 失败', { code: result.code ?? 'http', detail: result.detail ?? '', duration: Date.now() - startedAt })
748
- return result
906
+ await fetchAiData(request, provider, signal)
907
+ log('info', '测试 API 成功', { duration: Date.now() - startedAt })
908
+ return { ok: true }
749
909
  } catch (error) {
750
910
  if (error.name === 'AbortError') {
751
911
  log('warn', '测试 API 取消', { duration: Date.now() - startedAt })
752
- throw error
912
+ return { ok: false, code: 'cancelled', detail: '' }
753
913
  }
754
914
  if (error.name === 'AI_TIMEOUT') {
755
915
  log('error', '测试 API 超时', { duration: Date.now() - startedAt })
756
916
  return { ok: false, code: 'timeout', detail: '' }
757
917
  }
918
+ const code = error.status ? testHttpCode(error.status) : error.name === 'AI_BAD_RESPONSE' ? 'bad-response' : 'network'
758
919
  const reason = String(error?.message ?? error).slice(0, 160)
759
920
  log('error', '测试 API 失败', { detail: reason, duration: Date.now() - startedAt })
760
- return { ok: false, code: 'network', detail: reason }
761
- } finally {
762
- clearTimeout(timer)
921
+ return { ok: false, code, detail: reason }
763
922
  }
764
923
  }
765
924
 
@@ -800,7 +959,7 @@ window.__ModuleLoader__.load({
800
959
  .sss-trigger { display: flex; align-items: center; gap: 8px; width: 100%; box-sizing: border-box; height: 32px; padding: 0 9px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-dimmed); font: inherit; font-size: 14px; line-height: 22px; cursor: pointer; text-align: left; }
801
960
  .sss-trigger:hover { border-color: var(--dsw-alias-label-dimmed); color: var(--dsw-alias-label-secondary); }
802
961
  .sss-trigger:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; }
803
- .sss-page-overlay { position: fixed; inset: 0; z-index: 900; display: flex; align-items: flex-start; justify-content: center; padding: 72px 20px 32px; background: var(--dsw-alias-bg-mask, rgb(0 0 0 / 36%)); }
962
+ .sss-page-overlay { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: flex-start; justify-content: center; padding: 72px 20px 32px; background: var(--dsw-alias-bg-mask, rgb(0 0 0 / 36%)); }
804
963
  .sss-page { display: flex; flex-direction: column; width: min(760px, 100%); max-height: min(680px, calc(100vh - 104px)); overflow: hidden; background: var(--dsw-alias-bg-layer-3); border: 1px solid var(--dsw-alias-border-l1); border-radius: 14px; box-shadow: var(--dsw-shadow-lv3); }
805
964
  .sss-page-head { flex: none; display: flex; align-items: center; gap: 8px; height: 52px; padding: 0 10px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
806
965
  .sss-page-input { flex: 1; min-width: 0; height: 38px; padding: 0 6px; border: none; outline: none; background: transparent; font-size: 16px; line-height: 24px; color: var(--dsw-alias-label-primary); }
@@ -921,9 +1080,69 @@ window.__ModuleLoader__.load({
921
1080
  return typeof value === 'string' ? value : ''
922
1081
  }
923
1082
 
1083
+ const serializeHarvest = (harvest) => {
1084
+ const raw = {}
1085
+ for (const [slot, map] of harvest) {
1086
+ if (!(map instanceof Map) || map.size === 0) continue
1087
+ raw[slot] = Object.fromEntries([...map].filter(([, record]) => record && typeof record.title === 'string' && record.title !== ''))
1088
+ }
1089
+ return raw
1090
+ }
1091
+ const deserializeHarvest = (raw) => {
1092
+ const harvest = new Map()
1093
+ if (!raw || typeof raw !== 'object') return harvest
1094
+ for (const [slot, slotValue] of Object.entries(raw)) {
1095
+ if (!slotValue || typeof slotValue !== 'object') continue
1096
+ const map = new Map()
1097
+ for (const [key, record] of Object.entries(slotValue)) {
1098
+ if (!record || typeof record !== 'object' || typeof record.title !== 'string' || record.title === '') continue
1099
+ const inner = Array.isArray(record.inner)
1100
+ ? record.inner.filter((op) => op && typeof op.label === 'string').map((op) => ({ label: op.label, text: typeof op.text === 'string' ? op.text : '' }))
1101
+ : []
1102
+ map.set(key, {
1103
+ title: record.title,
1104
+ idx: Number.isFinite(record.idx) ? record.idx : 0,
1105
+ text: typeof record.text === 'string' ? record.text : '',
1106
+ inner,
1107
+ })
1108
+ }
1109
+ if (map.size > 0) harvest.set(slot, map)
1110
+ }
1111
+ return harvest
1112
+ }
1113
+
1114
+ const readIndexCache = () => {
1115
+ if (typeof localStorage === 'undefined') return null
1116
+ try {
1117
+ const raw = JSON.parse(localStorage.getItem(INDEX_STORAGE_KEY) ?? 'null')
1118
+ if (!raw || raw.version !== 1 || typeof raw.fingerprint !== 'string' || raw.fingerprint === '') return null
1119
+ return {
1120
+ fingerprint: typeof raw.fingerprint === 'string' ? raw.fingerprint : null,
1121
+ harvest: deserializeHarvest(raw.harvest),
1122
+ }
1123
+ } catch {
1124
+ return null
1125
+ }
1126
+ }
1127
+
1128
+ const otherPluginFingerprint = (plugins) => {
1129
+ const rows = Array.isArray(plugins) ? plugins : []
1130
+ const isSelf = (row) => [
1131
+ row?.id, row?.name, row?.npm, row?.package, row?.packageName,
1132
+ ].some((value) => typeof value === 'string' && value.includes(NPM_NAME))
1133
+ return JSON.stringify(rows.filter((row) => row && !isSelf(row)).map((row) => [
1134
+ String(row.id ?? row.name ?? ''),
1135
+ String(row.npm ?? row.package ?? row.packageName ?? ''),
1136
+ String(row.version ?? ''),
1137
+ ].sort()).sort())
1138
+ }
1139
+
924
1140
  function createModel(ctx) {
1141
+ const initialCache = readIndexCache()
1142
+ const cachedFingerprint = initialCache?.fingerprint ?? null
925
1143
  /** id → harvested visible title, per slot. */
926
- const harvest = new Map()
1144
+ const harvest = initialCache?.harvest ?? new Map()
1145
+ let fingerprint = cachedFingerprint
927
1146
 
928
1147
  const entriesOf = (slot) => {
929
1148
  try { return ctx.slots.entries(slot) ?? [] } catch { return [] }
@@ -1234,7 +1453,53 @@ window.__ModuleLoader__.load({
1234
1453
  : a.order - b.order)
1235
1454
  }
1236
1455
 
1237
- return { readSections, readDeep, runHarvest }
1456
+ const slotsFingerprint = () => otherPluginFingerprint([
1457
+ ...sortEntries(entriesOf('settings.plugin.item')).map((entry) => ({
1458
+ id: String(entry.options?.id ?? entry.options?.key ?? ''),
1459
+ name: optionLabel(entry.options?.label),
1460
+ locale: String(entry.options?.locale ?? ''),
1461
+ })),
1462
+ ...sortEntries(entriesOf('web-ui.plugin.item')).map((entry) => ({
1463
+ id: String(entry.options?.id ?? entry.options?.key ?? ''),
1464
+ name: optionLabel(entry.options?.label),
1465
+ locale: String(entry.options?.locale ?? ''),
1466
+ })),
1467
+ ])
1468
+
1469
+ const resetHarvest = () => { harvest.clear(); warmed = false }
1470
+
1471
+ const persistIndex = (nextFingerprint = fingerprint) => {
1472
+ if (typeof nextFingerprint !== 'string' || nextFingerprint === '' || harvest.size === 0) return false
1473
+ fingerprint = nextFingerprint
1474
+ if (typeof localStorage === 'undefined') return false
1475
+ try {
1476
+ localStorage.setItem(INDEX_STORAGE_KEY, JSON.stringify({
1477
+ version: 1,
1478
+ fingerprint,
1479
+ updatedAt: Date.now(),
1480
+ harvest: serializeHarvest(harvest),
1481
+ }))
1482
+ return true
1483
+ } catch {
1484
+ return false
1485
+ }
1486
+ }
1487
+
1488
+ const indexFingerprintReady = () => typeof fingerprint === 'string' && fingerprint !== ''
1489
+
1490
+ const applyIndexFingerprint = (nextFingerprint) => {
1491
+ if (typeof nextFingerprint !== 'string' || nextFingerprint === '') return false
1492
+ if (nextFingerprint === fingerprint) {
1493
+ if (nextFingerprint === cachedFingerprint) warmed = true
1494
+ return false
1495
+ }
1496
+ const wasPopulated = harvest.size > 0
1497
+ fingerprint = nextFingerprint
1498
+ resetHarvest()
1499
+ return wasPopulated
1500
+ }
1501
+
1502
+ return { readSections, readDeep, runHarvest, persistIndex, applyIndexFingerprint, resetHarvest, slotsFingerprint, indexFingerprintReady }
1238
1503
  }
1239
1504
 
1240
1505
  /* ---------- DOM-level navigation into the settings shell ---------- */
@@ -1286,7 +1551,7 @@ window.__ModuleLoader__.load({
1286
1551
  const warmAllSections = async (model, onStart, onDone) => {
1287
1552
  if (warmed || typeof document === 'undefined') return true
1288
1553
  const sections = model.readSections()
1289
- if (sections.length === 0) return true
1554
+ if (sections.length === 0) return false
1290
1555
  const original = activeSectionLabel()
1291
1556
  if (original === '') return false // settings panel not open yet; retry on next mount
1292
1557
  warmed = true
@@ -1306,6 +1571,8 @@ window.__ModuleLoader__.load({
1306
1571
  } finally {
1307
1572
  if (typeof onDone === 'function') onDone()
1308
1573
  }
1574
+ if (visited === 0) return false
1575
+ model.persistIndex()
1309
1576
  log('info', '预载设置索引', { total: sections.length, visited })
1310
1577
  return true
1311
1578
  }
@@ -1381,7 +1648,35 @@ window.__ModuleLoader__.load({
1381
1648
  }, 'settings-search: styles')
1382
1649
 
1383
1650
  const model = createModel(ctx)
1651
+ const indexControls = { resetHarvest: model.resetHarvest, persistIndex: model.persistIndex }
1384
1652
  const navigator = createNavigator(ctx)
1653
+ let pluginManagerSyncToken = 0
1654
+
1655
+ const syncPluginManagerIndex = async (face) => {
1656
+ if (face === null || !face.isLoopback) return
1657
+ const token = ++pluginManagerSyncToken
1658
+ try {
1659
+ const rows = await face.list()
1660
+ if (token !== pluginManagerSyncToken) return
1661
+ if (model.applyIndexFingerprint('manager:' + otherPluginFingerprint(rows))) {
1662
+ window.dispatchEvent(new CustomEvent(INDEX_INVALID_EVENT))
1663
+ log('info', '检测到其它插件变化,将重建设置索引', {})
1664
+ }
1665
+ } catch {
1666
+ // Keep the last trusted fingerprint when manager inspection fails.
1667
+ }
1668
+ }
1669
+
1670
+ ctx.inject(['pluginManager'], (inner) => {
1671
+ const face = inner.pluginManager ?? null
1672
+ const sync = () => { void syncPluginManagerIndex(face) }
1673
+ sync()
1674
+ const unsubscribe = typeof face?.onChange === 'function' ? face.onChange(sync) : null
1675
+ return () => {
1676
+ pluginManagerSyncToken += 1
1677
+ if (typeof unsubscribe === 'function') unsubscribe()
1678
+ }
1679
+ }, 'settings-search: plugin manager index fingerprint')
1385
1680
 
1386
1681
  /** Read the model fields from DSH; credentials are never requested. */
1387
1682
  const readDshModelConfig = async () => {
@@ -1407,7 +1702,9 @@ window.__ModuleLoader__.load({
1407
1702
  const baseUrl = String(providerProfile.baseURL ?? '').trim()
1408
1703
  if (!modelId || !baseUrl) return null
1409
1704
  return {
1410
- provider: protocol === 'anthropic-messages' ? 'anthropic' : 'openai',
1705
+ provider: providerId.toLowerCase() === 'ollama' || protocol === 'ollama'
1706
+ ? 'ollama'
1707
+ : protocol === 'anthropic-messages' ? 'anthropic' : 'openai',
1411
1708
  baseUrl,
1412
1709
  model: modelId,
1413
1710
  }
@@ -1565,6 +1862,24 @@ window.__ModuleLoader__.load({
1565
1862
  }
1566
1863
  }, [])
1567
1864
 
1865
+ React.useEffect(() => {
1866
+ // If no plugin manager is present, the slot registrations are the
1867
+ // only available change signal. Waiting briefly avoids racing the
1868
+ // optional manager during startup.
1869
+ const fallback = setTimeout(() => {
1870
+ if (model.indexFingerprintReady()) return
1871
+ if (model.applyIndexFingerprint(model.slotsFingerprint())) {
1872
+ window.dispatchEvent(new CustomEvent(INDEX_INVALID_EVENT))
1873
+ }
1874
+ }, 3000)
1875
+ const onInvalid = () => { setWarming(false); setTick((value) => value + 1) }
1876
+ window.addEventListener(INDEX_INVALID_EVENT, onInvalid)
1877
+ return () => {
1878
+ clearTimeout(fallback)
1879
+ window.removeEventListener(INDEX_INVALID_EVENT, onInvalid)
1880
+ }
1881
+ }, [])
1882
+
1568
1883
  React.useEffect(() => {
1569
1884
  const onWelcome = (event) => { if (event?.detail === true) setShowWelcome(true) }
1570
1885
  window.addEventListener(WELCOME_EVENT, onWelcome)
@@ -1659,12 +1974,12 @@ window.__ModuleLoader__.load({
1659
1974
  const results = [...shownPages, ...shownOptions, ...related.map((r) => r.item)]
1660
1975
 
1661
1976
  const cfg = aiConfig()
1662
- const aiConfigured = Boolean(cfg.baseUrl && cfg.model && cfg.apiKey)
1977
+ const aiConfigured = aiConfigReady(cfg)
1663
1978
  const aiReady = aiEnabled && aiConfigured && q.length >= 2 && noLocalMatch
1664
1979
  React.useEffect(() => {
1665
- if (!aiReady) return
1666
1980
  setAiKeywords([])
1667
1981
  setAiStatus('idle')
1982
+ if (!aiReady) return
1668
1983
  let alive = true
1669
1984
  const controller = new AbortController()
1670
1985
  const timer = setTimeout(() => {
@@ -1692,7 +2007,7 @@ window.__ModuleLoader__.load({
1692
2007
  clearTimeout(timer)
1693
2008
  controller.abort()
1694
2009
  }
1695
- }, [aiReady, aiEnabled, q])
2010
+ }, [aiReady, aiEnabled, q, cfg.provider, cfg.baseUrl, cfg.model, cfg.apiKey])
1696
2011
 
1697
2012
  const loggedQueryRef = React.useRef('')
1698
2013
  React.useEffect(() => {
@@ -1892,7 +2207,6 @@ window.__ModuleLoader__.load({
1892
2207
  ? React.createElement(Welcome, { t, onClose: dismissWelcome, onOpenPanel: openPanelFromWelcome })
1893
2208
  : null,
1894
2209
  ]),
1895
- React.createElement('div', { className: 'sss-wrap sss-top' }, [pageTriggerButton]),
1896
2210
  React.createElement('div', {
1897
2211
  className: 'sss-page-overlay',
1898
2212
  onMouseDown: (event) => { if (event.target === event.currentTarget) setPageOpen(false) },
@@ -2031,9 +2345,15 @@ window.__ModuleLoader__.load({
2031
2345
  const [baseUrl, setBaseUrl] = React.useState(cfg.baseUrl)
2032
2346
  const [model, setModel] = React.useState(cfg.model)
2033
2347
  const [apiKey, setApiKey] = React.useState(cfg.apiKey)
2348
+ const providerDrafts = React.useRef({})
2349
+ const testAbortRef = React.useRef(null)
2350
+ const modelsAbortRef = React.useRef(null)
2034
2351
  const [aiSaved, setAiSaved] = React.useState(false)
2035
2352
  const [testState, setTestState] = React.useState('idle') // idle | loading | ok | fail
2036
2353
  const [testMsg, setTestMsg] = React.useState('')
2354
+ const [aiModels, setAiModels] = React.useState([])
2355
+ const [modelsState, setModelsState] = React.useState('idle') // idle | loading | ready | fail
2356
+ const [modelsMsg, setModelsMsg] = React.useState('')
2037
2357
  const [dshImportState, setDshImportState] = React.useState('idle') // idle | loading | ok | fail
2038
2358
  const [dshImportMsg, setDshImportMsg] = React.useState('')
2039
2359
  const [, setLogVersion] = React.useState(0)
@@ -2041,6 +2361,8 @@ window.__ModuleLoader__.load({
2041
2361
  const [logCopyFail, setLogCopyFail] = React.useState(false)
2042
2362
  const [logExportFail, setLogExportFail] = React.useState(false)
2043
2363
  const [logCleared, setLogCleared] = React.useState(false)
2364
+ const [indexState, setIndexState] = React.useState('idle')
2365
+ const [warming, setWarming] = React.useState(false)
2044
2366
  const [confirmClear, setConfirmClear] = React.useState(false)
2045
2367
  const [confirmClearAi, setConfirmClearAi] = React.useState(false)
2046
2368
 
@@ -2065,6 +2387,15 @@ window.__ModuleLoader__.load({
2065
2387
  useAutoReset(copied, () => { setCopied(false); setCopyFailed(false) })
2066
2388
  useAutoReset(aiSaved, () => setAiSaved(false))
2067
2389
 
2390
+ React.useEffect(() => {
2391
+ setTestState('idle')
2392
+ setTestMsg('')
2393
+ setAiSaved(false)
2394
+ return () => testAbortRef.current?.abort()
2395
+ }, [provider, baseUrl, model, apiKey])
2396
+
2397
+ React.useEffect(() => () => modelsAbortRef.current?.abort(), [provider, baseUrl, apiKey])
2398
+
2068
2399
  React.useEffect(() => {
2069
2400
  const sync = () => setLogVersion((v) => v + 1)
2070
2401
  window.addEventListener(LOG_EVENT, sync)
@@ -2075,13 +2406,84 @@ window.__ModuleLoader__.load({
2075
2406
  useAutoReset(logCleared, () => setLogCleared(false))
2076
2407
  useAutoReset(logExportFail, () => setLogExportFail(false))
2077
2408
 
2409
+ const changeProvider = (next) => {
2410
+ providerDrafts.current[provider] = { baseUrl, model }
2411
+ const draft = providerDrafts.current[next]
2412
+ setProvider(next)
2413
+ setBaseUrl(draft?.baseUrl ?? (next === 'ollama' ? OLLAMA_BASE_URL : ''))
2414
+ setModel(draft?.model ?? '')
2415
+ resetModelList()
2416
+ }
2417
+
2418
+ const resetModelList = () => {
2419
+ modelsAbortRef.current?.abort()
2420
+ setAiModels([])
2421
+ setModelsState('idle')
2422
+ setModelsMsg('')
2423
+ }
2424
+
2425
+ const modelsErrorHint = (code) => {
2426
+ if (code === 'need-config') return t('aiModelsNeedConfig')
2427
+ const cloudKeys = {
2428
+ auth: 'aiTestErrAuth',
2429
+ forbidden: 'aiTestErrForbidden',
2430
+ 'not-found': 'aiTestErrNotFound',
2431
+ 'rate-limit': 'aiTestErrRateLimit',
2432
+ 'bad-request': 'aiTestErrBadRequest',
2433
+ server: 'aiTestErrServer',
2434
+ timeout: 'aiTestErrTimeout',
2435
+ network: 'aiTestErrNetwork',
2436
+ 'bad-response': 'aiTestErrBadResponse',
2437
+ 'unsafe-url': 'aiTestErrUnsafeUrl',
2438
+ }
2439
+ const ollamaKeys = { ...cloudKeys, network: 'aiOllamaErrNetwork', forbidden: 'aiOllamaErrForbidden', timeout: 'aiOllamaErrTimeout' }
2440
+ return t((provider === 'ollama' ? ollamaKeys : cloudKeys)[code] ?? 'aiTestErrHttp')
2441
+ }
2442
+
2443
+ const loadModels = async () => {
2444
+ if (modelsState === 'loading') return
2445
+ const source = normalizeAiConfig({ provider, baseUrl, model, apiKey })
2446
+ if (!modelsUrl(source.provider, source.baseUrl) || (source.provider !== 'ollama' && !source.apiKey)) {
2447
+ setModelsState('fail')
2448
+ setModelsMsg(t('aiModelsFail').replace('{hint}', t('aiModelsNeedConfig')).replace('{detail}', ''))
2449
+ return
2450
+ }
2451
+ const controller = new AbortController()
2452
+ modelsAbortRef.current?.abort()
2453
+ modelsAbortRef.current = controller
2454
+ setModelsState('loading')
2455
+ setModelsMsg('')
2456
+ const startedAt = Date.now()
2457
+ log('info', '获取 AI 模型列表', { provider: source.provider })
2458
+ const result = await fetchAiModels(source, controller.signal)
2459
+ if (controller.signal.aborted) return
2460
+ if (modelsAbortRef.current !== controller) return
2461
+ modelsAbortRef.current = null
2462
+ if (result.ok) {
2463
+ setAiModels(result.models)
2464
+ setModelsState('ready')
2465
+ setModelsMsg(result.models.length === 0
2466
+ ? t('aiModelsEmpty')
2467
+ : t('aiModelsLoaded').replace('{count}', String(result.models.length)))
2468
+ log('info', '获取 AI 模型列表成功', { count: result.models.length, duration: Date.now() - startedAt })
2469
+ } else {
2470
+ setAiModels([])
2471
+ setModelsState('fail')
2472
+ const hint = modelsErrorHint(result.code)
2473
+ const detail = result.detail ? `(${result.detail})` : ''
2474
+ setModelsMsg(t('aiModelsFail').replace('{hint}', hint).replace('{detail}', detail))
2475
+ log('error', '获取 AI 模型列表失败', { code: result.code, detail: result.detail, duration: Date.now() - startedAt })
2476
+ }
2477
+ }
2478
+
2078
2479
  const saveAi = () => {
2079
- setAiConfig({
2480
+ const next = normalizeAiConfig({
2080
2481
  provider,
2081
- baseUrl: baseUrl.trim(),
2082
- model: model.trim(),
2083
- apiKey: apiKey.trim(),
2482
+ baseUrl,
2483
+ model,
2484
+ apiKey,
2084
2485
  })
2486
+ setAiConfig(next)
2085
2487
  setAiSaved(true)
2086
2488
  log('info', '保存 AI 配置', { provider, model: model.trim() })
2087
2489
  }
@@ -2093,10 +2495,12 @@ window.__ModuleLoader__.load({
2093
2495
  try {
2094
2496
  const imported = await readDshModelConfig()
2095
2497
  if (!imported) throw new Error(t('aiDshImportEmpty'))
2498
+ providerDrafts.current[provider] = { baseUrl, model }
2096
2499
  setAiConfig({ ...imported, apiKey })
2097
2500
  setProvider(imported.provider)
2098
2501
  setBaseUrl(imported.baseUrl)
2099
2502
  setModel(imported.model)
2503
+ resetModelList()
2100
2504
  setDshImportState('ok')
2101
2505
  setDshImportMsg(t('aiDshImportOk'))
2102
2506
  log('info', '导入 DSH 模型配置', { provider: imported.provider, model: imported.model })
@@ -2113,6 +2517,25 @@ window.__ModuleLoader__.load({
2113
2517
  setSearchStyleState(style)
2114
2518
  }
2115
2519
 
2520
+ const rebuildIndex = async () => {
2521
+ if (indexState === 'loading' || warming) return
2522
+ setIndexState('loading')
2523
+ setWarming(true)
2524
+ indexControls.resetHarvest()
2525
+ try {
2526
+ const done = await warmAllSections(model, () => setWarming(true), () => setWarming(false))
2527
+ if (!done) throw new Error('index rebuild did not visit any sections')
2528
+ indexControls.persistIndex()
2529
+ setIndexState('done')
2530
+ window.dispatchEvent(new CustomEvent(INDEX_REBUILD_DONE_EVENT))
2531
+ setTimeout(() => setIndexState((state) => state === 'done' ? 'idle' : state), 2400)
2532
+ } catch (error) {
2533
+ setIndexState('idle')
2534
+ setWarming(false)
2535
+ log('error', '手动重建设置索引失败', { detail: String(error?.message ?? error).slice(0, 160) })
2536
+ }
2537
+ }
2538
+
2116
2539
  const updateShortcutEnabled = (enabled) => {
2117
2540
  setSearchShortcutEnabled(enabled)
2118
2541
  setShortcutOn(enabled)
@@ -2128,26 +2551,38 @@ window.__ModuleLoader__.load({
2128
2551
  setBaseUrl('')
2129
2552
  setModel('')
2130
2553
  setApiKey('')
2554
+ providerDrafts.current = {}
2555
+ resetModelList()
2131
2556
  setAiSaved(false)
2132
2557
  log('info', '清除 API 配置', {})
2133
2558
  }
2134
2559
 
2135
2560
  const testService = async () => {
2136
2561
  if (testState === 'loading') return
2137
- if (!baseUrl.trim() || !model.trim() || !apiKey.trim()) {
2562
+ if (!aiConfigReady({ provider, baseUrl, model, apiKey })) {
2138
2563
  setTestState('fail')
2139
2564
  setTestMsg(t('aiNeedConfig'))
2140
2565
  return
2141
2566
  }
2142
2567
  setTestState('loading')
2143
2568
  setTestMsg('')
2144
- const result = await testAiService({ provider, baseUrl, model, apiKey })
2569
+ const controller = new AbortController()
2570
+ testAbortRef.current?.abort()
2571
+ testAbortRef.current = controller
2572
+ const result = await testAiService({ provider, baseUrl, model, apiKey }, controller.signal)
2573
+ if (controller.signal.aborted) return
2574
+ if (testAbortRef.current === controller) testAbortRef.current = null
2145
2575
  if (result.ok) {
2146
2576
  setTestState('ok')
2147
2577
  setTestMsg(t('aiTestOk'))
2148
2578
  } else {
2149
2579
  setTestState('fail')
2150
- const hintKey = {
2580
+ const ollamaHintKey = provider === 'ollama' ? {
2581
+ network: 'aiOllamaErrNetwork',
2582
+ forbidden: 'aiOllamaErrForbidden',
2583
+ timeout: 'aiOllamaErrTimeout',
2584
+ }[result.code] : null
2585
+ const hintKey = ollamaHintKey ?? {
2151
2586
  auth: 'aiTestErrAuth',
2152
2587
  forbidden: 'aiTestErrForbidden',
2153
2588
  'not-found': 'aiTestErrNotFound',
@@ -2332,6 +2767,23 @@ window.__ModuleLoader__.load({
2332
2767
  ]),
2333
2768
  React.createElement('p', { className: 'sss-vr-hint' }, t('shortcutHint')),
2334
2769
  ]),
2770
+ React.createElement('div', { className: 'sss-vr-field' }, [
2771
+ React.createElement('div', { className: 'sss-vr-field-head' }, [
2772
+ React.createElement('span', { className: 'sss-vr-label' }, t('indexTitle')),
2773
+ ]),
2774
+ React.createElement('p', { className: 'sss-vr-hint' }, t('indexDesc')),
2775
+ React.createElement('div', { className: 'sss-vr-actions' }, [
2776
+ React.createElement('button', {
2777
+ className: 'sss-vr-btn',
2778
+ type: 'button',
2779
+ disabled: indexState === 'loading' || warming,
2780
+ onClick: () => { void rebuildIndex() },
2781
+ }, indexState === 'loading' || warming ? t('indexRebuilding') : t('indexRebuild')),
2782
+ ]),
2783
+ indexState === 'done'
2784
+ ? React.createElement('p', { className: 'sss-vr-note', style: { color: '#1e8e3e' } }, t('indexDone'))
2785
+ : null,
2786
+ ]),
2335
2787
  React.createElement('div', { className: 'sss-vr-field sss-vr-form' }, [
2336
2788
  React.createElement('div', { className: 'sss-vr-field-head' }, [
2337
2789
  React.createElement('label', { className: 'sss-vr-label' }, t('aiProvider')),
@@ -2339,14 +2791,16 @@ window.__ModuleLoader__.load({
2339
2791
  React.createElement('select', {
2340
2792
  className: 'sss-vr-input sss-vr-select',
2341
2793
  name: 'dsh-settings-search-provider',
2794
+ 'aria-label': t('aiProvider'),
2342
2795
  value: provider,
2343
2796
  autoComplete: 'off',
2344
2797
  'data-lpignore': 'true',
2345
2798
  'data-form-type': 'other',
2346
- onChange: (event) => setProvider(event.target.value),
2799
+ onChange: (event) => changeProvider(event.target.value),
2347
2800
  }, [
2348
2801
  React.createElement('option', { value: 'openai' }, t('aiOpenAI')),
2349
2802
  React.createElement('option', { value: 'anthropic' }, t('aiAnthropic')),
2803
+ React.createElement('option', { value: 'ollama' }, t('aiOllama')),
2350
2804
  ]),
2351
2805
  React.createElement('div', { className: 'sss-vr-field-head' }, [
2352
2806
  React.createElement('label', { className: 'sss-vr-label' }, t('aiBaseUrl')),
@@ -2355,48 +2809,65 @@ window.__ModuleLoader__.load({
2355
2809
  className: 'sss-vr-input',
2356
2810
  type: 'url',
2357
2811
  name: 'dsh-settings-search-base-url',
2812
+ 'aria-label': t('aiBaseUrl'),
2358
2813
  value: baseUrl,
2359
- placeholder: provider === 'anthropic' ? 'https://api.anthropic.com' : 'https://api.openai.com/v1',
2360
- spellCheck: false,
2361
- autoComplete: 'off',
2362
- 'data-lpignore': 'true',
2363
- 'data-1p-ignore': 'true',
2364
- 'data-form-type': 'other',
2365
- onChange: (event) => setBaseUrl(event.target.value),
2366
- }),
2367
- React.createElement('p', { className: 'sss-vr-hint' }, t('aiBaseUrlHint')),
2368
- React.createElement('div', { className: 'sss-vr-field-head' }, [
2369
- React.createElement('label', { className: 'sss-vr-label' }, t('aiModel')),
2370
- ]),
2371
- React.createElement('input', {
2372
- className: 'sss-vr-input',
2373
- type: 'text',
2374
- name: 'dsh-settings-search-model',
2375
- value: model,
2376
- placeholder: provider === 'anthropic' ? 'claude-sonnet-4-20250514' : 'gpt-4o-mini',
2377
- spellCheck: false,
2378
- autoComplete: 'off',
2379
- 'data-lpignore': 'true',
2380
- 'data-1p-ignore': 'true',
2381
- 'data-form-type': 'other',
2382
- onChange: (event) => setModel(event.target.value),
2383
- }),
2384
- React.createElement('div', { className: 'sss-vr-field-head' }, [
2385
- React.createElement('label', { className: 'sss-vr-label' }, t('aiApiKey')),
2386
- ]),
2387
- React.createElement('input', {
2388
- className: 'sss-vr-input sss-vr-mask',
2389
- type: 'text',
2390
- name: 'dsh-settings-search-api-key',
2391
- value: apiKey,
2392
- placeholder: 'sk-…',
2814
+ placeholder: provider === 'ollama' ? OLLAMA_BASE_URL
2815
+ : provider === 'anthropic' ? 'https://api.anthropic.com' : 'https://api.openai.com/v1',
2393
2816
  spellCheck: false,
2394
2817
  autoComplete: 'off',
2395
2818
  'data-lpignore': 'true',
2396
2819
  'data-1p-ignore': 'true',
2397
2820
  'data-form-type': 'other',
2398
- onChange: (event) => setApiKey(event.target.value),
2399
- }),
2821
+ onChange: (event) => { setBaseUrl(event.target.value); resetModelList() },
2822
+ }),
2823
+ React.createElement('p', { className: 'sss-vr-hint' }, t('aiBaseUrlHint')),
2824
+ React.createElement('div', { className: 'sss-vr-field-head' }, [
2825
+ React.createElement('label', { className: 'sss-vr-label' }, t('aiModel')),
2826
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: modelsState === 'loading', onClick: () => { void loadModels() } },
2827
+ modelsState === 'loading' ? t('aiModelsLoading') : t('aiModelsLoad')),
2828
+ ]),
2829
+ React.createElement('p', { className: 'sss-vr-hint' }, t('aiModelHint')),
2830
+ React.createElement('input', {
2831
+ className: 'sss-vr-input',
2832
+ type: 'text',
2833
+ name: 'dsh-settings-search-model',
2834
+ 'aria-label': t('aiModel'),
2835
+ value: model,
2836
+ list: 'dsh-settings-search-model-options',
2837
+ placeholder: provider === 'ollama' ? 'qwen3:8b'
2838
+ : provider === 'anthropic' ? 'claude-sonnet-4-20250514' : 'gpt-4o-mini',
2839
+ spellCheck: false,
2840
+ autoComplete: 'off',
2841
+ 'data-lpignore': 'true',
2842
+ 'data-1p-ignore': 'true',
2843
+ 'data-form-type': 'other',
2844
+ onChange: (event) => setModel(event.target.value),
2845
+ }),
2846
+ React.createElement('datalist', { id: 'dsh-settings-search-model-options' },
2847
+ aiModels.map((item) => React.createElement('option', { key: item, value: item }, item))),
2848
+ modelsMsg
2849
+ ? React.createElement('p', {
2850
+ className: modelsState === 'fail' ? 'sss-vr-note' : 'sss-vr-hint',
2851
+ style: modelsState === 'fail' ? { color: 'var(--dsw-alias-label-danger)' } : undefined,
2852
+ }, modelsMsg)
2853
+ : null,
2854
+ provider !== 'ollama' ? React.createElement('div', { className: 'sss-vr-field-head' }, [
2855
+ React.createElement('label', { className: 'sss-vr-label' }, t('aiApiKey')),
2856
+ ]) : null,
2857
+ provider !== 'ollama' ? React.createElement('input', {
2858
+ className: 'sss-vr-input sss-vr-mask',
2859
+ type: 'text',
2860
+ name: 'dsh-settings-search-api-key',
2861
+ 'aria-label': t('aiApiKey'),
2862
+ value: apiKey,
2863
+ placeholder: 'sk-…',
2864
+ spellCheck: false,
2865
+ autoComplete: 'off',
2866
+ 'data-lpignore': 'true',
2867
+ 'data-1p-ignore': 'true',
2868
+ 'data-form-type': 'other',
2869
+ onChange: (event) => { setApiKey(event.target.value); resetModelList() },
2870
+ }) : null,
2400
2871
  React.createElement('div', { className: 'sss-vr-actions' }, [
2401
2872
  React.createElement('button', { className: 'sss-vr-btn sss-vr-primary', type: 'button', onClick: () => { void saveAi() } },
2402
2873
  aiSaved ? t('aiConfigSaved') : t('aiConfigSave')),
@@ -2444,6 +2915,8 @@ window.__ModuleLoader__.load({
2444
2915
  state === 'loading' ? t('updChecking') : t('updCheck')),
2445
2916
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: notesState === 'loading', onClick: () => { void fetchNotes() } },
2446
2917
  notesState === 'loading' ? t('updFetchingNotes') : t('updReleaseNotes')),
2918
+ React.createElement('a', { className: 'sss-vr-link', href: REPO_URL, target: '_blank', rel: 'noopener noreferrer' },
2919
+ t('updRepo')),
2447
2920
  ]),
2448
2921
  notesState === 'loaded'
2449
2922
  ? React.createElement('div', { className: 'sss-vr-notes' }, notesBody)
@@ -2460,6 +2933,8 @@ window.__ModuleLoader__.load({
2460
2933
  : null,
2461
2934
  React.createElement('div', { className: 'sss-vr-actions' }, [
2462
2935
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void exportLogs() } }, t('logExport')),
2936
+ React.createElement('a', { className: 'sss-vr-link', href: ISSUES_URL, target: '_blank', rel: 'noopener noreferrer' },
2937
+ t('logIssues')),
2463
2938
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void copyLogs() } },
2464
2939
  logCopied ? t('logCopied') : t('logCopy')),
2465
2940
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: LOGS.length === 0, onClick: () => requestClear() },