@objectivex666/dsh-settings-search 1.10.0 → 1.11.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
@@ -22,7 +22,7 @@
22
22
  - 📂 **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.
23
23
  - 🔤 **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.
24
24
  - 🧠 **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.
25
- - 🤖 **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.
25
+ - 🤖 **AI-assisted search** — when nothing matches locally, your OpenAI-compatible, Anthropic, or local Ollama model understands your search intent and suggests settings. Off by default; configure it on the plugin's settings page. Ollama needs no API key.
26
26
  - 🧾 **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.
27
27
  - 🧭 **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.
28
28
  - 🌱 **Auto-index on startup (v1.8.0)** — the first time the settings panel opens, the plugin automatically visits every settings section, silently reads in-page options (e.g. "Enable Workshop card") into the search index, then restores your current section, so all section options are searchable without having to open each page manually. A "Importing section options…" hint shows under the search box and clears when done.
@@ -84,6 +84,22 @@ The package ships its own `cordis.patch.yml` (that exact content), which `dsh pl
84
84
 
85
85
  ---
86
86
 
87
+ ## Local Ollama (v1.11.0)
88
+
89
+ Enable "AI-assisted search" on the Settings Search page and select "Ollama (local)" as the API type:
90
+
91
+ - **Base URL**: defaults to `http://127.0.0.1:11434`; custom ports are supported. Accepts the root URL, `/api`, `/api/chat`, or `/v1`.
92
+ - **Model**: enter the full name, including its tag, of an installed model. Use `ollama list` to see installed models.
93
+ - **API key**: not required. Ollama requests never send an existing cloud API key.
94
+
95
+ Save the configuration and use "Test API" to check connectivity. Ollama requests allow 120 seconds for cold starts; cancelling a search aborts the request.
96
+
97
+ The plugin does not install or start Ollama, or download models. Prepare a running Ollama service and model yourself. Requests originate in the browser, so "local" means the browser's device, not a remote DSH server.
98
+
99
+ For connection failures, check the service URL. For cross-origin failures, add the exact DSH page origin (for example `http://127.0.0.1:3080`) to Ollama's `OLLAMA_ORIGINS` and restart Ollama. Avoid unnecessary wildcard origins. Non-local URLs still require HTTPS.
100
+
101
+ ---
102
+
87
103
  ## Package layout
88
104
 
89
105
  ```
@@ -91,12 +107,13 @@ lib/index.js Host half (no-op — makes the package a valid Cordis plugin
91
107
  lib/client.js Browser half (search UI, __ModuleLoader__ format)
92
108
  cordis.patch.yml Composition patch: inserts the settings-search plugin row
93
109
  scripts/check.mjs Structural self-checks (npm test)
110
+ scripts/ai.test.mjs Mock AI regression tests (no model execution)
94
111
  ```
95
112
 
96
113
  ## Development
97
114
 
98
115
  ```bash
99
- npm test # validates the host half, the dsh.client manifest, and the client bundle handoff
116
+ npm test # structural checks and mocked Ollama / OpenAI / Anthropic regression tests
100
117
  ```
101
118
 
