@objectivex666/dsh-settings-search 1.9.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 +22 -3
- package/README.md +21 -3
- package/lib/client.js +293 -184
- package/package.json +2 -2
- package/scripts/ai.test.mjs +265 -0
package/README.en.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
[](./LICENSE)
|
|
7
7
|
[](https://nodejs.org)
|
|
8
8
|
[](https://awesome-dsh-plugin.com)
|
|
9
|
+
[](https://dsh-plugin.org/plugins/objectivex666/dsh-settings-search)
|
|
9
10
|
|
|
10
11
|
**English** · [中文](./README.md)
|
|
11
12
|
|
|
@@ -14,13 +15,14 @@
|
|
|
14
15
|
## Features
|
|
15
16
|
|
|
16
17
|
- 🔍 **Live search** — a search box in the settings panel filters every registered settings page (`settings.section`) and general item (`settings.general.item`) as you type.
|
|
17
|
-
- 🧩 **
|
|
18
|
+
- 🧩 **Three search styles** — choose the classic sidebar, window top bar, or popup search page. The choice is saved in the current browser.
|
|
19
|
+
- ⌨️ **Search shortcut (v1.10.0)** — focus the search box with `Ctrl+F` or `⌘F` inside the settings panel. You can enable or disable it from the "Settings Search" page; disabling keeps the browser's default find behavior.
|
|
18
20
|
- 🤝 **Import DSH model config (v1.9.0)** — import the Base URL and model name from DSH's current default model in the AI config section. For credential safety, the API key is never imported.
|
|
19
21
|
- 🎯 **First-run onboarding (v1.9.0)** — a welcome modal appears only the first time the settings panel opens after installation. It introduces the four search styles, pinyin/intent/AI hints, click-to-jump, and auto-indexing; there are no "Don't show again" or manual reopen buttons.
|
|
20
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.
|
|
21
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.
|
|
22
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.
|
|
23
|
-
- 🤖 **AI-assisted search
|
|
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.
|
|
24
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.
|
|
25
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.
|
|
26
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.
|
|
@@ -82,6 +84,22 @@ The package ships its own `cordis.patch.yml` (that exact content), which `dsh pl
|
|
|
82
84
|
|
|
83
85
|
---
|
|
84
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
|
+
|
|
85
103
|
## Package layout
|
|
86
104
|
|
|
87
105
|
```
|
|
@@ -89,12 +107,13 @@ lib/index.js Host half (no-op — makes the package a valid Cordis plugin
|
|
|
89
107
|
lib/client.js Browser half (search UI, __ModuleLoader__ format)
|
|
90
108
|
cordis.patch.yml Composition patch: inserts the settings-search plugin row
|
|
91
109
|
scripts/check.mjs Structural self-checks (npm test)
|
|
110
|
+
scripts/ai.test.mjs Mock AI regression tests (no model execution)
|
|
92
111
|
```
|
|
93
112
|
|
|
94
113
|
## Development
|
|
95
114
|
|
|
96
115
|
```bash
|
|
97
|
-
npm test #
|
|
116
|
+
npm test # structural checks and mocked Ollama / OpenAI / Anthropic regression tests
|
|
98
117
|
```
|
|
99
118
|
|
|
100
119
|
> Historical note: up to v1.1 the repo shipped `dsh-settings-search.js` (an
|
package/README.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
[](./LICENSE)
|
|
7
7
|
[](https://nodejs.org)
|
|
8
8
|
[](https://awesome-dsh-plugin.com)
|
|
9
|
+
[](https://dsh-plugin.org/plugins/objectivex666/dsh-settings-search)
|
|
9
10
|
|
|
10
11
|
**中文** · [English](./README.en.md)
|
|
11
12
|
|
|
@@ -14,13 +15,14 @@
|
|
|
14
15
|
## 功能特性
|
|
15
16
|
|
|
16
17
|
- 🔍 **实时搜索**:在设置面板中嵌入搜索框,输入关键词立即过滤所有已注册的设置页(`settings.section`)与通用设置项(`settings.general.item`)。
|
|
17
|
-
- 🧩
|
|
18
|
+
- 🧩 **三种搜索样式**:可选择经典侧栏、窗口顶栏或弹出搜索页;选择会保存在当前浏览器。
|
|
19
|
+
- ⌨️ **搜索快捷键(v1.10.0)**:在设置面板内按 `Ctrl+F` 或 `⌘F` 聚焦搜索框;可在「设置搜索」页启用或关闭,关闭后保留浏览器默认查找。
|
|
18
20
|
- 🤝 **导入 DSH 模型配置(v1.9.0)**:在 AI 配置区一键读取 DSH 当前默认模型的 Base URL 与模型名;为避免凭据扩散,导入始终不包含 API Key。
|
|
19
21
|
- 🎯 **新用户引导(v1.9.0)**:仅在安装后首次打开设置面板时弹出引导弹窗,介绍四种搜索样式、拼音/意图/AI 联想、点击即达与自动导入索引等核心功能;不再提供“不再显示”和手动重开按钮。
|
|
20
22
|
- 📂 **页内选项搜索(v1.2.0)**:不只搜页面,还能直达每个设置页内部的具体选项——插件页的标签页(`settings.plugins.tab`)、Web UI 插件的卡片(`web-ui.plugin.item`)、通用设置行等,结果以「页面 › 选项」面包屑展示。
|
|
21
23
|
- 🔤 **拼音联想搜索(v1.5.0)**:输入拼音首字母(如 `sz`)或全拼(如 `shezhi`)即可联想中文设置项;支持 `↑`/`↓` 选择、`Enter` 跳转、`Esc` 关闭,命中的文字自动高亮。
|
|
22
24
|
- 🧠 **意图联想搜索(v1.6.0)**:直接描述想做的事(如「太亮了」「字太小」「忘记密码」),无需知道选项名即可联想真实设置项,结果在「猜你想找」分组展示。
|
|
23
|
-
- 🤖 **AI
|
|
25
|
+
- 🤖 **AI 联想搜索**:没有本地匹配时,用你配置的 OpenAI 兼容、Anthropic 或本机 Ollama 模型理解搜索意图并推荐设置项。默认关闭,可在「设置搜索」页开启并配置;Ollama 无需 API Key。
|
|
24
26
|
- 🧾 **日志与导出(v1.7.0)**:记录插件运行、搜索与 AI 请求的关键动作,可在「设置搜索」页一键导出、复制或清空,便于排查问题。不会记录 API Key。
|
|
25
27
|
- 🧭 **点击即达**:选中结果后自动点击对应的左侧导航项打开所在分区;若是页内标签页会继续点开对应标签,并高亮闪烁目标选项行。无法自动跳转时显示手动路径提示。
|
|
26
28
|
- 🌱 **启动自动索引(v1.8.0)**:首次打开设置面板时自动遍历各设置分区,静默读取分区内选项(如「启用创意工坊卡片」)并导入搜索索引;此后分区选项无需逐页手动打开即可直接搜索,读取完成后自动恢复当前所在分区,导入过程搜索框下方会显示提示,完成后自动消失。
|
|
@@ -82,6 +84,21 @@ dsh plugin --profile web add /path/to/dsh-settings-search
|
|
|
82
84
|
|
|
83
85
|
---
|
|
84
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
|
+
---
|
|
85
102
|
|
|
86
103
|
|
|
87
104
|
## 包结构
|
|
@@ -91,12 +108,13 @@ lib/index.js Host half(空实现,保证作为合法 Cordis 插件行
|
|
|
91
108
|
lib/client.js Browser half(搜索框 UI,__ModuleLoader__ 格式)
|
|
92
109
|
cordis.patch.yml 组合补丁:插入 settings-search 插件行
|
|
93
110
|
scripts/check.mjs 结构自检(npm test)
|
|
111
|
+
scripts/ai.test.mjs AI 接口回归测试(模拟请求,不运行模型)
|
|
94
112
|
```
|
|
95
113
|
|
|
96
114
|
## 开发
|
|
97
115
|
|
|
98
116
|
```bash
|
|
99
|
-
npm test #
|
|
117
|
+
npm test # 结构检查及 Ollama / OpenAI / Anthropic 模拟接口回归测试
|
|
100
118
|
```
|
|
101
119
|
|
|
102
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.
|
|
33
|
+
const PKG_VERSION = '1.11.0'
|
|
34
34
|
const NPM_NAME = '@objectivex666/dsh-settings-search'
|
|
35
35
|
const zh = {
|
|
36
36
|
title: '设置',
|
|
@@ -41,8 +41,9 @@ window.__ModuleLoader__.load({
|
|
|
41
41
|
styleClassic: '经典侧栏',
|
|
42
42
|
styleTop: '窗口顶栏',
|
|
43
43
|
stylePage: '弹出搜索页',
|
|
44
|
-
styleMixed: '顶栏 + 搜索页',
|
|
45
44
|
openSearch: '打开搜索',
|
|
45
|
+
shortcutTitle: 'Ctrl+F / ⌘F 快捷键',
|
|
46
|
+
shortcutHint: '在设置面板内按 Ctrl+F 或 ⌘F 聚焦搜索框;关闭后保留浏览器默认查找。',
|
|
46
47
|
warming: '正在导入各分区选项…',
|
|
47
48
|
empty: '没有匹配的设置',
|
|
48
49
|
pages: '设置页',
|
|
@@ -71,16 +72,17 @@ window.__ModuleLoader__.load({
|
|
|
71
72
|
updFetchingNotes: '正在获取…',
|
|
72
73
|
updRestart: '更新完成后重启 DSH,新版本才会生效。',
|
|
73
74
|
aiSearch: 'AI 联想搜索',
|
|
74
|
-
aiDesc: '没有直接匹配时,用你配置的模型(OpenAI
|
|
75
|
+
aiDesc: '没有直接匹配时,用你配置的模型(OpenAI 兼容、Anthropic 或本机 Ollama)理解目标并推荐设置项。',
|
|
75
76
|
aiPrivacy: 'AI 请求会发送到你配置的模型服务;API Key 仅保存在本机浏览器。',
|
|
76
77
|
aiOn: '已开启',
|
|
77
78
|
aiOff: '已关闭',
|
|
78
79
|
aiThinking: '正在用 AI 理解搜索意图…',
|
|
79
80
|
aiUnavailable: 'AI 联想暂不可用',
|
|
80
|
-
aiNeedConfig: '请先在「设置搜索」页配置 Base URL、模型名与 API Key
|
|
81
|
+
aiNeedConfig: '请先在「设置搜索」页配置 Base URL、模型名与 API Key(Ollama 无需密钥)。',
|
|
81
82
|
aiProvider: '接口类型',
|
|
82
83
|
aiOpenAI: 'OpenAI 兼容',
|
|
83
84
|
aiAnthropic: 'Anthropic',
|
|
85
|
+
aiOllama: 'Ollama(本机)',
|
|
84
86
|
aiBaseUrl: 'Base URL',
|
|
85
87
|
aiBaseUrlHint: '仅支持 http(s) 地址;非本机地址必须使用 https,以免泄露 API Key。',
|
|
86
88
|
aiModel: '模型名',
|
|
@@ -111,6 +113,9 @@ window.__ModuleLoader__.load({
|
|
|
111
113
|
aiTestErrBadResponse: '响应格式异常,请确认接口类型与服务兼容',
|
|
112
114
|
aiTestErrHttp: '请求失败(HTTP 状态码错误),请检查地址与配置',
|
|
113
115
|
aiTestErrUnsafeUrl: '接口地址不安全:仅支持 http(s),非本机必须为 https,且不可包含账号密码',
|
|
116
|
+
aiOllamaErrNetwork: '无法连接 Ollama,请确认本机服务已启动、地址可达,且 OLLAMA_ORIGINS 允许当前 DSH 页面来源',
|
|
117
|
+
aiOllamaErrForbidden: 'Ollama 拒绝访问,请检查 OLLAMA_ORIGINS 是否允许当前 DSH 页面来源',
|
|
118
|
+
aiOllamaErrTimeout: 'Ollama 响应超时,请检查本机服务及模型加载状态',
|
|
114
119
|
logTitle: '日志',
|
|
115
120
|
logDesc: '记录插件运行、搜索与 AI 请求的关键动作,便于排查问题。',
|
|
116
121
|
logEmpty: '暂无日志',
|
|
@@ -128,7 +133,7 @@ window.__ModuleLoader__.load({
|
|
|
128
133
|
wlTitle: '欢迎使用设置搜索',
|
|
129
134
|
wlSubtitle: '为 DSH 设置面板提供即时搜索与导航,几秒钟就能找到任何设置。',
|
|
130
135
|
wlFeature1Title: '实时搜索',
|
|
131
|
-
wlFeature1Desc: '
|
|
136
|
+
wlFeature1Desc: '支持经典侧栏、窗口顶栏与弹出搜索页;输入关键词即可过滤设置。',
|
|
132
137
|
wlFeature2Title: '拼音与意图联想',
|
|
133
138
|
wlFeature2Desc: '输入拼音首字母(如 sz)或直接描述需求(如「太亮」),也能联想设置项。',
|
|
134
139
|
wlFeature3Title: 'AI 联想(可选)',
|
|
@@ -151,8 +156,9 @@ window.__ModuleLoader__.load({
|
|
|
151
156
|
styleClassic: 'Classic sidebar',
|
|
152
157
|
styleTop: 'Top bar',
|
|
153
158
|
stylePage: 'Popup page',
|
|
154
|
-
styleMixed: 'Top bar + popup',
|
|
155
159
|
openSearch: 'Open search',
|
|
160
|
+
shortcutTitle: 'Ctrl+F / ⌘F shortcut',
|
|
161
|
+
shortcutHint: 'Focus the search box with Ctrl+F or ⌘F inside the settings panel; turn it off to keep the browser default.',
|
|
156
162
|
warming: 'Importing section options…',
|
|
157
163
|
empty: 'No matching settings',
|
|
158
164
|
pages: 'Settings pages',
|
|
@@ -181,16 +187,17 @@ window.__ModuleLoader__.load({
|
|
|
181
187
|
updFetchingNotes: 'Fetching…',
|
|
182
188
|
updRestart: 'Restart DSH after updating for the new version to take effect.',
|
|
183
189
|
aiSearch: 'AI-assisted search',
|
|
184
|
-
aiDesc: 'When no direct match exists, your own model (OpenAI-compatible or
|
|
190
|
+
aiDesc: 'When no direct match exists, your own model (OpenAI-compatible, Anthropic, or local Ollama) understands your goal and suggests settings.',
|
|
185
191
|
aiPrivacy: 'AI requests are sent to the model service you configure; the API key stays only in your browser.',
|
|
186
192
|
aiOn: 'On',
|
|
187
193
|
aiOff: 'Off',
|
|
188
194
|
aiThinking: 'Understanding your intent with AI…',
|
|
189
195
|
aiUnavailable: 'AI suggestions are unavailable',
|
|
190
|
-
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).',
|
|
191
197
|
aiProvider: 'API type',
|
|
192
198
|
aiOpenAI: 'OpenAI-compatible',
|
|
193
199
|
aiAnthropic: 'Anthropic',
|
|
200
|
+
aiOllama: 'Ollama (local)',
|
|
194
201
|
aiBaseUrl: 'Base URL',
|
|
195
202
|
aiBaseUrlHint: 'Only http(s) URLs are allowed; remote hosts must use https to avoid exposing your API key.',
|
|
196
203
|
aiModel: 'Model',
|
|
@@ -221,6 +228,9 @@ window.__ModuleLoader__.load({
|
|
|
221
228
|
aiTestErrBadResponse: 'Unexpected response — confirm the API type matches the service',
|
|
222
229
|
aiTestErrHttp: 'Request failed (HTTP status error) — check the URL and config',
|
|
223
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',
|
|
224
234
|
logTitle: 'Logs',
|
|
225
235
|
logDesc: 'Logs key plugin actions, searches, and AI requests to help troubleshoot.',
|
|
226
236
|
logEmpty: 'No logs yet',
|
|
@@ -238,7 +248,7 @@ window.__ModuleLoader__.load({
|
|
|
238
248
|
wlTitle: 'Welcome to Settings Search',
|
|
239
249
|
wlSubtitle: 'Instant search and navigation for the DSH settings panel — find any setting in seconds.',
|
|
240
250
|
wlFeature1Title: 'Instant search',
|
|
241
|
-
wlFeature1Desc: 'Choose classic sidebar, top bar,
|
|
251
|
+
wlFeature1Desc: 'Choose classic sidebar, top bar, or popup search page; typing filters settings.',
|
|
242
252
|
wlFeature2Title: 'Pinyin & intent hints',
|
|
243
253
|
wlFeature2Desc: 'Type pinyin initials (e.g. sz) or describe what you want to do to find settings.',
|
|
244
254
|
wlFeature3Title: 'AI hints (optional)',
|
|
@@ -461,6 +471,20 @@ window.__ModuleLoader__.load({
|
|
|
461
471
|
const AI_BASE_URL_KEY = 'dsh-settings-search:ai-base-url'
|
|
462
472
|
const AI_MODEL_KEY = 'dsh-settings-search:ai-model'
|
|
463
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
|
+
}
|
|
464
488
|
/** Session-only API key storage avoids durable browser storage exposure. */
|
|
465
489
|
const readApiKey = () => {
|
|
466
490
|
const legacy = typeof localStorage !== 'undefined' ? (localStorage.getItem(AI_API_KEY_KEY) ?? '').trim() : ''
|
|
@@ -489,24 +513,23 @@ window.__ModuleLoader__.load({
|
|
|
489
513
|
window.dispatchEvent(new CustomEvent(AI_TOGGLE_EVENT, { detail: enabled }))
|
|
490
514
|
}
|
|
491
515
|
const aiConfig = () => {
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
provider: providerRaw === 'anthropic' ? 'anthropic' : 'openai',
|
|
498
|
-
baseUrl: (localStorage?.getItem(AI_BASE_URL_KEY) ?? '').trim(),
|
|
499
|
-
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),
|
|
500
521
|
apiKey: readApiKey(),
|
|
501
|
-
}
|
|
522
|
+
})
|
|
502
523
|
}
|
|
503
524
|
const setAiConfig = (cfg) => {
|
|
525
|
+
AI_CACHE.clear()
|
|
504
526
|
if (typeof localStorage === 'undefined' && typeof sessionStorage === 'undefined') return
|
|
505
|
-
|
|
527
|
+
const normalized = normalizeAiConfig(cfg)
|
|
528
|
+
writeApiKey(normalized.apiKey)
|
|
506
529
|
if (typeof localStorage === 'undefined') return
|
|
507
|
-
localStorage.setItem(AI_PROVIDER_KEY,
|
|
508
|
-
localStorage.setItem(AI_BASE_URL_KEY,
|
|
509
|
-
localStorage.setItem(AI_MODEL_KEY,
|
|
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)
|
|
510
533
|
window.dispatchEvent(new CustomEvent(AI_CONFIG_EVENT))
|
|
511
534
|
}
|
|
512
535
|
/** First-run onboarding: remembered via localStorage so it shows once. */
|
|
@@ -514,7 +537,9 @@ window.__ModuleLoader__.load({
|
|
|
514
537
|
const WELCOME_EVENT = 'dsh-settings-search:welcome'
|
|
515
538
|
const SEARCH_STYLE_KEY = 'dsh-settings-search:style'
|
|
516
539
|
const SEARCH_STYLE_EVENT = 'dsh-settings-search:style-change'
|
|
517
|
-
const
|
|
540
|
+
const SEARCH_SHORTCUT_KEY = 'dsh-settings-search:shortcut-enabled'
|
|
541
|
+
const SEARCH_SHORTCUT_EVENT = 'dsh-settings-search:shortcut-change'
|
|
542
|
+
const SEARCH_STYLES = ['sidebar', 'top', 'page']
|
|
518
543
|
const readSearchStyle = () => {
|
|
519
544
|
if (typeof localStorage === 'undefined') return 'sidebar'
|
|
520
545
|
const style = localStorage.getItem(SEARCH_STYLE_KEY)
|
|
@@ -525,6 +550,15 @@ window.__ModuleLoader__.load({
|
|
|
525
550
|
localStorage.setItem(SEARCH_STYLE_KEY, style)
|
|
526
551
|
window.dispatchEvent(new CustomEvent(SEARCH_STYLE_EVENT, { detail: style }))
|
|
527
552
|
}
|
|
553
|
+
const searchShortcutEnabled = () => {
|
|
554
|
+
if (typeof localStorage === 'undefined') return true
|
|
555
|
+
return localStorage.getItem(SEARCH_SHORTCUT_KEY) !== '0'
|
|
556
|
+
}
|
|
557
|
+
const setSearchShortcutEnabled = (enabled) => {
|
|
558
|
+
if (typeof localStorage === 'undefined') return
|
|
559
|
+
localStorage.setItem(SEARCH_SHORTCUT_KEY, enabled ? '1' : '0')
|
|
560
|
+
window.dispatchEvent(new CustomEvent(SEARCH_SHORTCUT_EVENT, { detail: enabled }))
|
|
561
|
+
}
|
|
528
562
|
const welcomed = () => {
|
|
529
563
|
if (typeof localStorage === 'undefined') return true
|
|
530
564
|
return localStorage.getItem(ONBOARD_KEY) === '1'
|
|
@@ -586,61 +620,109 @@ window.__ModuleLoader__.load({
|
|
|
586
620
|
if (/\/v1\/messages$/i.test(safe)) return safe
|
|
587
621
|
return /\/v1$/i.test(safe) ? `${safe}/messages` : `${safe}/v1/messages`
|
|
588
622
|
}
|
|
589
|
-
const
|
|
590
|
-
const
|
|
591
|
-
if (!
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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 })
|
|
597
675
|
let timer
|
|
598
676
|
const deadline = new Promise((_, reject) => {
|
|
599
677
|
timer = setTimeout(() => {
|
|
600
678
|
const error = new Error('AI request timed out')
|
|
601
679
|
error.name = 'AI_TIMEOUT'
|
|
602
680
|
reject(error)
|
|
603
|
-
|
|
681
|
+
controller.abort()
|
|
682
|
+
}, provider === 'ollama' ? 120000 : 12000)
|
|
604
683
|
})
|
|
605
684
|
const work = (async () => {
|
|
606
|
-
const
|
|
607
|
-
const headers = {
|
|
608
|
-
'content-type': 'application/json',
|
|
609
|
-
...(isAnthropic
|
|
610
|
-
? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true' }
|
|
611
|
-
: { authorization: `Bearer ${apiKey}` }),
|
|
612
|
-
}
|
|
613
|
-
const body = isAnthropic
|
|
614
|
-
? {
|
|
615
|
-
model,
|
|
616
|
-
max_tokens: 1024,
|
|
617
|
-
system: AI_PROMPT,
|
|
618
|
-
messages: [{ role: 'user', content: userContent }],
|
|
619
|
-
temperature: 0,
|
|
620
|
-
}
|
|
621
|
-
: {
|
|
622
|
-
model,
|
|
623
|
-
messages: [
|
|
624
|
-
{ role: 'system', content: AI_PROMPT },
|
|
625
|
-
{ role: 'user', content: userContent },
|
|
626
|
-
],
|
|
627
|
-
temperature: 0,
|
|
628
|
-
}
|
|
629
|
-
const response = await fetch(url, {
|
|
685
|
+
const response = await fetch(request.url, {
|
|
630
686
|
method: 'POST',
|
|
631
|
-
headers,
|
|
632
|
-
signal,
|
|
633
|
-
|
|
687
|
+
headers: request.headers,
|
|
688
|
+
signal: controller.signal,
|
|
689
|
+
redirect: 'error',
|
|
690
|
+
body: JSON.stringify(request.body),
|
|
634
691
|
})
|
|
635
|
-
if (!response.ok)
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
|
700
|
+
})
|
|
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
|
|
641
707
|
})()
|
|
642
708
|
try {
|
|
643
|
-
|
|
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))
|
|
644
726
|
log('info', 'AI 结果', { count: keywords.length, duration: Date.now() - startedAt })
|
|
645
727
|
return keywords
|
|
646
728
|
} catch (error) {
|
|
@@ -650,15 +732,14 @@ window.__ModuleLoader__.load({
|
|
|
650
732
|
}
|
|
651
733
|
log('error', 'AI 请求失败', { message: String(error?.message ?? error).slice(0, 160), duration: Date.now() - startedAt })
|
|
652
734
|
throw error
|
|
653
|
-
} finally {
|
|
654
|
-
clearTimeout(timer)
|
|
655
735
|
}
|
|
656
736
|
}
|
|
657
737
|
const requestAiKeywords = async (query, labels, signal) => {
|
|
658
|
-
const
|
|
738
|
+
const { provider, baseUrl, model } = aiConfig()
|
|
739
|
+
const cacheKey = JSON.stringify([provider, baseUrl, model, query.toLowerCase(), labels])
|
|
659
740
|
if (AI_CACHE.has(cacheKey)) return AI_CACHE.get(cacheKey)
|
|
660
741
|
const keywords = await requestUserAi(query, labels, signal)
|
|
661
|
-
if (keywords.length > 0) {
|
|
742
|
+
if (!signal?.aborted && keywords.length > 0) {
|
|
662
743
|
// bound the cache so a long session cannot grow it without limit
|
|
663
744
|
if (AI_CACHE.size >= 100) AI_CACHE.clear()
|
|
664
745
|
AI_CACHE.set(cacheKey, keywords)
|
|
@@ -684,67 +765,31 @@ window.__ModuleLoader__.load({
|
|
|
684
765
|
const code = Number(status)
|
|
685
766
|
return HTTP_TEST_CODES[code] ?? (code >= 500 && code <= 599 ? 'server' : 'http')
|
|
686
767
|
}
|
|
687
|
-
const testAiService = async (cfg) => {
|
|
688
|
-
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
const
|
|
692
|
-
if (!
|
|
693
|
-
const isAnthropic = provider === 'anthropic'
|
|
694
|
-
const requestUrl = isAnthropic ? anthropicMessagesUrl(baseUrl) : chatCompletionsUrl(baseUrl)
|
|
695
|
-
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: '' }
|
|
696
774
|
const startedAt = Date.now()
|
|
697
775
|
log('info', '测试 API 服务', { provider, model })
|
|
698
|
-
let timer
|
|
699
|
-
const deadline = new Promise((_, reject) => {
|
|
700
|
-
timer = setTimeout(() => {
|
|
701
|
-
const error = new Error('AI request timed out')
|
|
702
|
-
error.name = 'AI_TIMEOUT'
|
|
703
|
-
reject(error)
|
|
704
|
-
}, 12000)
|
|
705
|
-
})
|
|
706
|
-
const work = (async () => {
|
|
707
|
-
const headers = {
|
|
708
|
-
'content-type': 'application/json',
|
|
709
|
-
...(isAnthropic
|
|
710
|
-
? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true' }
|
|
711
|
-
: { authorization: `Bearer ${apiKey}` }),
|
|
712
|
-
}
|
|
713
|
-
const body = isAnthropic
|
|
714
|
-
? { model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] }
|
|
715
|
-
: { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, temperature: 0 }
|
|
716
|
-
const response = await fetch(requestUrl, {
|
|
717
|
-
method: 'POST',
|
|
718
|
-
headers,
|
|
719
|
-
body: JSON.stringify(body),
|
|
720
|
-
})
|
|
721
|
-
if (!response.ok) return { ok: false, code: testHttpCode(response.status), detail: `HTTP ${response.status}` }
|
|
722
|
-
const data = await response.json().catch(() => ({}))
|
|
723
|
-
const valid = isAnthropic
|
|
724
|
-
? (Array.isArray(data?.content) && data.content.length > 0)
|
|
725
|
-
: Boolean(data?.choices?.[0]?.message)
|
|
726
|
-
if (!valid) return { ok: false, code: 'bad-response', detail: '' }
|
|
727
|
-
return { ok: true }
|
|
728
|
-
})()
|
|
729
776
|
try {
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
return result
|
|
777
|
+
await fetchAiData(request, provider, signal)
|
|
778
|
+
log('info', '测试 API 成功', { duration: Date.now() - startedAt })
|
|
779
|
+
return { ok: true }
|
|
734
780
|
} catch (error) {
|
|
735
781
|
if (error.name === 'AbortError') {
|
|
736
782
|
log('warn', '测试 API 取消', { duration: Date.now() - startedAt })
|
|
737
|
-
|
|
783
|
+
return { ok: false, code: 'cancelled', detail: '' }
|
|
738
784
|
}
|
|
739
785
|
if (error.name === 'AI_TIMEOUT') {
|
|
740
786
|
log('error', '测试 API 超时', { duration: Date.now() - startedAt })
|
|
741
787
|
return { ok: false, code: 'timeout', detail: '' }
|
|
742
788
|
}
|
|
789
|
+
const code = error.status ? testHttpCode(error.status) : error.name === 'AI_BAD_RESPONSE' ? 'bad-response' : 'network'
|
|
743
790
|
const reason = String(error?.message ?? error).slice(0, 160)
|
|
744
791
|
log('error', '测试 API 失败', { detail: reason, duration: Date.now() - startedAt })
|
|
745
|
-
return { ok: false, code
|
|
746
|
-
} finally {
|
|
747
|
-
clearTimeout(timer)
|
|
792
|
+
return { ok: false, code, detail: reason }
|
|
748
793
|
}
|
|
749
794
|
}
|
|
750
795
|
|
|
@@ -795,7 +840,6 @@ window.__ModuleLoader__.load({
|
|
|
795
840
|
.sss-page-results { flex: 1; min-height: 0; overflow: auto; padding: 8px; }
|
|
796
841
|
.sss-page-results .sss-group { padding: 2px; }
|
|
797
842
|
.sss-page-results .sss-item { padding: 10px 8px; border-radius: 8px; }
|
|
798
|
-
.sss-mixed-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-primary); font-size: 16px; line-height: 24px; }
|
|
799
843
|
@keyframes sssFlashBg { 0% { background-color: var(--dsw-alias-interactive-bg-hover); } 100% { background-color: transparent; } }
|
|
800
844
|
.sss-flash { animation: sssFlashBg 1.8s ease-out; border-radius: 8px; }
|
|
801
845
|
.sss-vr { list-style: none; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-3); border-radius: 12px; max-width: 780px; width: 100%; box-sizing: border-box; transition: border-color .16s, background .16s; }
|
|
@@ -1225,9 +1269,16 @@ window.__ModuleLoader__.load({
|
|
|
1225
1269
|
|
|
1226
1270
|
/* ---------- DOM-level navigation into the settings shell ---------- */
|
|
1227
1271
|
|
|
1228
|
-
/**
|
|
1272
|
+
/**
|
|
1273
|
+
* Root element that hosts the settings panel's nav buttons. Derived from
|
|
1274
|
+
* the plugin's own mounted UI so an unrelated or plugin-owned dialog can
|
|
1275
|
+
* never become the click root; the first-dialog fallback only applies
|
|
1276
|
+
* when the plugin's anchors are not mounted.
|
|
1277
|
+
*/
|
|
1229
1278
|
const panelRoot = () => {
|
|
1230
1279
|
if (typeof document === 'undefined') return null
|
|
1280
|
+
const anchor = document.querySelector('.sss-wrap, .sss-vr')
|
|
1281
|
+
if (anchor) return anchor.closest('[role="dialog"]') ?? document.body
|
|
1231
1282
|
return document.querySelector('[role="dialog"]') ?? document.body
|
|
1232
1283
|
}
|
|
1233
1284
|
const panelButtons = () => {
|
|
@@ -1237,7 +1288,7 @@ window.__ModuleLoader__.load({
|
|
|
1237
1288
|
const clickNavButton = (wantText) => {
|
|
1238
1289
|
const want = String(wantText ?? '').trim()
|
|
1239
1290
|
if (want === '') return false
|
|
1240
|
-
const buttons = panelButtons()
|
|
1291
|
+
const buttons = panelButtons().filter((b) => /_navCell/.test(String(b.className || '')))
|
|
1241
1292
|
const textOf = (b) => (b.textContent ?? '').trim()
|
|
1242
1293
|
const hit = buttons.find((b) => textOf(b) === want)
|
|
1243
1294
|
if (!hit) return false
|
|
@@ -1386,7 +1437,9 @@ window.__ModuleLoader__.load({
|
|
|
1386
1437
|
const baseUrl = String(providerProfile.baseURL ?? '').trim()
|
|
1387
1438
|
if (!modelId || !baseUrl) return null
|
|
1388
1439
|
return {
|
|
1389
|
-
provider:
|
|
1440
|
+
provider: providerId.toLowerCase() === 'ollama' || protocol === 'ollama'
|
|
1441
|
+
? 'ollama'
|
|
1442
|
+
: protocol === 'anthropic-messages' ? 'anthropic' : 'openai',
|
|
1390
1443
|
baseUrl,
|
|
1391
1444
|
model: modelId,
|
|
1392
1445
|
}
|
|
@@ -1477,6 +1530,7 @@ window.__ModuleLoader__.load({
|
|
|
1477
1530
|
const [open, setOpen] = React.useState(false)
|
|
1478
1531
|
const [searchStyle, setSearchStyleState] = React.useState(readSearchStyle)
|
|
1479
1532
|
const [pageOpen, setPageOpen] = React.useState(false)
|
|
1533
|
+
const [shortcutEnabled, setShortcutEnabled] = React.useState(searchShortcutEnabled)
|
|
1480
1534
|
const [showWelcome, setShowWelcome] = React.useState(() => !welcomed())
|
|
1481
1535
|
const [tick, setTick] = React.useState(0)
|
|
1482
1536
|
const [warming, setWarming] = React.useState(false)
|
|
@@ -1555,6 +1609,21 @@ window.__ModuleLoader__.load({
|
|
|
1555
1609
|
return () => window.removeEventListener(SEARCH_STYLE_EVENT, sync)
|
|
1556
1610
|
}, [])
|
|
1557
1611
|
|
|
1612
|
+
// A style switch must not carry an open popup from the previous layout
|
|
1613
|
+
// into the new one. The host can keep this component mounted while the
|
|
1614
|
+
// settings choice changes, so reset transient view state explicitly.
|
|
1615
|
+
React.useEffect(() => {
|
|
1616
|
+
setOpen(false)
|
|
1617
|
+
setPageOpen(false)
|
|
1618
|
+
setHint(null)
|
|
1619
|
+
}, [searchStyle])
|
|
1620
|
+
|
|
1621
|
+
React.useEffect(() => {
|
|
1622
|
+
const sync = () => setShortcutEnabled(searchShortcutEnabled())
|
|
1623
|
+
window.addEventListener(SEARCH_SHORTCUT_EVENT, sync)
|
|
1624
|
+
return () => window.removeEventListener(SEARCH_SHORTCUT_EVENT, sync)
|
|
1625
|
+
}, [])
|
|
1626
|
+
|
|
1558
1627
|
React.useEffect(() => {
|
|
1559
1628
|
const sync = () => setAiEnabled(aiSearchEnabled())
|
|
1560
1629
|
const onConfig = () => forceTick()
|
|
@@ -1622,12 +1691,12 @@ window.__ModuleLoader__.load({
|
|
|
1622
1691
|
const results = [...shownPages, ...shownOptions, ...related.map((r) => r.item)]
|
|
1623
1692
|
|
|
1624
1693
|
const cfg = aiConfig()
|
|
1625
|
-
const aiConfigured =
|
|
1694
|
+
const aiConfigured = aiConfigReady(cfg)
|
|
1626
1695
|
const aiReady = aiEnabled && aiConfigured && q.length >= 2 && noLocalMatch
|
|
1627
1696
|
React.useEffect(() => {
|
|
1628
|
-
if (!aiReady) return
|
|
1629
1697
|
setAiKeywords([])
|
|
1630
1698
|
setAiStatus('idle')
|
|
1699
|
+
if (!aiReady) return
|
|
1631
1700
|
let alive = true
|
|
1632
1701
|
const controller = new AbortController()
|
|
1633
1702
|
const timer = setTimeout(() => {
|
|
@@ -1655,7 +1724,7 @@ window.__ModuleLoader__.load({
|
|
|
1655
1724
|
clearTimeout(timer)
|
|
1656
1725
|
controller.abort()
|
|
1657
1726
|
}
|
|
1658
|
-
}, [aiReady, aiEnabled, q])
|
|
1727
|
+
}, [aiReady, aiEnabled, q, cfg.provider, cfg.baseUrl, cfg.model, cfg.apiKey])
|
|
1659
1728
|
|
|
1660
1729
|
const loggedQueryRef = React.useRef('')
|
|
1661
1730
|
React.useEffect(() => {
|
|
@@ -1670,8 +1739,7 @@ window.__ModuleLoader__.load({
|
|
|
1670
1739
|
const popRef = React.useRef(null)
|
|
1671
1740
|
const inputRef = React.useRef(null)
|
|
1672
1741
|
const pageMode = searchStyle === 'page'
|
|
1673
|
-
const
|
|
1674
|
-
const topMode = searchStyle === 'top' || mixedMode
|
|
1742
|
+
const topMode = searchStyle === 'top'
|
|
1675
1743
|
const activeIndex = Math.min(active, Math.max(results.length - 1, 0))
|
|
1676
1744
|
|
|
1677
1745
|
React.useEffect(() => { setActive(0) }, [query])
|
|
@@ -1680,6 +1748,23 @@ window.__ModuleLoader__.load({
|
|
|
1680
1748
|
const timer = setTimeout(() => inputRef.current?.focus(), 0)
|
|
1681
1749
|
return () => clearTimeout(timer)
|
|
1682
1750
|
}, [pageMode, pageOpen])
|
|
1751
|
+
React.useEffect(() => {
|
|
1752
|
+
if (!shortcutEnabled) return
|
|
1753
|
+
const onKeyDown = (event) => {
|
|
1754
|
+
if (!event.ctrlKey && !event.metaKey) return
|
|
1755
|
+
if (event.altKey || event.shiftKey || event.key !== 'f' && event.key !== 'F') return
|
|
1756
|
+
event.preventDefault()
|
|
1757
|
+
if (pageMode) {
|
|
1758
|
+
setPageOpen(true)
|
|
1759
|
+
return
|
|
1760
|
+
}
|
|
1761
|
+
setHint(null)
|
|
1762
|
+
setOpen(true)
|
|
1763
|
+
inputRef.current?.focus()
|
|
1764
|
+
}
|
|
1765
|
+
window.addEventListener('keydown', onKeyDown)
|
|
1766
|
+
return () => window.removeEventListener('keydown', onKeyDown)
|
|
1767
|
+
}, [pageMode, shortcutEnabled])
|
|
1683
1768
|
React.useEffect(() => {
|
|
1684
1769
|
const el = popRef.current?.querySelector('.sss-item.is-active')
|
|
1685
1770
|
el?.scrollIntoView?.({ block: 'nearest' })
|
|
@@ -1812,12 +1897,12 @@ window.__ModuleLoader__.load({
|
|
|
1812
1897
|
const item = results[activeIndex]
|
|
1813
1898
|
if (item) { e.preventDefault(); pick(item) }
|
|
1814
1899
|
} else if (e.key === 'Escape') {
|
|
1815
|
-
if (pageMode
|
|
1900
|
+
if (pageMode) setPageOpen(false)
|
|
1816
1901
|
else setOpen(false)
|
|
1817
1902
|
}
|
|
1818
1903
|
},
|
|
1819
|
-
onChange: (e) => { setQuery(e.target.value); setHint(null); setOpen(true)
|
|
1820
|
-
onFocus: () =>
|
|
1904
|
+
onChange: (e) => { setQuery(e.target.value); setHint(null); setOpen(true) },
|
|
1905
|
+
onFocus: () => setOpen(true),
|
|
1821
1906
|
onBlur: () => setOpen(false),
|
|
1822
1907
|
})
|
|
1823
1908
|
const clearButton = query !== '' ? React.createElement('button', {
|
|
@@ -1912,44 +1997,11 @@ window.__ModuleLoader__.load({
|
|
|
1912
1997
|
clearButton,
|
|
1913
1998
|
]),
|
|
1914
1999
|
warming ? React.createElement('p', { className: 'sss-warm-note' }, t('warming')) : null,
|
|
1915
|
-
|
|
1916
|
-
|
|
2000
|
+
open && q !== '' ? React.createElement('div', { className: 'sss-pop', ref: popRef }, [resultsNode]) : null,
|
|
2001
|
+
hint !== null
|
|
1917
2002
|
? React.createElement('div', { className: 'sss-guide' }, t('jumpFail').replace('{path}', hint))
|
|
1918
2003
|
: null,
|
|
1919
2004
|
]),
|
|
1920
|
-
mixedMode && pageOpen
|
|
1921
|
-
? React.createElement('div', {
|
|
1922
|
-
className: 'sss-page-overlay',
|
|
1923
|
-
onMouseDown: (event) => { if (event.target === event.currentTarget) setPageOpen(false) },
|
|
1924
|
-
}, [
|
|
1925
|
-
React.createElement('div', { className: 'sss-page' }, [
|
|
1926
|
-
React.createElement('div', { className: 'sss-page-head' }, [
|
|
1927
|
-
React.createElement('button', {
|
|
1928
|
-
className: 'sss-icon-btn',
|
|
1929
|
-
type: 'button',
|
|
1930
|
-
'aria-label': t('searchBtn'),
|
|
1931
|
-
title: t('searchBtn'),
|
|
1932
|
-
onClick: () => inputRef.current?.focus(),
|
|
1933
|
-
}, [SearchIcon]),
|
|
1934
|
-
React.createElement('span', { className: 'sss-page-input sss-mixed-title' }, t('pageSearchTitle')),
|
|
1935
|
-
clearButton,
|
|
1936
|
-
React.createElement('button', {
|
|
1937
|
-
className: 'sss-page-close',
|
|
1938
|
-
type: 'button',
|
|
1939
|
-
'aria-label': 'Close',
|
|
1940
|
-
onClick: () => setPageOpen(false),
|
|
1941
|
-
}, '✕'),
|
|
1942
|
-
]),
|
|
1943
|
-
React.createElement('div', { className: 'sss-page-results' }, [
|
|
1944
|
-
warming ? React.createElement('p', { className: 'sss-warm-note' }, t('warming')) : null,
|
|
1945
|
-
q !== '' ? resultsNode : null,
|
|
1946
|
-
hint !== null
|
|
1947
|
-
? React.createElement('div', { className: 'sss-guide' }, t('jumpFail').replace('{path}', hint))
|
|
1948
|
-
: null,
|
|
1949
|
-
]),
|
|
1950
|
-
]),
|
|
1951
|
-
])
|
|
1952
|
-
: null,
|
|
1953
2005
|
])
|
|
1954
2006
|
}
|
|
1955
2007
|
|
|
@@ -1998,6 +2050,7 @@ window.__ModuleLoader__.load({
|
|
|
1998
2050
|
function UpdatePanel() {
|
|
1999
2051
|
const [open, setOpen] = React.useState(true)
|
|
2000
2052
|
const [searchStyle, setSearchStyleState] = React.useState(readSearchStyle)
|
|
2053
|
+
const [shortcutOn, setShortcutOn] = React.useState(searchShortcutEnabled)
|
|
2001
2054
|
const [state, setState] = React.useState('idle') // idle | loading | newest | outdated | error
|
|
2002
2055
|
const [latest, setLatest] = React.useState('')
|
|
2003
2056
|
const [copied, setCopied] = React.useState(false)
|
|
@@ -2010,6 +2063,8 @@ window.__ModuleLoader__.load({
|
|
|
2010
2063
|
const [baseUrl, setBaseUrl] = React.useState(cfg.baseUrl)
|
|
2011
2064
|
const [model, setModel] = React.useState(cfg.model)
|
|
2012
2065
|
const [apiKey, setApiKey] = React.useState(cfg.apiKey)
|
|
2066
|
+
const providerDrafts = React.useRef({})
|
|
2067
|
+
const testAbortRef = React.useRef(null)
|
|
2013
2068
|
const [aiSaved, setAiSaved] = React.useState(false)
|
|
2014
2069
|
const [testState, setTestState] = React.useState('idle') // idle | loading | ok | fail
|
|
2015
2070
|
const [testMsg, setTestMsg] = React.useState('')
|
|
@@ -2035,9 +2090,22 @@ window.__ModuleLoader__.load({
|
|
|
2035
2090
|
return () => window.removeEventListener(SEARCH_STYLE_EVENT, sync)
|
|
2036
2091
|
}, [])
|
|
2037
2092
|
|
|
2093
|
+
React.useEffect(() => {
|
|
2094
|
+
const sync = () => setShortcutOn(searchShortcutEnabled())
|
|
2095
|
+
window.addEventListener(SEARCH_SHORTCUT_EVENT, sync)
|
|
2096
|
+
return () => window.removeEventListener(SEARCH_SHORTCUT_EVENT, sync)
|
|
2097
|
+
}, [])
|
|
2098
|
+
|
|
2038
2099
|
useAutoReset(copied, () => { setCopied(false); setCopyFailed(false) })
|
|
2039
2100
|
useAutoReset(aiSaved, () => setAiSaved(false))
|
|
2040
2101
|
|
|
2102
|
+
React.useEffect(() => {
|
|
2103
|
+
setTestState('idle')
|
|
2104
|
+
setTestMsg('')
|
|
2105
|
+
setAiSaved(false)
|
|
2106
|
+
return () => testAbortRef.current?.abort()
|
|
2107
|
+
}, [provider, baseUrl, model, apiKey])
|
|
2108
|
+
|
|
2041
2109
|
React.useEffect(() => {
|
|
2042
2110
|
const sync = () => setLogVersion((v) => v + 1)
|
|
2043
2111
|
window.addEventListener(LOG_EVENT, sync)
|
|
@@ -2048,13 +2116,22 @@ window.__ModuleLoader__.load({
|
|
|
2048
2116
|
useAutoReset(logCleared, () => setLogCleared(false))
|
|
2049
2117
|
useAutoReset(logExportFail, () => setLogExportFail(false))
|
|
2050
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
|
+
|
|
2051
2127
|
const saveAi = () => {
|
|
2052
|
-
|
|
2128
|
+
const next = normalizeAiConfig({
|
|
2053
2129
|
provider,
|
|
2054
|
-
baseUrl
|
|
2055
|
-
model
|
|
2056
|
-
apiKey
|
|
2130
|
+
baseUrl,
|
|
2131
|
+
model,
|
|
2132
|
+
apiKey,
|
|
2057
2133
|
})
|
|
2134
|
+
setAiConfig(next)
|
|
2058
2135
|
setAiSaved(true)
|
|
2059
2136
|
log('info', '保存 AI 配置', { provider, model: model.trim() })
|
|
2060
2137
|
}
|
|
@@ -2066,6 +2143,7 @@ window.__ModuleLoader__.load({
|
|
|
2066
2143
|
try {
|
|
2067
2144
|
const imported = await readDshModelConfig()
|
|
2068
2145
|
if (!imported) throw new Error(t('aiDshImportEmpty'))
|
|
2146
|
+
providerDrafts.current[provider] = { baseUrl, model }
|
|
2069
2147
|
setAiConfig({ ...imported, apiKey })
|
|
2070
2148
|
setProvider(imported.provider)
|
|
2071
2149
|
setBaseUrl(imported.baseUrl)
|
|
@@ -2086,6 +2164,12 @@ window.__ModuleLoader__.load({
|
|
|
2086
2164
|
setSearchStyleState(style)
|
|
2087
2165
|
}
|
|
2088
2166
|
|
|
2167
|
+
const updateShortcutEnabled = (enabled) => {
|
|
2168
|
+
setSearchShortcutEnabled(enabled)
|
|
2169
|
+
setShortcutOn(enabled)
|
|
2170
|
+
log('info', enabled ? '启用搜索快捷键' : '禁用搜索快捷键', {})
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2089
2173
|
const requestClearAi = () => setConfirmClearAi(true)
|
|
2090
2174
|
const cancelClearAi = () => setConfirmClearAi(false)
|
|
2091
2175
|
const clearAiConfig = () => {
|
|
@@ -2095,26 +2179,37 @@ window.__ModuleLoader__.load({
|
|
|
2095
2179
|
setBaseUrl('')
|
|
2096
2180
|
setModel('')
|
|
2097
2181
|
setApiKey('')
|
|
2182
|
+
providerDrafts.current = {}
|
|
2098
2183
|
setAiSaved(false)
|
|
2099
2184
|
log('info', '清除 API 配置', {})
|
|
2100
2185
|
}
|
|
2101
2186
|
|
|
2102
2187
|
const testService = async () => {
|
|
2103
2188
|
if (testState === 'loading') return
|
|
2104
|
-
if (!
|
|
2189
|
+
if (!aiConfigReady({ provider, baseUrl, model, apiKey })) {
|
|
2105
2190
|
setTestState('fail')
|
|
2106
2191
|
setTestMsg(t('aiNeedConfig'))
|
|
2107
2192
|
return
|
|
2108
2193
|
}
|
|
2109
2194
|
setTestState('loading')
|
|
2110
2195
|
setTestMsg('')
|
|
2111
|
-
const
|
|
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
|
|
2112
2202
|
if (result.ok) {
|
|
2113
2203
|
setTestState('ok')
|
|
2114
2204
|
setTestMsg(t('aiTestOk'))
|
|
2115
2205
|
} else {
|
|
2116
2206
|
setTestState('fail')
|
|
2117
|
-
const
|
|
2207
|
+
const ollamaHintKey = provider === 'ollama' ? {
|
|
2208
|
+
network: 'aiOllamaErrNetwork',
|
|
2209
|
+
forbidden: 'aiOllamaErrForbidden',
|
|
2210
|
+
timeout: 'aiOllamaErrTimeout',
|
|
2211
|
+
}[result.code] : null
|
|
2212
|
+
const hintKey = ollamaHintKey ?? {
|
|
2118
2213
|
auth: 'aiTestErrAuth',
|
|
2119
2214
|
forbidden: 'aiTestErrForbidden',
|
|
2120
2215
|
'not-found': 'aiTestErrNotFound',
|
|
@@ -2268,11 +2363,6 @@ window.__ModuleLoader__.load({
|
|
|
2268
2363
|
type: 'button',
|
|
2269
2364
|
onClick: () => updateSearchStyle('page'),
|
|
2270
2365
|
}, t('stylePage')),
|
|
2271
|
-
React.createElement('button', {
|
|
2272
|
-
className: 'sss-vr-seg' + (searchStyle === 'mixed' ? ' is-active' : ''),
|
|
2273
|
-
type: 'button',
|
|
2274
|
-
onClick: () => updateSearchStyle('mixed'),
|
|
2275
|
-
}, t('styleMixed')),
|
|
2276
2366
|
]),
|
|
2277
2367
|
]),
|
|
2278
2368
|
React.createElement('div', { className: 'sss-vr-field' }, [
|
|
@@ -2292,6 +2382,18 @@ window.__ModuleLoader__.load({
|
|
|
2292
2382
|
React.createElement('p', { className: 'sss-vr-hint' }, t('aiDesc')),
|
|
2293
2383
|
React.createElement('p', { className: 'sss-vr-hint' }, t('aiPrivacy')),
|
|
2294
2384
|
]),
|
|
2385
|
+
React.createElement('div', { className: 'sss-vr-field' }, [
|
|
2386
|
+
React.createElement('div', { className: 'sss-vr-field-head' }, [
|
|
2387
|
+
React.createElement('span', { className: 'sss-vr-label' }, t('shortcutTitle')),
|
|
2388
|
+
React.createElement('input', {
|
|
2389
|
+
className: 'sss-vr-check',
|
|
2390
|
+
type: 'checkbox',
|
|
2391
|
+
checked: shortcutOn,
|
|
2392
|
+
onChange: (event) => updateShortcutEnabled(event.target.checked),
|
|
2393
|
+
}),
|
|
2394
|
+
]),
|
|
2395
|
+
React.createElement('p', { className: 'sss-vr-hint' }, t('shortcutHint')),
|
|
2396
|
+
]),
|
|
2295
2397
|
React.createElement('div', { className: 'sss-vr-field sss-vr-form' }, [
|
|
2296
2398
|
React.createElement('div', { className: 'sss-vr-field-head' }, [
|
|
2297
2399
|
React.createElement('label', { className: 'sss-vr-label' }, t('aiProvider')),
|
|
@@ -2299,14 +2401,16 @@ window.__ModuleLoader__.load({
|
|
|
2299
2401
|
React.createElement('select', {
|
|
2300
2402
|
className: 'sss-vr-input sss-vr-select',
|
|
2301
2403
|
name: 'dsh-settings-search-provider',
|
|
2404
|
+
'aria-label': t('aiProvider'),
|
|
2302
2405
|
value: provider,
|
|
2303
2406
|
autoComplete: 'off',
|
|
2304
2407
|
'data-lpignore': 'true',
|
|
2305
2408
|
'data-form-type': 'other',
|
|
2306
|
-
onChange: (event) =>
|
|
2409
|
+
onChange: (event) => changeProvider(event.target.value),
|
|
2307
2410
|
}, [
|
|
2308
2411
|
React.createElement('option', { value: 'openai' }, t('aiOpenAI')),
|
|
2309
2412
|
React.createElement('option', { value: 'anthropic' }, t('aiAnthropic')),
|
|
2413
|
+
React.createElement('option', { value: 'ollama' }, t('aiOllama')),
|
|
2310
2414
|
]),
|
|
2311
2415
|
React.createElement('div', { className: 'sss-vr-field-head' }, [
|
|
2312
2416
|
React.createElement('label', { className: 'sss-vr-label' }, t('aiBaseUrl')),
|
|
@@ -2315,8 +2419,10 @@ window.__ModuleLoader__.load({
|
|
|
2315
2419
|
className: 'sss-vr-input',
|
|
2316
2420
|
type: 'url',
|
|
2317
2421
|
name: 'dsh-settings-search-base-url',
|
|
2422
|
+
'aria-label': t('aiBaseUrl'),
|
|
2318
2423
|
value: baseUrl,
|
|
2319
|
-
placeholder: provider === '
|
|
2424
|
+
placeholder: provider === 'ollama' ? OLLAMA_BASE_URL
|
|
2425
|
+
: provider === 'anthropic' ? 'https://api.anthropic.com' : 'https://api.openai.com/v1',
|
|
2320
2426
|
spellCheck: false,
|
|
2321
2427
|
autoComplete: 'off',
|
|
2322
2428
|
'data-lpignore': 'true',
|
|
@@ -2332,8 +2438,10 @@ window.__ModuleLoader__.load({
|
|
|
2332
2438
|
className: 'sss-vr-input',
|
|
2333
2439
|
type: 'text',
|
|
2334
2440
|
name: 'dsh-settings-search-model',
|
|
2441
|
+
'aria-label': t('aiModel'),
|
|
2335
2442
|
value: model,
|
|
2336
|
-
placeholder: provider === '
|
|
2443
|
+
placeholder: provider === 'ollama' ? 'qwen3:8b'
|
|
2444
|
+
: provider === 'anthropic' ? 'claude-sonnet-4-20250514' : 'gpt-4o-mini',
|
|
2337
2445
|
spellCheck: false,
|
|
2338
2446
|
autoComplete: 'off',
|
|
2339
2447
|
'data-lpignore': 'true',
|
|
@@ -2341,13 +2449,14 @@ window.__ModuleLoader__.load({
|
|
|
2341
2449
|
'data-form-type': 'other',
|
|
2342
2450
|
onChange: (event) => setModel(event.target.value),
|
|
2343
2451
|
}),
|
|
2344
|
-
React.createElement('div', { className: 'sss-vr-field-head' }, [
|
|
2452
|
+
provider !== 'ollama' ? React.createElement('div', { className: 'sss-vr-field-head' }, [
|
|
2345
2453
|
React.createElement('label', { className: 'sss-vr-label' }, t('aiApiKey')),
|
|
2346
|
-
]),
|
|
2347
|
-
React.createElement('input', {
|
|
2454
|
+
]) : null,
|
|
2455
|
+
provider !== 'ollama' ? React.createElement('input', {
|
|
2348
2456
|
className: 'sss-vr-input sss-vr-mask',
|
|
2349
2457
|
type: 'text',
|
|
2350
2458
|
name: 'dsh-settings-search-api-key',
|
|
2459
|
+
'aria-label': t('aiApiKey'),
|
|
2351
2460
|
value: apiKey,
|
|
2352
2461
|
placeholder: 'sk-…',
|
|
2353
2462
|
spellCheck: false,
|
|
@@ -2356,7 +2465,7 @@ window.__ModuleLoader__.load({
|
|
|
2356
2465
|
'data-1p-ignore': 'true',
|
|
2357
2466
|
'data-form-type': 'other',
|
|
2358
2467
|
onChange: (event) => setApiKey(event.target.value),
|
|
2359
|
-
}),
|
|
2468
|
+
}) : null,
|
|
2360
2469
|
React.createElement('div', { className: 'sss-vr-actions' }, [
|
|
2361
2470
|
React.createElement('button', { className: 'sss-vr-btn sss-vr-primary', type: 'button', onClick: () => { void saveAi() } },
|
|
2362
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.
|
|
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
|
+
})
|