102
119
  > Historical note: up to v1.1 the repo shipped `dsh-settings-search.js` (an
package/README.md CHANGED
@@ -22,7 +22,7 @@
22
22
  - 📂 **页内选项搜索(v1.2.0)**:不只搜页面,还能直达每个设置页内部的具体选项——插件页的标签页(`settings.plugins.tab`)、Web UI 插件的卡片(`web-ui.plugin.item`)、通用设置行等,结果以「页面 › 选项」面包屑展示。
23
23
  - 🔤 **拼音联想搜索(v1.5.0)**:输入拼音首字母(如 `sz`)或全拼(如 `shezhi`)即可联想中文设置项;支持 `↑`/`↓` 选择、`Enter` 跳转、`Esc` 关闭,命中的文字自动高亮。
24
24
  - 🧠 **意图联想搜索(v1.6.0)**:直接描述想做的事(如「太亮了」「字太小」「忘记密码」),无需知道选项名即可联想真实设置项,结果在「猜你想找」分组展示。
25
- - 🤖 **AI 联想搜索(v1.7.0)**:没有本地匹配时,用你配置的模型(OpenAI 兼容或 Anthropic:Base URL / 模型名 / API Key)理解搜索意图并推荐设置项。默认关闭,可在「设置搜索」页开启并配置。
25
+ - 🤖 **AI 联想搜索**:没有本地匹配时,用你配置的 OpenAI 兼容、Anthropic 或本机 Ollama 模型理解搜索意图并推荐设置项。默认关闭,可在「设置搜索」页开启并配置;Ollama 无需 API Key。
26
26
  - 🧾 **日志与导出(v1.7.0)**:记录插件运行、搜索与 AI 请求的关键动作,可在「设置搜索」页一键导出、复制或清空,便于排查问题。不会记录 API Key。
27
27
  - 🧭 **点击即达**:选中结果后自动点击对应的左侧导航项打开所在分区;若是页内标签页会继续点开对应标签,并高亮闪烁目标选项行。无法自动跳转时显示手动路径提示。
28
28
  - 🌱 **启动自动索引(v1.8.0)**:首次打开设置面板时自动遍历各设置分区,静默读取分区内选项(如「启用创意工坊卡片」)并导入搜索索引;此后分区选项无需逐页手动打开即可直接搜索,读取完成后自动恢复当前所在分区,导入过程搜索框下方会显示提示,完成后自动消失。
@@ -84,6 +84,21 @@ dsh plugin --profile web add /path/to/dsh-settings-search
84
84
 
85
85
  ---
86
86
 
87
+ ## 本机 Ollama(v1.11.0)
88
+
89
+ 在「设置搜索」页开启「AI 联想搜索」,将「接口类型」选为「Ollama(本机)」:
90
+
91
+ - **Base URL**:默认 `http://127.0.0.1:11434`,也可填写自定义端口;支持根地址、`/api`、`/api/chat` 和 `/v1`。
92
+ - **模型名**:填写本机已安装的完整模型名(包括标签),可用 `ollama list` 查看。
93
+ - **API Key**:无需填写,Ollama 请求不会携带原有云端密钥。
94
+
95
+ 保存后可用「测试接口」检查连接。本地模型首次加载可能较慢,Ollama 请求的超时时间为 120 秒;取消搜索会中止请求。
96
+
97
+ 插件不会安装或启动 Ollama,也不会下载模型。使用时需自行准备已运行的 Ollama 服务和模型。请求由浏览器发出,因此「本机」指浏览器所在设备,而非远程 DSH 服务器。
98
+
99
+ 连接被拒绝时检查服务地址;跨域访问失败时,将当前 DSH 页面的确切来源(如 `http://127.0.0.1:3080`)加入 Ollama 的 `OLLAMA_ORIGINS`,并重启 Ollama。不要使用不必要的通配符来源。非本机地址仍要求 HTTPS。
100
+
101
+ ---
87
102
 
88
103
 
89
104
  ## 包结构
@@ -93,12 +108,13 @@ lib/index.js Host half(空实现,保证作为合法 Cordis 插件行
93
108
  lib/client.js Browser half(搜索框 UI,__ModuleLoader__ 格式)
94
109
  cordis.patch.yml 组合补丁:插入 settings-search 插件行
95
110
  scripts/check.mjs 结构自检(npm test)
111
+ scripts/ai.test.mjs AI 接口回归测试(模拟请求,不运行模型)
96
112
  ```
97
113
 
98
114
  ## 开发
99
115
 
100
116
  ```bash
101
- npm test # 校验 host 半、dsh.client 清单、client bundle handoff
117
+ npm test # 结构检查及 Ollama / OpenAI / Anthropic 模拟接口回归测试
102
118
  ```
103
119
 
104
120
  > 历史说明:v1.1 及之前仓库曾附带 `dsh-settings-search.js`(会话内
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.10.0'
33
+ const PKG_VERSION = '1.11.0'
34
34
  const NPM_NAME = '@objectivex666/dsh-settings-search'
35
35
  const zh = {
36
36
  title: '设置',
@@ -72,16 +72,17 @@ window.__ModuleLoader__.load({
72
72
  updFetchingNotes: '正在获取…',
73
73
  updRestart: '更新完成后重启 DSH,新版本才会生效。',
74
74
  aiSearch: 'AI 联想搜索',
75
- aiDesc: '没有直接匹配时,用你配置的模型(OpenAI 兼容或 Anthropic)理解目标并推荐设置项。',
75
+ aiDesc: '没有直接匹配时,用你配置的模型(OpenAI 兼容、Anthropic 或本机 Ollama)理解目标并推荐设置项。',
76
76
  aiPrivacy: 'AI 请求会发送到你配置的模型服务;API Key 仅保存在本机浏览器。',
77
77
  aiOn: '已开启',
78
78
  aiOff: '已关闭',
79
79
  aiThinking: '正在用 AI 理解搜索意图…',
80
80
  aiUnavailable: 'AI 联想暂不可用',
81
- aiNeedConfig: '请先在「设置搜索」页配置 Base URL、模型名与 API Key。',
81
+ aiNeedConfig: '请先在「设置搜索」页配置 Base URL、模型名与 API Key(Ollama 无需密钥)。',
82
82
  aiProvider: '接口类型',
83
83
  aiOpenAI: 'OpenAI 兼容',
84
84
  aiAnthropic: 'Anthropic',
85
+ aiOllama: 'Ollama(本机)',
85
86
  aiBaseUrl: 'Base URL',
86
87
  aiBaseUrlHint: '仅支持 http(s) 地址;非本机地址必须使用 https,以免泄露 API Key。',
87
88
  aiModel: '模型名',
@@ -112,6 +113,9 @@ window.__ModuleLoader__.load({
112
113
  aiTestErrBadResponse: '响应格式异常,请确认接口类型与服务兼容',
113
114
  aiTestErrHttp: '请求失败(HTTP 状态码错误),请检查地址与配置',
114
115
  aiTestErrUnsafeUrl: '接口地址不安全:仅支持 http(s),非本机必须为 https,且不可包含账号密码',
116
+ aiOllamaErrNetwork: '无法连接 Ollama,请确认本机服务已启动、地址可达,且 OLLAMA_ORIGINS 允许当前 DSH 页面来源',
117
+ aiOllamaErrForbidden: 'Ollama 拒绝访问,请检查 OLLAMA_ORIGINS 是否允许当前 DSH 页面来源',
118
+ aiOllamaErrTimeout: 'Ollama 响应超时,请检查本机服务及模型加载状态',
115
119
  logTitle: '日志',
116
120
  logDesc: '记录插件运行、搜索与 AI 请求的关键动作,便于排查问题。',
117
121
  logEmpty: '暂无日志',
@@ -183,16 +187,17 @@ window.__ModuleLoader__.load({
183
187
  updFetchingNotes: 'Fetching…',
184
188
  updRestart: 'Restart DSH after updating for the new version to take effect.',
185
189
  aiSearch: 'AI-assisted search',
186
- aiDesc: 'When no direct match exists, your own model (OpenAI-compatible or Anthropic) understands your goal and suggests settings.',
190
+ aiDesc: 'When no direct match exists, your own model (OpenAI-compatible, Anthropic, or local Ollama) understands your goal and suggests settings.',
187
191
  aiPrivacy: 'AI requests are sent to the model service you configure; the API key stays only in your browser.',
188
192
  aiOn: 'On',
189
193
  aiOff: 'Off',
190
194
  aiThinking: 'Understanding your intent with AI…',
191
195
  aiUnavailable: 'AI suggestions are unavailable',
192
- aiNeedConfig: 'Configure Base URL, model, and API key in the Settings Search panel first.',
196
+ aiNeedConfig: 'Configure Base URL, model, and API key in the Settings Search panel first (Ollama needs no key).',
193
197
  aiProvider: 'API type',
194
198
  aiOpenAI: 'OpenAI-compatible',
195
199
  aiAnthropic: 'Anthropic',
200
+ aiOllama: 'Ollama (local)',
196
201
  aiBaseUrl: 'Base URL',
197
202
  aiBaseUrlHint: 'Only http(s) URLs are allowed; remote hosts must use https to avoid exposing your API key.',
198
203
  aiModel: 'Model',
@@ -223,6 +228,9 @@ window.__ModuleLoader__.load({
223
228
  aiTestErrBadResponse: 'Unexpected response — confirm the API type matches the service',
224
229
  aiTestErrHttp: 'Request failed (HTTP status error) — check the URL and config',
225
230
  aiTestErrUnsafeUrl: 'Unsafe URL — only http(s) is allowed, remote hosts must use https, and embedded credentials are not permitted',
231
+ aiOllamaErrNetwork: 'Cannot connect to Ollama. Check that the local service is running, the URL is reachable, and OLLAMA_ORIGINS allows this DSH page origin',
232
+ aiOllamaErrForbidden: 'Ollama denied access. Check that OLLAMA_ORIGINS allows this DSH page origin',
233
+ aiOllamaErrTimeout: 'Ollama timed out. Check the local service and model loading status',
226
234
  logTitle: 'Logs',
227
235
  logDesc: 'Logs key plugin actions, searches, and AI requests to help troubleshoot.',
228
236
  logEmpty: 'No logs yet',
@@ -463,6 +471,20 @@ window.__ModuleLoader__.load({
463
471
  const AI_BASE_URL_KEY = 'dsh-settings-search:ai-base-url'
464
472
  const AI_MODEL_KEY = 'dsh-settings-search:ai-model'
465
473
  const AI_API_KEY_KEY = 'dsh-settings-search:ai-api-key'
474
+ const OLLAMA_BASE_URL = 'http://127.0.0.1:11434'
475
+ const normalizeAiConfig = (cfg) => {
476
+ const provider = ['anthropic', 'ollama'].includes(cfg.provider) ? cfg.provider : 'openai'
477
+ return {
478
+ provider,
479
+ baseUrl: String(cfg.baseUrl ?? '').trim() || (provider === 'ollama' ? OLLAMA_BASE_URL : ''),
480
+ model: String(cfg.model ?? '').trim(),
481
+ apiKey: String(cfg.apiKey ?? '').trim(),
482
+ }
483
+ }
484
+ const aiConfigReady = (cfg) => {
485
+ const { provider, baseUrl, model, apiKey } = normalizeAiConfig(cfg)
486
+ return Boolean(baseUrl && model && (provider === 'ollama' || apiKey))
487
+ }
466
488
  /** Session-only API key storage avoids durable browser storage exposure. */
467
489
  const readApiKey = () => {
468
490
  const legacy = typeof localStorage !== 'undefined' ? (localStorage.getItem(AI_API_KEY_KEY) ?? '').trim() : ''
@@ -491,24 +513,23 @@ window.__ModuleLoader__.load({
491
513
  window.dispatchEvent(new CustomEvent(AI_TOGGLE_EVENT, { detail: enabled }))
492
514
  }
493
515
  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(),
516
+ const storage = typeof localStorage === 'undefined' ? null : localStorage
517
+ return normalizeAiConfig({
518
+ provider: (storage?.getItem(AI_PROVIDER_KEY) ?? 'openai').trim(),
519
+ baseUrl: storage?.getItem(AI_BASE_URL_KEY),
520
+ model: storage?.getItem(AI_MODEL_KEY),
502
521
  apiKey: readApiKey(),
503
- }
522
+ })
504
523
  }
505
524
  const setAiConfig = (cfg) => {
525
+ AI_CACHE.clear()
506
526
  if (typeof localStorage === 'undefined' && typeof sessionStorage === 'undefined') return
507
- writeApiKey(cfg.apiKey)
527
+ const normalized = normalizeAiConfig(cfg)
528
+ writeApiKey(normalized.apiKey)
508
529
  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 ?? '')
530
+ localStorage.setItem(AI_PROVIDER_KEY, normalized.provider)
531
+ localStorage.setItem(AI_BASE_URL_KEY, normalized.baseUrl)
532
+ localStorage.setItem(AI_MODEL_KEY, normalized.model)
512
533
  window.dispatchEvent(new CustomEvent(AI_CONFIG_EVENT))
513
534
  }
514
535
  /** First-run onboarding: remembered via localStorage so it shows once. */
@@ -599,62 +620,109 @@ window.__ModuleLoader__.load({
599
620
  if (/\/v1\/messages$/i.test(safe)) return safe
600
621
  return /\/v1$/i.test(safe) ? `${safe}/messages` : `${safe}/v1/messages`
601
622
  }
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) })
623
+ const ollamaChatUrl = (baseUrl) => {
624
+ const safe = safeBaseUrl(baseUrl)
625
+ if (!safe) return ''
626
+ if (/\/api\/chat$/i.test(safe)) return safe
627
+ if (/\/api$/i.test(safe)) return `${safe}/chat`
628
+ // Accept the base URL used by Ollama's OpenAI-compatible endpoint too.
629
+ return `${safe.replace(/\/v1(?:\/chat\/completions)?$/i, '')}/api/chat`
630
+ }
631
+ const aiRequest = (cfg, userContent, testing = false) => {
632
+ const { provider, baseUrl, model, apiKey } = normalizeAiConfig(cfg)
633
+ const messages = [{ role: 'user', content: userContent }]
634
+ const headers = { 'content-type': 'application/json' }
635
+ if (provider === 'anthropic') {
636
+ return {
637
+ url: anthropicMessagesUrl(baseUrl),
638
+ headers: { ...headers, 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true' },
639
+ body: { model, messages, max_tokens: testing ? 1 : 1024, ...(testing ? {} : { system: AI_PROMPT, temperature: 0 }) },
640
+ }
641
+ }
642
+ if (!testing) messages.unshift({ role: 'system', content: AI_PROMPT })
643
+ if (provider === 'ollama') {
644
+ return {
645
+ url: ollamaChatUrl(baseUrl),
646
+ headers,
647
+ body: {
648
+ model,
649
+ messages,
650
+ stream: false,
651
+ ...(testing ? {} : { format: 'json' }),
652
+ options: { temperature: 0, num_predict: testing ? 1 : 1024 },
653
+ },
654
+ }
655
+ }
656
+ return {
657
+ url: chatCompletionsUrl(baseUrl),
658
+ headers: { ...headers, authorization: `Bearer ${apiKey}` },
659
+ body: { model, messages, temperature: 0, ...(testing ? { max_tokens: 1 } : {}) },
660
+ }
661
+ }
662
+ const aiResponseContent = (provider, data) => {
663
+ if (provider === 'ollama') return data?.message?.content
664
+ if (provider === 'anthropic') {
665
+ if (!Array.isArray(data?.content) || data.content.length === 0) return undefined
666
+ return data.content.filter((block) => block?.type === 'text').map((block) => block.text).join('')
667
+ }
668
+ return data?.choices?.[0]?.message?.content
669
+ }
670
+ const fetchAiData = async (request, provider, signal) => {
671
+ const controller = new AbortController()
672
+ const abort = () => controller.abort()
673
+ if (signal?.aborted) abort()
674
+ else signal?.addEventListener('abort', abort, { once: true })
610
675
  let timer
611
676
  const deadline = new Promise((_, reject) => {
612
677
  timer = setTimeout(() => {
613
678
  const error = new Error('AI request timed out')
614
679
  error.name = 'AI_TIMEOUT'
615
680
  reject(error)
616
- }, 12000)
681
+ controller.abort()
682
+ }, provider === 'ollama' ? 120000 : 12000)
617
683
  })
618
684
  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,
633
- }
634
- : {
635
- model,
636
- messages: [
637
- { role: 'system', content: AI_PROMPT },
638
- { role: 'user', content: userContent },
639
- ],
640
- temperature: 0,
641
- }
642
- const response = await fetch(url, {
685
+ const response = await fetch(request.url, {
643
686
  method: 'POST',
644
- headers,
645
- signal,
687
+ headers: request.headers,
688
+ signal: controller.signal,
646
689
  redirect: 'error',
647
- body: JSON.stringify(body),
690
+ body: JSON.stringify(request.body),
691
+ })
692
+ if (!response.ok) {
693
+ const error = new Error(`HTTP ${response.status}`)
694
+ error.status = response.status
695
+ throw error
696
+ }
697
+ const data = await response.json().catch((error) => {
698
+ if (error.name === 'AbortError') throw error
699
+ return null
648
700
  })
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)
701
+ if (data?.error || typeof aiResponseContent(provider, data) !== 'string') {
702
+ const error = new Error('Unexpected AI response')
703
+ error.name = 'AI_BAD_RESPONSE'
704
+ throw error
705
+ }
706
+ return data
655
707
  })()
656
708
  try {
657
- const keywords = await Promise.race([work, deadline])
709
+ return await Promise.race([work, deadline])
710
+ } finally {
711
+ clearTimeout(timer)
712
+ signal?.removeEventListener('abort', abort)
713
+ }
714
+ }
715
+ const requestUserAi = async (query, labels, signal) => {
716
+ const cfg = aiConfig()
717
+ if (!aiConfigReady(cfg)) return []
718
+ const { provider, model } = cfg
719
+ const request = aiRequest(cfg, `用户目标:${query}\n设置索引:\n${labels}`)
720
+ if (!request.url) return []
721
+ const startedAt = Date.now()
722
+ log('info', 'AI 请求', { provider, model, query: String(query).slice(0, 50) })
723
+ try {
724
+ const data = await fetchAiData(request, provider, signal)
725
+ const keywords = extractAiKeywords(aiResponseContent(provider, data))
658
726
  log('info', 'AI 结果', { count: keywords.length, duration: Date.now() - startedAt })
659
727
  return keywords
660
728
  } catch (error) {
@@ -664,15 +732,14 @@ window.__ModuleLoader__.load({
664
732
  }
665
733
  log('error', 'AI 请求失败', { message: String(error?.message ?? error).slice(0, 160), duration: Date.now() - startedAt })
666
734
  throw error
667
- } finally {
668
- clearTimeout(timer)
669
735
  }
670
736
  }
671
737
  const requestAiKeywords = async (query, labels, signal) => {
672
- const cacheKey = query.toLowerCase()
738
+ const { provider, baseUrl, model } = aiConfig()
739
+ const cacheKey = JSON.stringify([provider, baseUrl, model, query.toLowerCase(), labels])
673
740
  if (AI_CACHE.has(cacheKey)) return AI_CACHE.get(cacheKey)
674
741
  const keywords = await requestUserAi(query, labels, signal)
675
- if (keywords.length > 0) {
742
+ if (!signal?.aborted && keywords.length > 0) {
676
743
  // bound the cache so a long session cannot grow it without limit
677
744
  if (AI_CACHE.size >= 100) AI_CACHE.clear()
678
745
  AI_CACHE.set(cacheKey, keywords)
@@ -698,68 +765,31 @@ window.__ModuleLoader__.load({
698
765
  const code = Number(status)
699
766
  return HTTP_TEST_CODES[code] ?? (code >= 500 && code <= 599 ? 'server' : 'http')
700
767
  }
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: '' }
768
+ const testAiService = async (cfg, signal) => {
769
+ cfg = normalizeAiConfig(cfg)
770
+ const { provider, model } = cfg
771
+ if (!aiConfigReady(cfg)) return { ok: false, code: 'need-config', detail: '' }
772
+ const request = aiRequest(cfg, 'ping', true)
773
+ if (!request.url) return { ok: false, code: 'unsafe-url', detail: '' }
710
774
  const startedAt = Date.now()
711
775
  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
776
  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
777
+ await fetchAiData(request, provider, signal)
778
+ log('info', '测试 API 成功', { duration: Date.now() - startedAt })
779
+ return { ok: true }
749
780
  } catch (error) {
750
781
  if (error.name === 'AbortError') {
751
782
  log('warn', '测试 API 取消', { duration: Date.now() - startedAt })
752
- throw error
783
+ return { ok: false, code: 'cancelled', detail: '' }
753
784
  }
754
785
  if (error.name === 'AI_TIMEOUT') {
755
786
  log('error', '测试 API 超时', { duration: Date.now() - startedAt })
756
787
  return { ok: false, code: 'timeout', detail: '' }
757
788
  }
789
+ const code = error.status ? testHttpCode(error.status) : error.name === 'AI_BAD_RESPONSE' ? 'bad-response' : 'network'
758
790
  const reason = String(error?.message ?? error).slice(0, 160)
759
791
  log('error', '测试 API 失败', { detail: reason, duration: Date.now() - startedAt })
760
- return { ok: false, code: 'network', detail: reason }
761
- } finally {
762
- clearTimeout(timer)
792
+ return { ok: false, code, detail: reason }
763
793
  }
764
794
  }
765
795
 
@@ -1407,7 +1437,9 @@ window.__ModuleLoader__.load({
1407
1437
  const baseUrl = String(providerProfile.baseURL ?? '').trim()
1408
1438
  if (!modelId || !baseUrl) return null
1409
1439
  return {
1410
- provider: protocol === 'anthropic-messages' ? 'anthropic' : 'openai',
1440
+ provider: providerId.toLowerCase() === 'ollama' || protocol === 'ollama'
1441
+ ? 'ollama'
1442
+ : protocol === 'anthropic-messages' ? 'anthropic' : 'openai',
1411
1443
  baseUrl,
1412
1444
  model: modelId,
1413
1445
  }
@@ -1659,12 +1691,12 @@ window.__ModuleLoader__.load({
1659
1691
  const results = [...shownPages, ...shownOptions, ...related.map((r) => r.item)]
1660
1692
 
1661
1693
  const cfg = aiConfig()
1662
- const aiConfigured = Boolean(cfg.baseUrl && cfg.model && cfg.apiKey)
1694
+ const aiConfigured = aiConfigReady(cfg)
1663
1695
  const aiReady = aiEnabled && aiConfigured && q.length >= 2 && noLocalMatch
1664
1696
  React.useEffect(() => {
1665
- if (!aiReady) return
1666
1697
  setAiKeywords([])
1667
1698
  setAiStatus('idle')
1699
+ if (!aiReady) return
1668
1700
  let alive = true
1669
1701
  const controller = new AbortController()
1670
1702
  const timer = setTimeout(() => {
@@ -1692,7 +1724,7 @@ window.__ModuleLoader__.load({
1692
1724
  clearTimeout(timer)
1693
1725
  controller.abort()
1694
1726
  }
1695
- }, [aiReady, aiEnabled, q])
1727
+ }, [aiReady, aiEnabled, q, cfg.provider, cfg.baseUrl, cfg.model, cfg.apiKey])
1696
1728
 
1697
1729
  const loggedQueryRef = React.useRef('')
1698
1730
  React.useEffect(() => {
@@ -2031,6 +2063,8 @@ window.__ModuleLoader__.load({
2031
2063
  const [baseUrl, setBaseUrl] = React.useState(cfg.baseUrl)
2032
2064
  const [model, setModel] = React.useState(cfg.model)
2033
2065
  const [apiKey, setApiKey] = React.useState(cfg.apiKey)
2066
+ const providerDrafts = React.useRef({})
2067
+ const testAbortRef = React.useRef(null)
2034
2068
  const [aiSaved, setAiSaved] = React.useState(false)
2035
2069
  const [testState, setTestState] = React.useState('idle') // idle | loading | ok | fail
2036
2070
  const [testMsg, setTestMsg] = React.useState('')
@@ -2065,6 +2099,13 @@ window.__ModuleLoader__.load({
2065
2099
  useAutoReset(copied, () => { setCopied(false); setCopyFailed(false) })
2066
2100
  useAutoReset(aiSaved, () => setAiSaved(false))
2067
2101
 
2102
+ React.useEffect(() => {
2103
+ setTestState('idle')
2104
+ setTestMsg('')
2105
+ setAiSaved(false)
2106
+ return () => testAbortRef.current?.abort()
2107
+ }, [provider, baseUrl, model, apiKey])
2108
+
2068
2109
  React.useEffect(() => {
2069
2110
  const sync = () => setLogVersion((v) => v + 1)
2070
2111
  window.addEventListener(LOG_EVENT, sync)
@@ -2075,13 +2116,22 @@ window.__ModuleLoader__.load({
2075
2116
  useAutoReset(logCleared, () => setLogCleared(false))
2076
2117
  useAutoReset(logExportFail, () => setLogExportFail(false))
2077
2118
 
2119
+ const changeProvider = (next) => {
2120
+ providerDrafts.current[provider] = { baseUrl, model }
2121
+ const draft = providerDrafts.current[next]
2122
+ setProvider(next)
2123
+ setBaseUrl(draft?.baseUrl ?? (next === 'ollama' ? OLLAMA_BASE_URL : ''))
2124
+ setModel(draft?.model ?? '')
2125
+ }
2126
+
2078
2127
  const saveAi = () => {
2079
- setAiConfig({
2128
+ const next = normalizeAiConfig({
2080
2129
  provider,
2081
- baseUrl: baseUrl.trim(),
2082
- model: model.trim(),
2083
- apiKey: apiKey.trim(),
2130
+ baseUrl,
2131
+ model,
2132
+ apiKey,
2084
2133
  })
2134
+ setAiConfig(next)
2085
2135
  setAiSaved(true)
2086
2136
  log('info', '保存 AI 配置', { provider, model: model.trim() })
2087
2137
  }
@@ -2093,6 +2143,7 @@ window.__ModuleLoader__.load({
2093
2143
  try {
2094
2144
  const imported = await readDshModelConfig()
2095
2145
  if (!imported) throw new Error(t('aiDshImportEmpty'))
2146
+ providerDrafts.current[provider] = { baseUrl, model }
2096
2147
  setAiConfig({ ...imported, apiKey })
2097
2148
  setProvider(imported.provider)
2098
2149
  setBaseUrl(imported.baseUrl)
@@ -2128,26 +2179,37 @@ window.__ModuleLoader__.load({
2128
2179
  setBaseUrl('')
2129
2180
  setModel('')
2130
2181
  setApiKey('')
2182
+ providerDrafts.current = {}
2131
2183
  setAiSaved(false)
2132
2184
  log('info', '清除 API 配置', {})
2133
2185
  }
2134
2186
 
2135
2187
  const testService = async () => {
2136
2188
  if (testState === 'loading') return
2137
- if (!baseUrl.trim() || !model.trim() || !apiKey.trim()) {
2189
+ if (!aiConfigReady({ provider, baseUrl, model, apiKey })) {
2138
2190
  setTestState('fail')
2139
2191
  setTestMsg(t('aiNeedConfig'))
2140
2192
  return
2141
2193
  }
2142
2194
  setTestState('loading')
2143
2195
  setTestMsg('')
2144
- const result = await testAiService({ provider, baseUrl, model, apiKey })
2196
+ const controller = new AbortController()
2197
+ testAbortRef.current?.abort()
2198
+ testAbortRef.current = controller
2199
+ const result = await testAiService({ provider, baseUrl, model, apiKey }, controller.signal)
2200
+ if (controller.signal.aborted) return
2201
+ if (testAbortRef.current === controller) testAbortRef.current = null
2145
2202
  if (result.ok) {
2146
2203
  setTestState('ok')
2147
2204
  setTestMsg(t('aiTestOk'))
2148
2205
  } else {
2149
2206
  setTestState('fail')
2150
- const hintKey = {
2207
+ const ollamaHintKey = provider === 'ollama' ? {
2208
+ network: 'aiOllamaErrNetwork',
2209
+ forbidden: 'aiOllamaErrForbidden',
2210
+ timeout: 'aiOllamaErrTimeout',
2211
+ }[result.code] : null
2212
+ const hintKey = ollamaHintKey ?? {
2151
2213
  auth: 'aiTestErrAuth',
2152
2214
  forbidden: 'aiTestErrForbidden',
2153
2215
  'not-found': 'aiTestErrNotFound',
@@ -2339,14 +2401,16 @@ window.__ModuleLoader__.load({
2339
2401
  React.createElement('select', {
2340
2402
  className: 'sss-vr-input sss-vr-select',
2341
2403
  name: 'dsh-settings-search-provider',
2404
+ 'aria-label': t('aiProvider'),
2342
2405
  value: provider,
2343
2406
  autoComplete: 'off',
2344
2407
  'data-lpignore': 'true',
2345
2408
  'data-form-type': 'other',
2346
- onChange: (event) => setProvider(event.target.value),
2409
+ onChange: (event) => changeProvider(event.target.value),
2347
2410
  }, [
2348
2411
  React.createElement('option', { value: 'openai' }, t('aiOpenAI')),
2349
2412
  React.createElement('option', { value: 'anthropic' }, t('aiAnthropic')),
2413
+ React.createElement('option', { value: 'ollama' }, t('aiOllama')),
2350
2414
  ]),
2351
2415
  React.createElement('div', { className: 'sss-vr-field-head' }, [
2352
2416
  React.createElement('label', { className: 'sss-vr-label' }, t('aiBaseUrl')),
@@ -2355,8 +2419,10 @@ window.__ModuleLoader__.load({
2355
2419
  className: 'sss-vr-input',
2356
2420
  type: 'url',
2357
2421
  name: 'dsh-settings-search-base-url',
2422
+ 'aria-label': t('aiBaseUrl'),
2358
2423
  value: baseUrl,
2359
- placeholder: provider === 'anthropic' ? 'https://api.anthropic.com' : 'https://api.openai.com/v1',
2424
+ placeholder: provider === 'ollama' ? OLLAMA_BASE_URL
2425
+ : provider === 'anthropic' ? 'https://api.anthropic.com' : 'https://api.openai.com/v1',
2360
2426
  spellCheck: false,
2361
2427
  autoComplete: 'off',
2362
2428
  'data-lpignore': 'true',
@@ -2372,8 +2438,10 @@ window.__ModuleLoader__.load({
2372
2438
  className: 'sss-vr-input',
2373
2439
  type: 'text',
2374
2440
  name: 'dsh-settings-search-model',
2441
+ 'aria-label': t('aiModel'),
2375
2442
  value: model,
2376
- placeholder: provider === 'anthropic' ? 'claude-sonnet-4-20250514' : 'gpt-4o-mini',
2443
+ placeholder: provider === 'ollama' ? 'qwen3:8b'
2444
+ : provider === 'anthropic' ? 'claude-sonnet-4-20250514' : 'gpt-4o-mini',
2377
2445
  spellCheck: false,
2378
2446
  autoComplete: 'off',
2379
2447
  'data-lpignore': 'true',
@@ -2381,13 +2449,14 @@ window.__ModuleLoader__.load({
2381
2449
  'data-form-type': 'other',
2382
2450
  onChange: (event) => setModel(event.target.value),
2383
2451
  }),
2384
- React.createElement('div', { className: 'sss-vr-field-head' }, [
2452
+ provider !== 'ollama' ? React.createElement('div', { className: 'sss-vr-field-head' }, [
2385
2453
  React.createElement('label', { className: 'sss-vr-label' }, t('aiApiKey')),
2386
- ]),
2387
- React.createElement('input', {
2454
+ ]) : null,
2455
+ provider !== 'ollama' ? React.createElement('input', {
2388
2456
  className: 'sss-vr-input sss-vr-mask',
2389
2457
  type: 'text',
2390
2458
  name: 'dsh-settings-search-api-key',
2459
+ 'aria-label': t('aiApiKey'),
2391
2460
  value: apiKey,
2392
2461
  placeholder: 'sk-…',
2393
2462
  spellCheck: false,
@@ -2396,7 +2465,7 @@ window.__ModuleLoader__.load({
2396
2465
  'data-1p-ignore': 'true',
2397
2466
  'data-form-type': 'other',
2398
2467
  onChange: (event) => setApiKey(event.target.value),
2399
- }),
2468
+ }) : null,
2400
2469
  React.createElement('div', { className: 'sss-vr-actions' }, [
2401
2470
  React.createElement('button', { className: 'sss-vr-btn sss-vr-primary', type: 'button', onClick: () => { void saveAi() } },
2402
2471
  aiSaved ? t('aiConfigSaved') : t('aiConfigSave')),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectivex666/dsh-settings-search",
3
- "version": "1.10.0",
3
+ "version": "1.11.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",
@@ -55,7 +55,7 @@
55
55
  "node": ">=18"
56
56
  },
57
57
  "scripts": {
58
- "test": "node scripts/check.mjs"
58
+ "test": "node scripts/check.mjs && node --test scripts/ai.test.mjs"
59
59
  },
60
60
  "publishConfig": {
61
61
  "access": "public"
@@ -0,0 +1,265 @@
1
+ import assert from 'node:assert/strict'
2
+ import { readFileSync } from 'node:fs'
3
+ import test from 'node:test'
4
+ import vm from 'node:vm'
5
+
6
+ const source = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
7
+ const hook = ' exports.apply = apply'
8
+ assert.equal(source.split(hook).length, 2)
9
+ // Expose closure helpers only in the VM; the shipped module has no test exports.
10
+ const instrumented = source.replace(hook, `
11
+ Object.assign(exports, {
12
+ aiConfig, setAiConfig, aiConfigReady, ollamaChatUrl,
13
+ requestUserAi, requestAiKeywords, testAiService, LOGS,
14
+ })
15
+ ${hook}`)
16
+ const storage = () => {
17
+ const values = new Map()
18
+ return {
19
+ getItem: (key) => values.get(key) ?? null,
20
+ setItem: (key, value) => values.set(key, String(value)),
21
+ removeItem: (key) => values.delete(key),
22
+ }
23
+ }
24
+ const json = (data, status = 200) => ({
25
+ ok: status >= 200 && status < 300,
26
+ status,
27
+ json: async () => data,
28
+ })
29
+ const plain = (value) => JSON.parse(JSON.stringify(value))
30
+ const ollama = { provider: 'ollama', baseUrl: '', model: 'local-model:7b', apiKey: '' }
31
+ const openai = { provider: 'openai', baseUrl: 'https://example.test/v1', model: 'cloud-model', apiKey: 'test-secret' }
32
+ const anthropic = { ...openai, provider: 'anthropic', baseUrl: 'https://example.test' }
33
+ const reply = (provider, content = '{"keywords":["Language"]}') => provider === 'ollama'
34
+ ? { message: { role: 'assistant', content }, done: true }
35
+ : provider === 'anthropic'
36
+ ? { content: [{ type: 'text', text: content }] }
37
+ : { choices: [{ message: { role: 'assistant', content } }] }
38
+
39
+ function load(fetchImpl = async () => json(reply('ollama'))) {
40
+ let handoff
41
+ const calls = []
42
+ const sandbox = {
43
+ window: { __ModuleLoader__: { load: (value) => { handoff = value } }, dispatchEvent() {} },
44
+ localStorage: storage(),
45
+ sessionStorage: storage(),
46
+ CustomEvent: class { constructor(type) { this.type = type } },
47
+ URL,
48
+ AbortController,
49
+ setTimeout,
50
+ clearTimeout,
51
+ fetch: async (url, options) => {
52
+ const call = { url, ...options, body: JSON.parse(options.body) }
53
+ calls.push(call)
54
+ return fetchImpl(call)
55
+ },
56
+ }
57
+ vm.runInNewContext(instrumented, sandbox, { filename: 'lib/client.js' })
58
+ const api = handoff.factory(() => ({}))
59
+ return { api, calls, sandbox }
60
+ }
61
+
62
+ test('Ollama configuration persists and requires a model but no key', () => {
63
+ const { api, sandbox } = load()
64
+ api.setAiConfig({ ...ollama, model: ' local-model:7b ' })
65
+ assert.deepEqual(plain(api.aiConfig()), { ...ollama, baseUrl: 'http://127.0.0.1:11434' })
66
+ assert.equal(api.aiConfigReady(api.aiConfig()), true)
67
+ assert.equal(api.aiConfigReady({ ...ollama, model: ' ' }), false)
68
+ assert.equal(sandbox.localStorage.getItem('dsh-settings-search:ai-provider'), 'ollama')
69
+ assert.equal(sandbox.sessionStorage.getItem('dsh-settings-search:ai-api-key'), null)
70
+ })
71
+
72
+ test('legacy providers still require keys and unknown provider falls back to OpenAI', () => {
73
+ const { api } = load()
74
+ for (const cfg of [openai, anthropic]) {
75
+ assert.equal(api.aiConfigReady(cfg), true)
76
+ assert.equal(api.aiConfigReady({ ...cfg, apiKey: ' ' }), false)
77
+ }
78
+ api.setAiConfig({ ...openai, provider: 'unknown' })
79
+ assert.equal(api.aiConfig().provider, 'openai')
80
+ })
81
+
82
+ test('Ollama accepts root, native, compatibility, proxy and IPv6 base URLs', () => {
83
+ const { api } = load()
84
+ for (const suffix of ['', '/', '/api', '/api/', '/api/chat', '/api/chat/', '/v1', '/v1/chat/completions']) {
85
+ assert.equal(api.ollamaChatUrl(`http://localhost:11434${suffix}`), 'http://localhost:11434/api/chat')
86
+ }
87
+ assert.equal(api.ollamaChatUrl('http://[::1]:11434/'), 'http://[::1]:11434/api/chat')
88
+ assert.equal(api.ollamaChatUrl('https://example.test/ollama/v1/'), 'https://example.test/ollama/api/chat')
89
+ })
90
+
91
+ test('Ollama search uses native non-streaming JSON and never sends a stored cloud key', async () => {
92
+ const { api, calls, sandbox } = load(async () => json(reply('ollama', '{"keywords":["Language","Language"," Theme ",42]}')))
93
+ api.setAiConfig({ ...ollama, apiKey: 'test-secret' })
94
+ const keywords = await api.requestUserAi('unmatched intent', 'Language\nTheme')
95
+ assert.deepEqual(plain(keywords), ['Language', 'Theme'])
96
+ const [call] = calls
97
+ assert.equal(call.url, 'http://127.0.0.1:11434/api/chat')
98
+ assert.equal(call.method, 'POST')
99
+ assert.equal(call.redirect, 'error')
100
+ assert.deepEqual(plain(call.headers), { 'content-type': 'application/json' })
101
+ assert.equal(call.body.model, ollama.model)
102
+ assert.equal(call.body.stream, false)
103
+ assert.equal(call.body.format, 'json')
104
+ assert.equal(call.body.options.temperature, 0)
105
+ assert.equal(call.body.options.num_predict, 1024)
106
+ assert.equal(call.body.messages[0].role, 'system')
107
+ assert.match(call.body.messages[1].content, /unmatched intent/)
108
+ assert.match(call.body.messages[1].content, /Language\nTheme/)
109
+ assert.equal(JSON.stringify(call.body).includes('test-secret'), false)
110
+ assert.equal(JSON.stringify(api.LOGS).includes('test-secret'), false)
111
+ assert.equal(sandbox.localStorage.getItem('dsh-settings-search:ai-api-key'), null)
112
+ })
113
+
114
+ test('Ollama connectivity test works without an API key and limits generation', async () => {
115
+ const { api, calls } = load()
116
+ assert.deepEqual(plain(await api.testAiService(ollama)), { ok: true })
117
+ assert.deepEqual(plain(calls[0].body), {
118
+ model: ollama.model,
119
+ messages: [{ role: 'user', content: 'ping' }],
120
+ stream: false,
121
+ options: { temperature: 0, num_predict: 1 },
122
+ })
123
+ assert.equal(calls[0].headers.authorization, undefined)
124
+ })
125
+
126
+ for (const cfg of [openai, anthropic]) {
127
+ test(`${cfg.provider} search and test preserve protocol, authentication and redirect protection`, async () => {
128
+ const { api, calls } = load(async () => json(reply(cfg.provider)))
129
+ api.setAiConfig(cfg)
130
+ assert.deepEqual(plain(await api.requestUserAi('intent', 'Language')), ['Language'])
131
+ assert.deepEqual(plain(await api.testAiService(cfg)), { ok: true })
132
+ for (const call of calls) {
133
+ assert.equal(call.redirect, 'error')
134
+ assert.equal(call.body.model, cfg.model)
135
+ assert.equal(call.body.stream, undefined)
136
+ assert.equal(call.body.options, undefined)
137
+ if (cfg.provider === 'anthropic') {
138
+ assert.equal(call.url, 'https://example.test/v1/messages')
139
+ assert.equal(call.headers['x-api-key'], cfg.apiKey)
140
+ assert.equal(call.headers['anthropic-version'], '2023-06-01')
141
+ assert.equal(call.headers['anthropic-dangerous-direct-browser-access'], 'true')
142
+ assert.equal(call.headers.authorization, undefined)
143
+ } else {
144
+ assert.equal(call.url, 'https://example.test/v1/chat/completions')
145
+ assert.equal(call.headers.authorization, `Bearer ${cfg.apiKey}`)
146
+ }
147
+ }
148
+ assert.equal(calls[1].body.max_tokens, 1)
149
+ assert.equal(calls[1].body.messages.length, 1)
150
+ if (cfg.provider === 'anthropic') assert.equal(typeof calls[0].body.system, 'string')
151
+ else assert.equal(calls[0].body.messages[0].role, 'system')
152
+ })
153
+ }
154
+
155
+ test('incomplete configuration never sends requests', async () => {
156
+ const { api, calls } = load()
157
+ for (const cfg of [{ ...ollama, model: '' }, { ...openai, apiKey: '' }, { ...anthropic, apiKey: '' }]) {
158
+ api.setAiConfig(cfg)
159
+ assert.deepEqual(plain(await api.requestUserAi('intent', 'Language')), [])
160
+ assert.equal((await api.testAiService(cfg)).code, 'need-config')
161
+ }
162
+ assert.equal(calls.length, 0)
163
+ })
164
+
165
+ test('unsafe URLs are rejected before search and connectivity requests', async () => {
166
+ const { api, calls } = load()
167
+ for (const baseUrl of ['javascript:alert(1)', 'file:///tmp/model', 'http://example.test', 'https://user:pass@example.test']) {
168
+ const cfg = { ...ollama, baseUrl }
169
+ api.setAiConfig(cfg)
170
+ assert.deepEqual(plain(await api.requestUserAi('intent', 'Language')), [])
171
+ assert.equal((await api.testAiService(cfg)).code, 'unsafe-url')
172
+ }
173
+ assert.equal(calls.length, 0)
174
+ })
175
+
176
+ for (const [status, code] of [[400, 'bad-request'], [401, 'auth'], [403, 'forbidden'], [404, 'not-found'], [429, 'rate-limit'], [500, 'server']]) {
177
+ test(`Ollama HTTP ${status} produces ${code}`, async () => {
178
+ const { api } = load(async () => json({ error: 'test error' }, status))
179
+ assert.equal((await api.testAiService(ollama)).code, code)
180
+ api.setAiConfig(ollama)
181
+ await assert.rejects(api.requestUserAi('intent', 'Language'), { message: `HTTP ${status}` })
182
+ })
183
+ }
184
+
185
+ test('malformed responses and native error payloads cannot pass connectivity checks', async () => {
186
+ for (const data of [null, {}, { message: {} }, { message: { content: 123 } }, { error: 'bad model', ...reply('ollama') }]) {
187
+ const { api } = load(async () => json(data))
188
+ assert.equal((await api.testAiService(ollama)).code, 'bad-response')
189
+ }
190
+ const { api } = load(async () => ({ ok: true, json: async () => { throw new SyntaxError('invalid JSON') } }))
191
+ assert.equal((await api.testAiService(ollama)).code, 'bad-response')
192
+ })
193
+
194
+ test('network failure is reported without claiming connection success', async () => {
195
+ const { api } = load(async () => { throw new TypeError('Failed to fetch') })
196
+ assert.equal((await api.testAiService(ollama)).code, 'network')
197
+ })
198
+
199
+ const abortedFetch = ({ signal }) => new Promise((_, reject) => {
200
+ const abort = () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))
201
+ if (signal.aborted) abort()
202
+ else signal.addEventListener('abort', abort, { once: true })
203
+ })
204
+
205
+ for (const cfg of [ollama, openai, anthropic]) {
206
+ test(`${cfg.provider} timeout aborts the request and uses its provider deadline`, async () => {
207
+ const { api, sandbox, calls } = load(abortedFetch)
208
+ let expire
209
+ let cleared = false
210
+ sandbox.setTimeout = (callback, ms) => {
211
+ assert.equal(ms, cfg.provider === 'ollama' ? 120000 : 12000)
212
+ expire = callback
213
+ return 1
214
+ }
215
+ sandbox.clearTimeout = () => { cleared = true }
216
+ const result = api.testAiService(cfg)
217
+ expire()
218
+ assert.equal((await result).code, 'timeout')
219
+ assert.equal(calls[0].signal.aborted, true)
220
+ assert.equal(cleared, true)
221
+ })
222
+ }
223
+
224
+ test('search and connectivity propagate cancellation, including pre-aborted signals', async () => {
225
+ const { api, calls } = load(abortedFetch)
226
+ api.setAiConfig(ollama)
227
+ const controller = new AbortController()
228
+ const search = api.requestUserAi('intent', 'Language', controller.signal)
229
+ controller.abort()
230
+ await assert.rejects(search, { name: 'AbortError' })
231
+ assert.equal(calls[0].signal.aborted, true)
232
+ assert.equal((await api.testAiService(ollama, controller.signal)).code, 'cancelled')
233
+ })
234
+
235
+ test('a successful request clears its timer and detaches the caller abort listener', async () => {
236
+ const { api, calls, sandbox } = load()
237
+ const cleared = []
238
+ sandbox.setTimeout = () => 7
239
+ sandbox.clearTimeout = (timer) => cleared.push(timer)
240
+ const controller = new AbortController()
241
+ api.setAiConfig(ollama)
242
+ await api.requestUserAi('intent', 'Language', controller.signal)
243
+ controller.abort()
244
+ assert.equal(calls[0].signal.aborted, false)
245
+ assert.deepEqual(cleared, [7])
246
+ })
247
+
248
+ test('cache respects settings labels and is invalidated when provider configuration changes', async () => {
249
+ let provider = 'ollama'
250
+ const { api, calls } = load(async () => json(reply(provider)))
251
+ api.setAiConfig(ollama)
252
+ await api.requestAiKeywords('intent', 'Language')
253
+ await api.requestAiKeywords('intent', 'Language')
254
+ assert.equal(calls.length, 1)
255
+ await api.requestAiKeywords('intent', 'Theme')
256
+ assert.equal(calls.length, 2)
257
+ api.setAiConfig({ ...ollama, model: 'another-model' })
258
+ await api.requestAiKeywords('intent', 'Language')
259
+ assert.equal(calls.length, 3)
260
+ provider = 'openai'
261
+ api.setAiConfig(openai)
262
+ await api.requestAiKeywords('intent', 'Language')
263
+ assert.equal(calls.length, 4)
264
+ assert.equal(calls[3].headers.authorization, `Bearer ${openai.apiKey}`)
265
+ })