@objectivex666/dsh-settings-search 1.11.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +2 -1
- package/README.md +2 -1
- package/lib/client.js +450 -44
- package/package.json +1 -1
- package/scripts/ai.test.mjs +117 -3
package/README.en.md
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
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
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
|
+
- 💾 **Persistent settings index (v1.12.0)** — the first harvested settings index is stored locally. It rebuilds only on first use or after another plugin is installed or updated, and can be rebuilt manually from the Settings Search page.
|
|
26
27
|
- 🧾 **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
28
|
- 🧭 **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
29
|
- 🌱 **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.
|
|
@@ -89,7 +90,7 @@ The package ships its own `cordis.patch.yml` (that exact content), which `dsh pl
|
|
|
89
90
|
Enable "AI-assisted search" on the Settings Search page and select "Ollama (local)" as the API type:
|
|
90
91
|
|
|
91
92
|
- **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
|
+
- **Model**: choose one returned by "Get models", or enter the full name, including its tag, of an installed model. Use `ollama list` to see installed models.
|
|
93
94
|
- **API key**: not required. Ollama requests never send an existing cloud API key.
|
|
94
95
|
|
|
95
96
|
Save the configuration and use "Test API" to check connectivity. Ollama requests allow 120 seconds for cold starts; cancelling a search aborts the request.
|
package/README.md
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
- 🔤 **拼音联想搜索(v1.5.0)**:输入拼音首字母(如 `sz`)或全拼(如 `shezhi`)即可联想中文设置项;支持 `↑`/`↓` 选择、`Enter` 跳转、`Esc` 关闭,命中的文字自动高亮。
|
|
24
24
|
- 🧠 **意图联想搜索(v1.6.0)**:直接描述想做的事(如「太亮了」「字太小」「忘记密码」),无需知道选项名即可联想真实设置项,结果在「猜你想找」分组展示。
|
|
25
25
|
- 🤖 **AI 联想搜索**:没有本地匹配时,用你配置的 OpenAI 兼容、Anthropic 或本机 Ollama 模型理解搜索意图并推荐设置项。默认关闭,可在「设置搜索」页开启并配置;Ollama 无需 API Key。
|
|
26
|
+
- 💾 **持久设置索引(v1.12.0)**:首次采集后的设置索引保存在本机浏览器;只在首次安装、其它插件安装或更新时重建,也可在「设置搜索」页手动重建。
|
|
26
27
|
- 🧾 **日志与导出(v1.7.0)**:记录插件运行、搜索与 AI 请求的关键动作,可在「设置搜索」页一键导出、复制或清空,便于排查问题。不会记录 API Key。
|
|
27
28
|
- 🧭 **点击即达**:选中结果后自动点击对应的左侧导航项打开所在分区;若是页内标签页会继续点开对应标签,并高亮闪烁目标选项行。无法自动跳转时显示手动路径提示。
|
|
28
29
|
- 🌱 **启动自动索引(v1.8.0)**:首次打开设置面板时自动遍历各设置分区,静默读取分区内选项(如「启用创意工坊卡片」)并导入搜索索引;此后分区选项无需逐页手动打开即可直接搜索,读取完成后自动恢复当前所在分区,导入过程搜索框下方会显示提示,完成后自动消失。
|
|
@@ -89,7 +90,7 @@ dsh plugin --profile web add /path/to/dsh-settings-search
|
|
|
89
90
|
在「设置搜索」页开启「AI 联想搜索」,将「接口类型」选为「Ollama(本机)」:
|
|
90
91
|
|
|
91
92
|
- **Base URL**:默认 `http://127.0.0.1:11434`,也可填写自定义端口;支持根地址、`/api`、`/api/chat` 和 `/v1`。
|
|
92
|
-
-
|
|
93
|
+
- **模型名**:点「获取模型」从服务返回的模型中选择,或手动输入完整模型名(包括标签),可用 `ollama list` 查看。
|
|
93
94
|
- **API Key**:无需填写,Ollama 请求不会携带原有云端密钥。
|
|
94
95
|
|
|
95
96
|
保存后可用「测试接口」检查连接。本地模型首次加载可能较慢,Ollama 请求的超时时间为 120 秒;取消搜索会中止请求。
|
package/lib/client.js
CHANGED
|
@@ -30,8 +30,10 @@ window.__ModuleLoader__.load({
|
|
|
30
30
|
|
|
31
31
|
const NS = 'settings-search-v5'
|
|
32
32
|
/** Single source of truth is package.json — scripts/check.mjs asserts this matches. */
|
|
33
|
-
const PKG_VERSION = '1.
|
|
33
|
+
const PKG_VERSION = '1.12.0'
|
|
34
34
|
const NPM_NAME = '@objectivex666/dsh-settings-search'
|
|
35
|
+
const REPO_URL = 'https://github.com/objectivex666/dsh-settings-search'
|
|
36
|
+
const ISSUES_URL = 'https://github.com/objectivex666/dsh-settings-search/issues/new/choose'
|
|
35
37
|
const zh = {
|
|
36
38
|
title: '设置',
|
|
37
39
|
placeholder: '搜索设置…',
|
|
@@ -44,6 +46,11 @@ window.__ModuleLoader__.load({
|
|
|
44
46
|
openSearch: '打开搜索',
|
|
45
47
|
shortcutTitle: 'Ctrl+F / ⌘F 快捷键',
|
|
46
48
|
shortcutHint: '在设置面板内按 Ctrl+F 或 ⌘F 聚焦搜索框;关闭后保留浏览器默认查找。',
|
|
49
|
+
indexTitle: '设置索引',
|
|
50
|
+
indexDesc: '索引保存在本机浏览器;只在首次安装或更新其它插件后重建,也可以手动重建。',
|
|
51
|
+
indexRebuild: '手动重建索引',
|
|
52
|
+
indexRebuilding: '正在重建索引…',
|
|
53
|
+
indexDone: '索引已重建 ✓',
|
|
47
54
|
warming: '正在导入各分区选项…',
|
|
48
55
|
empty: '没有匹配的设置',
|
|
49
56
|
pages: '设置页',
|
|
@@ -69,6 +76,7 @@ window.__ModuleLoader__.load({
|
|
|
69
76
|
updNpx: 'npx',
|
|
70
77
|
updRelease: '查看 release notes',
|
|
71
78
|
updReleaseNotes: '查看更新说明',
|
|
79
|
+
updRepo: 'GitHub 仓库',
|
|
72
80
|
updFetchingNotes: '正在获取…',
|
|
73
81
|
updRestart: '更新完成后重启 DSH,新版本才会生效。',
|
|
74
82
|
aiSearch: 'AI 联想搜索',
|
|
@@ -86,6 +94,13 @@ window.__ModuleLoader__.load({
|
|
|
86
94
|
aiBaseUrl: 'Base URL',
|
|
87
95
|
aiBaseUrlHint: '仅支持 http(s) 地址;非本机地址必须使用 https,以免泄露 API Key。',
|
|
88
96
|
aiModel: '模型名',
|
|
97
|
+
aiModelHint: '从接口返回的模型中选择,或手动输入完整模型名。',
|
|
98
|
+
aiModelsLoad: '获取模型',
|
|
99
|
+
aiModelsLoading: '获取中…',
|
|
100
|
+
aiModelsLoaded: '已获取 {count} 个模型,可直接选择。',
|
|
101
|
+
aiModelsEmpty: '服务未返回模型,可手动输入。',
|
|
102
|
+
aiModelsFail: '获取模型失败:{hint}{detail}',
|
|
103
|
+
aiModelsNeedConfig: '先填写有效接口地址;云端接口还需 API Key。',
|
|
89
104
|
aiApiKey: 'API Key',
|
|
90
105
|
aiDshImport: '导入 DSH 模型配置',
|
|
91
106
|
aiDshImporting: '正在导入…',
|
|
@@ -120,6 +135,7 @@ window.__ModuleLoader__.load({
|
|
|
120
135
|
logDesc: '记录插件运行、搜索与 AI 请求的关键动作,便于排查问题。',
|
|
121
136
|
logEmpty: '暂无日志',
|
|
122
137
|
logExport: '导出日志',
|
|
138
|
+
logIssues: '反馈问题',
|
|
123
139
|
logCopy: '复制日志',
|
|
124
140
|
logCopied: '已复制 ✓',
|
|
125
141
|
logCopyFail: '复制失败,请稍后重试。',
|
|
@@ -160,6 +176,11 @@ window.__ModuleLoader__.load({
|
|
|
160
176
|
shortcutTitle: 'Ctrl+F / ⌘F shortcut',
|
|
161
177
|
shortcutHint: 'Focus the search box with Ctrl+F or ⌘F inside the settings panel; turn it off to keep the browser default.',
|
|
162
178
|
warming: 'Importing section options…',
|
|
179
|
+
indexTitle: 'Settings index',
|
|
180
|
+
indexDesc: 'The index is stored locally; it rebuilds only after another plugin is installed or updated. You can also rebuild it manually.',
|
|
181
|
+
indexRebuild: 'Rebuild index',
|
|
182
|
+
indexRebuilding: 'Rebuilding…',
|
|
183
|
+
indexDone: 'Index rebuilt ✓',
|
|
163
184
|
empty: 'No matching settings',
|
|
164
185
|
pages: 'Settings pages',
|
|
165
186
|
options: 'In-page options',
|
|
@@ -184,6 +205,7 @@ window.__ModuleLoader__.load({
|
|
|
184
205
|
updNpx: 'npx',
|
|
185
206
|
updRelease: 'View release notes',
|
|
186
207
|
updReleaseNotes: 'View changelog',
|
|
208
|
+
updRepo: 'GitHub repository',
|
|
187
209
|
updFetchingNotes: 'Fetching…',
|
|
188
210
|
updRestart: 'Restart DSH after updating for the new version to take effect.',
|
|
189
211
|
aiSearch: 'AI-assisted search',
|
|
@@ -201,6 +223,13 @@ window.__ModuleLoader__.load({
|
|
|
201
223
|
aiBaseUrl: 'Base URL',
|
|
202
224
|
aiBaseUrlHint: 'Only http(s) URLs are allowed; remote hosts must use https to avoid exposing your API key.',
|
|
203
225
|
aiModel: 'Model',
|
|
226
|
+
aiModelHint: 'Choose a model returned by the API, or enter a complete model name.',
|
|
227
|
+
aiModelsLoad: 'Get models',
|
|
228
|
+
aiModelsLoading: 'Loading…',
|
|
229
|
+
aiModelsLoaded: 'Loaded {count} models; choose one below.',
|
|
230
|
+
aiModelsEmpty: 'The service returned no models; enter one manually.',
|
|
231
|
+
aiModelsFail: 'Failed to load models: {hint}{detail}',
|
|
232
|
+
aiModelsNeedConfig: 'Enter a valid base URL first; cloud APIs also need an API key.',
|
|
204
233
|
aiApiKey: 'API Key',
|
|
205
234
|
aiDshImport: 'Import DSH model config',
|
|
206
235
|
aiDshImporting: 'Importing…',
|
|
@@ -235,6 +264,7 @@ window.__ModuleLoader__.load({
|
|
|
235
264
|
logDesc: 'Logs key plugin actions, searches, and AI requests to help troubleshoot.',
|
|
236
265
|
logEmpty: 'No logs yet',
|
|
237
266
|
logExport: 'Export logs',
|
|
267
|
+
logIssues: 'Report issue',
|
|
238
268
|
logCopy: 'Copy logs',
|
|
239
269
|
logCopied: 'Copied ✓',
|
|
240
270
|
logCopyFail: 'Copy failed. Please try again.',
|
|
@@ -539,6 +569,10 @@ window.__ModuleLoader__.load({
|
|
|
539
569
|
const SEARCH_STYLE_EVENT = 'dsh-settings-search:style-change'
|
|
540
570
|
const SEARCH_SHORTCUT_KEY = 'dsh-settings-search:shortcut-enabled'
|
|
541
571
|
const SEARCH_SHORTCUT_EVENT = 'dsh-settings-search:shortcut-change'
|
|
572
|
+
const INDEX_STORAGE_KEY = 'dsh-settings-search:index:v1'
|
|
573
|
+
const INDEX_INVALID_EVENT = 'dsh-settings-search:index-invalid'
|
|
574
|
+
const INDEX_REBUILD_EVENT = 'dsh-settings-search:index-rebuild'
|
|
575
|
+
const INDEX_REBUILD_DONE_EVENT = 'dsh-settings-search:index-rebuild-done'
|
|
542
576
|
const SEARCH_STYLES = ['sidebar', 'top', 'page']
|
|
543
577
|
const readSearchStyle = () => {
|
|
544
578
|
if (typeof localStorage === 'undefined') return 'sidebar'
|
|
@@ -628,6 +662,101 @@ window.__ModuleLoader__.load({
|
|
|
628
662
|
// Accept the base URL used by Ollama's OpenAI-compatible endpoint too.
|
|
629
663
|
return `${safe.replace(/\/v1(?:\/chat\/completions)?$/i, '')}/api/chat`
|
|
630
664
|
}
|
|
665
|
+
const ollamaModelsUrl = (baseUrl) => {
|
|
666
|
+
const safe = safeBaseUrl(baseUrl)
|
|
667
|
+
if (!safe) return ''
|
|
668
|
+
const root = safe.replace(/\/v1(?:\/models|\/chat\/completions)?$/i, '').replace(/\/api(?:\/tags|\/chat)?$/i, '')
|
|
669
|
+
return `${root}/api/tags`
|
|
670
|
+
}
|
|
671
|
+
const chatModelsUrl = (baseUrl) => {
|
|
672
|
+
const safe = safeBaseUrl(baseUrl)
|
|
673
|
+
if (!safe) return ''
|
|
674
|
+
const root = safe.replace(/\/(?:models|chat\/completions)$/i, '')
|
|
675
|
+
return /\/v1$/i.test(root) ? `${root}/models` : `${root}/v1/models`
|
|
676
|
+
}
|
|
677
|
+
const anthropicModelsUrl = (baseUrl) => {
|
|
678
|
+
const safe = safeBaseUrl(baseUrl)
|
|
679
|
+
if (!safe) return ''
|
|
680
|
+
const root = safe.replace(/\/v1\/(?:models|messages)$/i, '')
|
|
681
|
+
return `${root}/v1/models`
|
|
682
|
+
}
|
|
683
|
+
const modelsUrl = (provider, baseUrl) => provider === 'ollama'
|
|
684
|
+
? ollamaModelsUrl(baseUrl)
|
|
685
|
+
: provider === 'anthropic' ? anthropicModelsUrl(baseUrl) : chatModelsUrl(baseUrl)
|
|
686
|
+
const extractAiModels = (provider, data) => {
|
|
687
|
+
const rows = provider === 'ollama'
|
|
688
|
+
? (Array.isArray(data?.models) ? data.models : [])
|
|
689
|
+
: (Array.isArray(data?.data) ? data.data : Array.isArray(data?.models) ? data.models : [])
|
|
690
|
+
return [...new Set(rows
|
|
691
|
+
.map((row) => typeof row === 'string' ? row : row?.id ?? row?.name ?? row?.model)
|
|
692
|
+
.filter((value) => typeof value === 'string')
|
|
693
|
+
.map((value) => value.trim())
|
|
694
|
+
.filter(Boolean))].slice(0, 500)
|
|
695
|
+
}
|
|
696
|
+
const fetchAiModels = async (cfg, signal) => {
|
|
697
|
+
cfg = normalizeAiConfig(cfg)
|
|
698
|
+
const { provider, baseUrl, apiKey } = cfg
|
|
699
|
+
const url = modelsUrl(provider, baseUrl)
|
|
700
|
+
if (!url) return { ok: false, code: 'unsafe-url', models: [], detail: '' }
|
|
701
|
+
if (provider !== 'ollama' && !apiKey) return { ok: false, code: 'need-config', models: [], detail: '' }
|
|
702
|
+
const headers = provider === 'anthropic'
|
|
703
|
+
? {
|
|
704
|
+
'x-api-key': apiKey,
|
|
705
|
+
'anthropic-version': '2023-06-01',
|
|
706
|
+
'anthropic-dangerous-direct-browser-access': 'true',
|
|
707
|
+
}
|
|
708
|
+
: provider === 'openai' ? { authorization: `Bearer ${apiKey}` } : {}
|
|
709
|
+
const controller = new AbortController()
|
|
710
|
+
const abort = () => controller.abort()
|
|
711
|
+
if (signal?.aborted) abort()
|
|
712
|
+
else signal?.addEventListener('abort', abort, { once: true })
|
|
713
|
+
let timer
|
|
714
|
+
const deadline = new Promise((_, reject) => {
|
|
715
|
+
timer = setTimeout(() => {
|
|
716
|
+
const error = new Error('AI model list timed out')
|
|
717
|
+
error.name = 'AI_TIMEOUT'
|
|
718
|
+
reject(error)
|
|
719
|
+
controller.abort()
|
|
720
|
+
}, 12000)
|
|
721
|
+
})
|
|
722
|
+
try {
|
|
723
|
+
const models = await Promise.race([
|
|
724
|
+
(async () => {
|
|
725
|
+
const response = await fetch(url, {
|
|
726
|
+
method: 'GET',
|
|
727
|
+
headers,
|
|
728
|
+
signal: controller.signal,
|
|
729
|
+
redirect: 'error',
|
|
730
|
+
})
|
|
731
|
+
if (!response.ok) {
|
|
732
|
+
const error = new Error(`HTTP ${response.status}`)
|
|
733
|
+
error.status = response.status
|
|
734
|
+
throw error
|
|
735
|
+
}
|
|
736
|
+
const data = await response.json().catch((error) => {
|
|
737
|
+
if (error.name === 'AbortError') throw error
|
|
738
|
+
return null
|
|
739
|
+
})
|
|
740
|
+
if (data?.error) {
|
|
741
|
+
const error = new Error('Unexpected model list response')
|
|
742
|
+
error.name = 'AI_BAD_RESPONSE'
|
|
743
|
+
throw error
|
|
744
|
+
}
|
|
745
|
+
return extractAiModels(provider, data)
|
|
746
|
+
})(),
|
|
747
|
+
deadline,
|
|
748
|
+
])
|
|
749
|
+
return { ok: true, models, code: models.length === 0 ? 'empty' : 'ok', detail: '' }
|
|
750
|
+
} catch (error) {
|
|
751
|
+
if (error.name === 'AbortError') return { ok: false, code: 'cancelled', models: [], detail: '' }
|
|
752
|
+
if (error.name === 'AI_TIMEOUT') return { ok: false, code: 'timeout', models: [], detail: '' }
|
|
753
|
+
const code = error.status ? testHttpCode(error.status) : error.name === 'AI_BAD_RESPONSE' ? 'bad-response' : 'network'
|
|
754
|
+
return { ok: false, code, models: [], detail: String(error?.message ?? error).slice(0, 160) }
|
|
755
|
+
} finally {
|
|
756
|
+
clearTimeout(timer)
|
|
757
|
+
signal?.removeEventListener('abort', abort)
|
|
758
|
+
}
|
|
759
|
+
}
|
|
631
760
|
const aiRequest = (cfg, userContent, testing = false) => {
|
|
632
761
|
const { provider, baseUrl, model, apiKey } = normalizeAiConfig(cfg)
|
|
633
762
|
const messages = [{ role: 'user', content: userContent }]
|
|
@@ -830,7 +959,7 @@ window.__ModuleLoader__.load({
|
|
|
830
959
|
.sss-trigger { display: flex; align-items: center; gap: 8px; width: 100%; box-sizing: border-box; height: 32px; padding: 0 9px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-dimmed); font: inherit; font-size: 14px; line-height: 22px; cursor: pointer; text-align: left; }
|
|
831
960
|
.sss-trigger:hover { border-color: var(--dsw-alias-label-dimmed); color: var(--dsw-alias-label-secondary); }
|
|
832
961
|
.sss-trigger:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; }
|
|
833
|
-
.sss-page-overlay { position: fixed; inset: 0; z-index:
|
|
962
|
+
.sss-page-overlay { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: flex-start; justify-content: center; padding: 72px 20px 32px; background: var(--dsw-alias-bg-mask, rgb(0 0 0 / 36%)); }
|
|
834
963
|
.sss-page { display: flex; flex-direction: column; width: min(760px, 100%); max-height: min(680px, calc(100vh - 104px)); overflow: hidden; background: var(--dsw-alias-bg-layer-3); border: 1px solid var(--dsw-alias-border-l1); border-radius: 14px; box-shadow: var(--dsw-shadow-lv3); }
|
|
835
964
|
.sss-page-head { flex: none; display: flex; align-items: center; gap: 8px; height: 52px; padding: 0 10px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
|
|
836
965
|
.sss-page-input { flex: 1; min-width: 0; height: 38px; padding: 0 6px; border: none; outline: none; background: transparent; font-size: 16px; line-height: 24px; color: var(--dsw-alias-label-primary); }
|
|
@@ -951,9 +1080,69 @@ window.__ModuleLoader__.load({
|
|
|
951
1080
|
return typeof value === 'string' ? value : ''
|
|
952
1081
|
}
|
|
953
1082
|
|
|
1083
|
+
const serializeHarvest = (harvest) => {
|
|
1084
|
+
const raw = {}
|
|
1085
|
+
for (const [slot, map] of harvest) {
|
|
1086
|
+
if (!(map instanceof Map) || map.size === 0) continue
|
|
1087
|
+
raw[slot] = Object.fromEntries([...map].filter(([, record]) => record && typeof record.title === 'string' && record.title !== ''))
|
|
1088
|
+
}
|
|
1089
|
+
return raw
|
|
1090
|
+
}
|
|
1091
|
+
const deserializeHarvest = (raw) => {
|
|
1092
|
+
const harvest = new Map()
|
|
1093
|
+
if (!raw || typeof raw !== 'object') return harvest
|
|
1094
|
+
for (const [slot, slotValue] of Object.entries(raw)) {
|
|
1095
|
+
if (!slotValue || typeof slotValue !== 'object') continue
|
|
1096
|
+
const map = new Map()
|
|
1097
|
+
for (const [key, record] of Object.entries(slotValue)) {
|
|
1098
|
+
if (!record || typeof record !== 'object' || typeof record.title !== 'string' || record.title === '') continue
|
|
1099
|
+
const inner = Array.isArray(record.inner)
|
|
1100
|
+
? record.inner.filter((op) => op && typeof op.label === 'string').map((op) => ({ label: op.label, text: typeof op.text === 'string' ? op.text : '' }))
|
|
1101
|
+
: []
|
|
1102
|
+
map.set(key, {
|
|
1103
|
+
title: record.title,
|
|
1104
|
+
idx: Number.isFinite(record.idx) ? record.idx : 0,
|
|
1105
|
+
text: typeof record.text === 'string' ? record.text : '',
|
|
1106
|
+
inner,
|
|
1107
|
+
})
|
|
1108
|
+
}
|
|
1109
|
+
if (map.size > 0) harvest.set(slot, map)
|
|
1110
|
+
}
|
|
1111
|
+
return harvest
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const readIndexCache = () => {
|
|
1115
|
+
if (typeof localStorage === 'undefined') return null
|
|
1116
|
+
try {
|
|
1117
|
+
const raw = JSON.parse(localStorage.getItem(INDEX_STORAGE_KEY) ?? 'null')
|
|
1118
|
+
if (!raw || raw.version !== 1 || typeof raw.fingerprint !== 'string' || raw.fingerprint === '') return null
|
|
1119
|
+
return {
|
|
1120
|
+
fingerprint: typeof raw.fingerprint === 'string' ? raw.fingerprint : null,
|
|
1121
|
+
harvest: deserializeHarvest(raw.harvest),
|
|
1122
|
+
}
|
|
1123
|
+
} catch {
|
|
1124
|
+
return null
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const otherPluginFingerprint = (plugins) => {
|
|
1129
|
+
const rows = Array.isArray(plugins) ? plugins : []
|
|
1130
|
+
const isSelf = (row) => [
|
|
1131
|
+
row?.id, row?.name, row?.npm, row?.package, row?.packageName,
|
|
1132
|
+
].some((value) => typeof value === 'string' && value.includes(NPM_NAME))
|
|
1133
|
+
return JSON.stringify(rows.filter((row) => row && !isSelf(row)).map((row) => [
|
|
1134
|
+
String(row.id ?? row.name ?? ''),
|
|
1135
|
+
String(row.npm ?? row.package ?? row.packageName ?? ''),
|
|
1136
|
+
String(row.version ?? ''),
|
|
1137
|
+
].sort()).sort())
|
|
1138
|
+
}
|
|
1139
|
+
|
|
954
1140
|
function createModel(ctx) {
|
|
1141
|
+
const initialCache = readIndexCache()
|
|
1142
|
+
const cachedFingerprint = initialCache?.fingerprint ?? null
|
|
955
1143
|
/** id → harvested visible title, per slot. */
|
|
956
|
-
const harvest = new Map()
|
|
1144
|
+
const harvest = initialCache?.harvest ?? new Map()
|
|
1145
|
+
let fingerprint = cachedFingerprint
|
|
957
1146
|
|
|
958
1147
|
const entriesOf = (slot) => {
|
|
959
1148
|
try { return ctx.slots.entries(slot) ?? [] } catch { return [] }
|
|
@@ -1264,7 +1453,53 @@ window.__ModuleLoader__.load({
|
|
|
1264
1453
|
: a.order - b.order)
|
|
1265
1454
|
}
|
|
1266
1455
|
|
|
1267
|
-
|
|
1456
|
+
const slotsFingerprint = () => otherPluginFingerprint([
|
|
1457
|
+
...sortEntries(entriesOf('settings.plugin.item')).map((entry) => ({
|
|
1458
|
+
id: String(entry.options?.id ?? entry.options?.key ?? ''),
|
|
1459
|
+
name: optionLabel(entry.options?.label),
|
|
1460
|
+
locale: String(entry.options?.locale ?? ''),
|
|
1461
|
+
})),
|
|
1462
|
+
...sortEntries(entriesOf('web-ui.plugin.item')).map((entry) => ({
|
|
1463
|
+
id: String(entry.options?.id ?? entry.options?.key ?? ''),
|
|
1464
|
+
name: optionLabel(entry.options?.label),
|
|
1465
|
+
locale: String(entry.options?.locale ?? ''),
|
|
1466
|
+
})),
|
|
1467
|
+
])
|
|
1468
|
+
|
|
1469
|
+
const resetHarvest = () => { harvest.clear(); warmed = false }
|
|
1470
|
+
|
|
1471
|
+
const persistIndex = (nextFingerprint = fingerprint) => {
|
|
1472
|
+
if (typeof nextFingerprint !== 'string' || nextFingerprint === '' || harvest.size === 0) return false
|
|
1473
|
+
fingerprint = nextFingerprint
|
|
1474
|
+
if (typeof localStorage === 'undefined') return false
|
|
1475
|
+
try {
|
|
1476
|
+
localStorage.setItem(INDEX_STORAGE_KEY, JSON.stringify({
|
|
1477
|
+
version: 1,
|
|
1478
|
+
fingerprint,
|
|
1479
|
+
updatedAt: Date.now(),
|
|
1480
|
+
harvest: serializeHarvest(harvest),
|
|
1481
|
+
}))
|
|
1482
|
+
return true
|
|
1483
|
+
} catch {
|
|
1484
|
+
return false
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
const indexFingerprintReady = () => typeof fingerprint === 'string' && fingerprint !== ''
|
|
1489
|
+
|
|
1490
|
+
const applyIndexFingerprint = (nextFingerprint) => {
|
|
1491
|
+
if (typeof nextFingerprint !== 'string' || nextFingerprint === '') return false
|
|
1492
|
+
if (nextFingerprint === fingerprint) {
|
|
1493
|
+
if (nextFingerprint === cachedFingerprint) warmed = true
|
|
1494
|
+
return false
|
|
1495
|
+
}
|
|
1496
|
+
const wasPopulated = harvest.size > 0
|
|
1497
|
+
fingerprint = nextFingerprint
|
|
1498
|
+
resetHarvest()
|
|
1499
|
+
return wasPopulated
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
return { readSections, readDeep, runHarvest, persistIndex, applyIndexFingerprint, resetHarvest, slotsFingerprint, indexFingerprintReady }
|
|
1268
1503
|
}
|
|
1269
1504
|
|
|
1270
1505
|
/* ---------- DOM-level navigation into the settings shell ---------- */
|
|
@@ -1316,7 +1551,7 @@ window.__ModuleLoader__.load({
|
|
|
1316
1551
|
const warmAllSections = async (model, onStart, onDone) => {
|
|
1317
1552
|
if (warmed || typeof document === 'undefined') return true
|
|
1318
1553
|
const sections = model.readSections()
|
|
1319
|
-
if (sections.length === 0) return
|
|
1554
|
+
if (sections.length === 0) return false
|
|
1320
1555
|
const original = activeSectionLabel()
|
|
1321
1556
|
if (original === '') return false // settings panel not open yet; retry on next mount
|
|
1322
1557
|
warmed = true
|
|
@@ -1336,6 +1571,8 @@ window.__ModuleLoader__.load({
|
|
|
1336
1571
|
} finally {
|
|
1337
1572
|
if (typeof onDone === 'function') onDone()
|
|
1338
1573
|
}
|
|
1574
|
+
if (visited === 0) return false
|
|
1575
|
+
model.persistIndex()
|
|
1339
1576
|
log('info', '预载设置索引', { total: sections.length, visited })
|
|
1340
1577
|
return true
|
|
1341
1578
|
}
|
|
@@ -1411,7 +1648,35 @@ window.__ModuleLoader__.load({
|
|
|
1411
1648
|
}, 'settings-search: styles')
|
|
1412
1649
|
|
|
1413
1650
|
const model = createModel(ctx)
|
|
1651
|
+
const indexControls = { resetHarvest: model.resetHarvest, persistIndex: model.persistIndex }
|
|
1414
1652
|
const navigator = createNavigator(ctx)
|
|
1653
|
+
let pluginManagerSyncToken = 0
|
|
1654
|
+
|
|
1655
|
+
const syncPluginManagerIndex = async (face) => {
|
|
1656
|
+
if (face === null || !face.isLoopback) return
|
|
1657
|
+
const token = ++pluginManagerSyncToken
|
|
1658
|
+
try {
|
|
1659
|
+
const rows = await face.list()
|
|
1660
|
+
if (token !== pluginManagerSyncToken) return
|
|
1661
|
+
if (model.applyIndexFingerprint('manager:' + otherPluginFingerprint(rows))) {
|
|
1662
|
+
window.dispatchEvent(new CustomEvent(INDEX_INVALID_EVENT))
|
|
1663
|
+
log('info', '检测到其它插件变化,将重建设置索引', {})
|
|
1664
|
+
}
|
|
1665
|
+
} catch {
|
|
1666
|
+
// Keep the last trusted fingerprint when manager inspection fails.
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
ctx.inject(['pluginManager'], (inner) => {
|
|
1671
|
+
const face = inner.pluginManager ?? null
|
|
1672
|
+
const sync = () => { void syncPluginManagerIndex(face) }
|
|
1673
|
+
sync()
|
|
1674
|
+
const unsubscribe = typeof face?.onChange === 'function' ? face.onChange(sync) : null
|
|
1675
|
+
return () => {
|
|
1676
|
+
pluginManagerSyncToken += 1
|
|
1677
|
+
if (typeof unsubscribe === 'function') unsubscribe()
|
|
1678
|
+
}
|
|
1679
|
+
}, 'settings-search: plugin manager index fingerprint')
|
|
1415
1680
|
|
|
1416
1681
|
/** Read the model fields from DSH; credentials are never requested. */
|
|
1417
1682
|
const readDshModelConfig = async () => {
|
|
@@ -1597,6 +1862,24 @@ window.__ModuleLoader__.load({
|
|
|
1597
1862
|
}
|
|
1598
1863
|
}, [])
|
|
1599
1864
|
|
|
1865
|
+
React.useEffect(() => {
|
|
1866
|
+
// If no plugin manager is present, the slot registrations are the
|
|
1867
|
+
// only available change signal. Waiting briefly avoids racing the
|
|
1868
|
+
// optional manager during startup.
|
|
1869
|
+
const fallback = setTimeout(() => {
|
|
1870
|
+
if (model.indexFingerprintReady()) return
|
|
1871
|
+
if (model.applyIndexFingerprint(model.slotsFingerprint())) {
|
|
1872
|
+
window.dispatchEvent(new CustomEvent(INDEX_INVALID_EVENT))
|
|
1873
|
+
}
|
|
1874
|
+
}, 3000)
|
|
1875
|
+
const onInvalid = () => { setWarming(false); setTick((value) => value + 1) }
|
|
1876
|
+
window.addEventListener(INDEX_INVALID_EVENT, onInvalid)
|
|
1877
|
+
return () => {
|
|
1878
|
+
clearTimeout(fallback)
|
|
1879
|
+
window.removeEventListener(INDEX_INVALID_EVENT, onInvalid)
|
|
1880
|
+
}
|
|
1881
|
+
}, [])
|
|
1882
|
+
|
|
1600
1883
|
React.useEffect(() => {
|
|
1601
1884
|
const onWelcome = (event) => { if (event?.detail === true) setShowWelcome(true) }
|
|
1602
1885
|
window.addEventListener(WELCOME_EVENT, onWelcome)
|
|
@@ -1924,7 +2207,6 @@ window.__ModuleLoader__.load({
|
|
|
1924
2207
|
? React.createElement(Welcome, { t, onClose: dismissWelcome, onOpenPanel: openPanelFromWelcome })
|
|
1925
2208
|
: null,
|
|
1926
2209
|
]),
|
|
1927
|
-
React.createElement('div', { className: 'sss-wrap sss-top' }, [pageTriggerButton]),
|
|
1928
2210
|
React.createElement('div', {
|
|
1929
2211
|
className: 'sss-page-overlay',
|
|
1930
2212
|
onMouseDown: (event) => { if (event.target === event.currentTarget) setPageOpen(false) },
|
|
@@ -2065,9 +2347,13 @@ window.__ModuleLoader__.load({
|
|
|
2065
2347
|
const [apiKey, setApiKey] = React.useState(cfg.apiKey)
|
|
2066
2348
|
const providerDrafts = React.useRef({})
|
|
2067
2349
|
const testAbortRef = React.useRef(null)
|
|
2350
|
+
const modelsAbortRef = React.useRef(null)
|
|
2068
2351
|
const [aiSaved, setAiSaved] = React.useState(false)
|
|
2069
2352
|
const [testState, setTestState] = React.useState('idle') // idle | loading | ok | fail
|
|
2070
2353
|
const [testMsg, setTestMsg] = React.useState('')
|
|
2354
|
+
const [aiModels, setAiModels] = React.useState([])
|
|
2355
|
+
const [modelsState, setModelsState] = React.useState('idle') // idle | loading | ready | fail
|
|
2356
|
+
const [modelsMsg, setModelsMsg] = React.useState('')
|
|
2071
2357
|
const [dshImportState, setDshImportState] = React.useState('idle') // idle | loading | ok | fail
|
|
2072
2358
|
const [dshImportMsg, setDshImportMsg] = React.useState('')
|
|
2073
2359
|
const [, setLogVersion] = React.useState(0)
|
|
@@ -2075,6 +2361,8 @@ window.__ModuleLoader__.load({
|
|
|
2075
2361
|
const [logCopyFail, setLogCopyFail] = React.useState(false)
|
|
2076
2362
|
const [logExportFail, setLogExportFail] = React.useState(false)
|
|
2077
2363
|
const [logCleared, setLogCleared] = React.useState(false)
|
|
2364
|
+
const [indexState, setIndexState] = React.useState('idle')
|
|
2365
|
+
const [warming, setWarming] = React.useState(false)
|
|
2078
2366
|
const [confirmClear, setConfirmClear] = React.useState(false)
|
|
2079
2367
|
const [confirmClearAi, setConfirmClearAi] = React.useState(false)
|
|
2080
2368
|
|
|
@@ -2106,6 +2394,8 @@ window.__ModuleLoader__.load({
|
|
|
2106
2394
|
return () => testAbortRef.current?.abort()
|
|
2107
2395
|
}, [provider, baseUrl, model, apiKey])
|
|
2108
2396
|
|
|
2397
|
+
React.useEffect(() => () => modelsAbortRef.current?.abort(), [provider, baseUrl, apiKey])
|
|
2398
|
+
|
|
2109
2399
|
React.useEffect(() => {
|
|
2110
2400
|
const sync = () => setLogVersion((v) => v + 1)
|
|
2111
2401
|
window.addEventListener(LOG_EVENT, sync)
|
|
@@ -2122,6 +2412,68 @@ window.__ModuleLoader__.load({
|
|
|
2122
2412
|
setProvider(next)
|
|
2123
2413
|
setBaseUrl(draft?.baseUrl ?? (next === 'ollama' ? OLLAMA_BASE_URL : ''))
|
|
2124
2414
|
setModel(draft?.model ?? '')
|
|
2415
|
+
resetModelList()
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
const resetModelList = () => {
|
|
2419
|
+
modelsAbortRef.current?.abort()
|
|
2420
|
+
setAiModels([])
|
|
2421
|
+
setModelsState('idle')
|
|
2422
|
+
setModelsMsg('')
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
const modelsErrorHint = (code) => {
|
|
2426
|
+
if (code === 'need-config') return t('aiModelsNeedConfig')
|
|
2427
|
+
const cloudKeys = {
|
|
2428
|
+
auth: 'aiTestErrAuth',
|
|
2429
|
+
forbidden: 'aiTestErrForbidden',
|
|
2430
|
+
'not-found': 'aiTestErrNotFound',
|
|
2431
|
+
'rate-limit': 'aiTestErrRateLimit',
|
|
2432
|
+
'bad-request': 'aiTestErrBadRequest',
|
|
2433
|
+
server: 'aiTestErrServer',
|
|
2434
|
+
timeout: 'aiTestErrTimeout',
|
|
2435
|
+
network: 'aiTestErrNetwork',
|
|
2436
|
+
'bad-response': 'aiTestErrBadResponse',
|
|
2437
|
+
'unsafe-url': 'aiTestErrUnsafeUrl',
|
|
2438
|
+
}
|
|
2439
|
+
const ollamaKeys = { ...cloudKeys, network: 'aiOllamaErrNetwork', forbidden: 'aiOllamaErrForbidden', timeout: 'aiOllamaErrTimeout' }
|
|
2440
|
+
return t((provider === 'ollama' ? ollamaKeys : cloudKeys)[code] ?? 'aiTestErrHttp')
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
const loadModels = async () => {
|
|
2444
|
+
if (modelsState === 'loading') return
|
|
2445
|
+
const source = normalizeAiConfig({ provider, baseUrl, model, apiKey })
|
|
2446
|
+
if (!modelsUrl(source.provider, source.baseUrl) || (source.provider !== 'ollama' && !source.apiKey)) {
|
|
2447
|
+
setModelsState('fail')
|
|
2448
|
+
setModelsMsg(t('aiModelsFail').replace('{hint}', t('aiModelsNeedConfig')).replace('{detail}', ''))
|
|
2449
|
+
return
|
|
2450
|
+
}
|
|
2451
|
+
const controller = new AbortController()
|
|
2452
|
+
modelsAbortRef.current?.abort()
|
|
2453
|
+
modelsAbortRef.current = controller
|
|
2454
|
+
setModelsState('loading')
|
|
2455
|
+
setModelsMsg('')
|
|
2456
|
+
const startedAt = Date.now()
|
|
2457
|
+
log('info', '获取 AI 模型列表', { provider: source.provider })
|
|
2458
|
+
const result = await fetchAiModels(source, controller.signal)
|
|
2459
|
+
if (controller.signal.aborted) return
|
|
2460
|
+
if (modelsAbortRef.current !== controller) return
|
|
2461
|
+
modelsAbortRef.current = null
|
|
2462
|
+
if (result.ok) {
|
|
2463
|
+
setAiModels(result.models)
|
|
2464
|
+
setModelsState('ready')
|
|
2465
|
+
setModelsMsg(result.models.length === 0
|
|
2466
|
+
? t('aiModelsEmpty')
|
|
2467
|
+
: t('aiModelsLoaded').replace('{count}', String(result.models.length)))
|
|
2468
|
+
log('info', '获取 AI 模型列表成功', { count: result.models.length, duration: Date.now() - startedAt })
|
|
2469
|
+
} else {
|
|
2470
|
+
setAiModels([])
|
|
2471
|
+
setModelsState('fail')
|
|
2472
|
+
const hint = modelsErrorHint(result.code)
|
|
2473
|
+
const detail = result.detail ? `(${result.detail})` : ''
|
|
2474
|
+
setModelsMsg(t('aiModelsFail').replace('{hint}', hint).replace('{detail}', detail))
|
|
2475
|
+
log('error', '获取 AI 模型列表失败', { code: result.code, detail: result.detail, duration: Date.now() - startedAt })
|
|
2476
|
+
}
|
|
2125
2477
|
}
|
|
2126
2478
|
|
|
2127
2479
|
const saveAi = () => {
|
|
@@ -2148,6 +2500,7 @@ window.__ModuleLoader__.load({
|
|
|
2148
2500
|
setProvider(imported.provider)
|
|
2149
2501
|
setBaseUrl(imported.baseUrl)
|
|
2150
2502
|
setModel(imported.model)
|
|
2503
|
+
resetModelList()
|
|
2151
2504
|
setDshImportState('ok')
|
|
2152
2505
|
setDshImportMsg(t('aiDshImportOk'))
|
|
2153
2506
|
log('info', '导入 DSH 模型配置', { provider: imported.provider, model: imported.model })
|
|
@@ -2164,6 +2517,25 @@ window.__ModuleLoader__.load({
|
|
|
2164
2517
|
setSearchStyleState(style)
|
|
2165
2518
|
}
|
|
2166
2519
|
|
|
2520
|
+
const rebuildIndex = async () => {
|
|
2521
|
+
if (indexState === 'loading' || warming) return
|
|
2522
|
+
setIndexState('loading')
|
|
2523
|
+
setWarming(true)
|
|
2524
|
+
indexControls.resetHarvest()
|
|
2525
|
+
try {
|
|
2526
|
+
const done = await warmAllSections(model, () => setWarming(true), () => setWarming(false))
|
|
2527
|
+
if (!done) throw new Error('index rebuild did not visit any sections')
|
|
2528
|
+
indexControls.persistIndex()
|
|
2529
|
+
setIndexState('done')
|
|
2530
|
+
window.dispatchEvent(new CustomEvent(INDEX_REBUILD_DONE_EVENT))
|
|
2531
|
+
setTimeout(() => setIndexState((state) => state === 'done' ? 'idle' : state), 2400)
|
|
2532
|
+
} catch (error) {
|
|
2533
|
+
setIndexState('idle')
|
|
2534
|
+
setWarming(false)
|
|
2535
|
+
log('error', '手动重建设置索引失败', { detail: String(error?.message ?? error).slice(0, 160) })
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2167
2539
|
const updateShortcutEnabled = (enabled) => {
|
|
2168
2540
|
setSearchShortcutEnabled(enabled)
|
|
2169
2541
|
setShortcutOn(enabled)
|
|
@@ -2180,6 +2552,7 @@ window.__ModuleLoader__.load({
|
|
|
2180
2552
|
setModel('')
|
|
2181
2553
|
setApiKey('')
|
|
2182
2554
|
providerDrafts.current = {}
|
|
2555
|
+
resetModelList()
|
|
2183
2556
|
setAiSaved(false)
|
|
2184
2557
|
log('info', '清除 API 配置', {})
|
|
2185
2558
|
}
|
|
@@ -2394,6 +2767,23 @@ window.__ModuleLoader__.load({
|
|
|
2394
2767
|
]),
|
|
2395
2768
|
React.createElement('p', { className: 'sss-vr-hint' }, t('shortcutHint')),
|
|
2396
2769
|
]),
|
|
2770
|
+
React.createElement('div', { className: 'sss-vr-field' }, [
|
|
2771
|
+
React.createElement('div', { className: 'sss-vr-field-head' }, [
|
|
2772
|
+
React.createElement('span', { className: 'sss-vr-label' }, t('indexTitle')),
|
|
2773
|
+
]),
|
|
2774
|
+
React.createElement('p', { className: 'sss-vr-hint' }, t('indexDesc')),
|
|
2775
|
+
React.createElement('div', { className: 'sss-vr-actions' }, [
|
|
2776
|
+
React.createElement('button', {
|
|
2777
|
+
className: 'sss-vr-btn',
|
|
2778
|
+
type: 'button',
|
|
2779
|
+
disabled: indexState === 'loading' || warming,
|
|
2780
|
+
onClick: () => { void rebuildIndex() },
|
|
2781
|
+
}, indexState === 'loading' || warming ? t('indexRebuilding') : t('indexRebuild')),
|
|
2782
|
+
]),
|
|
2783
|
+
indexState === 'done'
|
|
2784
|
+
? React.createElement('p', { className: 'sss-vr-note', style: { color: '#1e8e3e' } }, t('indexDone'))
|
|
2785
|
+
: null,
|
|
2786
|
+
]),
|
|
2397
2787
|
React.createElement('div', { className: 'sss-vr-field sss-vr-form' }, [
|
|
2398
2788
|
React.createElement('div', { className: 'sss-vr-field-head' }, [
|
|
2399
2789
|
React.createElement('label', { className: 'sss-vr-label' }, t('aiProvider')),
|
|
@@ -2428,44 +2818,56 @@ window.__ModuleLoader__.load({
|
|
|
2428
2818
|
'data-lpignore': 'true',
|
|
2429
2819
|
'data-1p-ignore': 'true',
|
|
2430
2820
|
'data-form-type': 'other',
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
'
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2821
|
+
onChange: (event) => { setBaseUrl(event.target.value); resetModelList() },
|
|
2822
|
+
}),
|
|
2823
|
+
React.createElement('p', { className: 'sss-vr-hint' }, t('aiBaseUrlHint')),
|
|
2824
|
+
React.createElement('div', { className: 'sss-vr-field-head' }, [
|
|
2825
|
+
React.createElement('label', { className: 'sss-vr-label' }, t('aiModel')),
|
|
2826
|
+
React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: modelsState === 'loading', onClick: () => { void loadModels() } },
|
|
2827
|
+
modelsState === 'loading' ? t('aiModelsLoading') : t('aiModelsLoad')),
|
|
2828
|
+
]),
|
|
2829
|
+
React.createElement('p', { className: 'sss-vr-hint' }, t('aiModelHint')),
|
|
2830
|
+
React.createElement('input', {
|
|
2831
|
+
className: 'sss-vr-input',
|
|
2832
|
+
type: 'text',
|
|
2833
|
+
name: 'dsh-settings-search-model',
|
|
2834
|
+
'aria-label': t('aiModel'),
|
|
2835
|
+
value: model,
|
|
2836
|
+
list: 'dsh-settings-search-model-options',
|
|
2837
|
+
placeholder: provider === 'ollama' ? 'qwen3:8b'
|
|
2838
|
+
: provider === 'anthropic' ? 'claude-sonnet-4-20250514' : 'gpt-4o-mini',
|
|
2839
|
+
spellCheck: false,
|
|
2840
|
+
autoComplete: 'off',
|
|
2841
|
+
'data-lpignore': 'true',
|
|
2842
|
+
'data-1p-ignore': 'true',
|
|
2843
|
+
'data-form-type': 'other',
|
|
2844
|
+
onChange: (event) => setModel(event.target.value),
|
|
2845
|
+
}),
|
|
2846
|
+
React.createElement('datalist', { id: 'dsh-settings-search-model-options' },
|
|
2847
|
+
aiModels.map((item) => React.createElement('option', { key: item, value: item }, item))),
|
|
2848
|
+
modelsMsg
|
|
2849
|
+
? React.createElement('p', {
|
|
2850
|
+
className: modelsState === 'fail' ? 'sss-vr-note' : 'sss-vr-hint',
|
|
2851
|
+
style: modelsState === 'fail' ? { color: 'var(--dsw-alias-label-danger)' } : undefined,
|
|
2852
|
+
}, modelsMsg)
|
|
2853
|
+
: null,
|
|
2854
|
+
provider !== 'ollama' ? React.createElement('div', { className: 'sss-vr-field-head' }, [
|
|
2855
|
+
React.createElement('label', { className: 'sss-vr-label' }, t('aiApiKey')),
|
|
2856
|
+
]) : null,
|
|
2857
|
+
provider !== 'ollama' ? React.createElement('input', {
|
|
2858
|
+
className: 'sss-vr-input sss-vr-mask',
|
|
2859
|
+
type: 'text',
|
|
2860
|
+
name: 'dsh-settings-search-api-key',
|
|
2861
|
+
'aria-label': t('aiApiKey'),
|
|
2862
|
+
value: apiKey,
|
|
2863
|
+
placeholder: 'sk-…',
|
|
2864
|
+
spellCheck: false,
|
|
2865
|
+
autoComplete: 'off',
|
|
2866
|
+
'data-lpignore': 'true',
|
|
2867
|
+
'data-1p-ignore': 'true',
|
|
2868
|
+
'data-form-type': 'other',
|
|
2869
|
+
onChange: (event) => { setApiKey(event.target.value); resetModelList() },
|
|
2870
|
+
}) : null,
|
|
2469
2871
|
React.createElement('div', { className: 'sss-vr-actions' }, [
|
|
2470
2872
|
React.createElement('button', { className: 'sss-vr-btn sss-vr-primary', type: 'button', onClick: () => { void saveAi() } },
|
|
2471
2873
|
aiSaved ? t('aiConfigSaved') : t('aiConfigSave')),
|
|
@@ -2513,6 +2915,8 @@ window.__ModuleLoader__.load({
|
|
|
2513
2915
|
state === 'loading' ? t('updChecking') : t('updCheck')),
|
|
2514
2916
|
React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: notesState === 'loading', onClick: () => { void fetchNotes() } },
|
|
2515
2917
|
notesState === 'loading' ? t('updFetchingNotes') : t('updReleaseNotes')),
|
|
2918
|
+
React.createElement('a', { className: 'sss-vr-link', href: REPO_URL, target: '_blank', rel: 'noopener noreferrer' },
|
|
2919
|
+
t('updRepo')),
|
|
2516
2920
|
]),
|
|
2517
2921
|
notesState === 'loaded'
|
|
2518
2922
|
? React.createElement('div', { className: 'sss-vr-notes' }, notesBody)
|
|
@@ -2529,6 +2933,8 @@ window.__ModuleLoader__.load({
|
|
|
2529
2933
|
: null,
|
|
2530
2934
|
React.createElement('div', { className: 'sss-vr-actions' }, [
|
|
2531
2935
|
React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void exportLogs() } }, t('logExport')),
|
|
2936
|
+
React.createElement('a', { className: 'sss-vr-link', href: ISSUES_URL, target: '_blank', rel: 'noopener noreferrer' },
|
|
2937
|
+
t('logIssues')),
|
|
2532
2938
|
React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void copyLogs() } },
|
|
2533
2939
|
logCopied ? t('logCopied') : t('logCopy')),
|
|
2534
2940
|
React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: LOGS.length === 0, onClick: () => requestClear() },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectivex666/dsh-settings-search",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.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",
|
package/scripts/ai.test.mjs
CHANGED
|
@@ -11,6 +11,8 @@ const instrumented = source.replace(hook, `
|
|
|
11
11
|
Object.assign(exports, {
|
|
12
12
|
aiConfig, setAiConfig, aiConfigReady, ollamaChatUrl,
|
|
13
13
|
requestUserAi, requestAiKeywords, testAiService, LOGS,
|
|
14
|
+
modelsUrl, fetchAiModels,
|
|
15
|
+
readIndexCache, serializeHarvest,
|
|
14
16
|
})
|
|
15
17
|
${hook}`)
|
|
16
18
|
const storage = () => {
|
|
@@ -30,6 +32,8 @@ const plain = (value) => JSON.parse(JSON.stringify(value))
|
|
|
30
32
|
const ollama = { provider: 'ollama', baseUrl: '', model: 'local-model:7b', apiKey: '' }
|
|
31
33
|
const openai = { provider: 'openai', baseUrl: 'https://example.test/v1', model: 'cloud-model', apiKey: 'test-secret' }
|
|
32
34
|
const anthropic = { ...openai, provider: 'anthropic', baseUrl: 'https://example.test' }
|
|
35
|
+
const modelHook = ' const model = createModel(ctx)'
|
|
36
|
+
|
|
33
37
|
const reply = (provider, content = '{"keywords":["Language"]}') => provider === 'ollama'
|
|
34
38
|
? { message: { role: 'assistant', content }, done: true }
|
|
35
39
|
: provider === 'anthropic'
|
|
@@ -49,16 +53,73 @@ function load(fetchImpl = async () => json(reply('ollama'))) {
|
|
|
49
53
|
setTimeout,
|
|
50
54
|
clearTimeout,
|
|
51
55
|
fetch: async (url, options) => {
|
|
52
|
-
const call = { url, ...options, body: JSON.parse(options.body) }
|
|
56
|
+
const call = { url, ...options, body: options.body ? JSON.parse(options.body) : undefined }
|
|
53
57
|
calls.push(call)
|
|
54
58
|
return fetchImpl(call)
|
|
55
59
|
},
|
|
56
60
|
}
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
const instrumentedModel = instrumented.replace(modelHook, modelHook + '\n exports.__indexTestModel = model')
|
|
62
|
+
vm.runInNewContext(instrumentedModel, sandbox, { filename: 'lib/client.js' })
|
|
63
|
+
const api = handoff.factory(() => ({ createElement: () => null }))
|
|
59
64
|
return { api, calls, sandbox }
|
|
60
65
|
}
|
|
61
66
|
|
|
67
|
+
test('persistent settings index reuses matching fingerprints and drops invalid rows', () => {
|
|
68
|
+
const { api, sandbox } = load()
|
|
69
|
+
const records = {
|
|
70
|
+
'settings.general.item': {
|
|
71
|
+
row1: { title: 'Enable Workshop card', idx: 1, text: 'Enable Workshop card and save.', inner: [] },
|
|
72
|
+
broken: { idx: 2 },
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
assert.equal(Object.keys(api.serializeHarvest(new Map())).length, 0)
|
|
76
|
+
sandbox.localStorage.setItem('dsh-settings-search:index:v1', JSON.stringify({
|
|
77
|
+
version: 1,
|
|
78
|
+
fingerprint: 'manager:test',
|
|
79
|
+
updatedAt: 1,
|
|
80
|
+
harvest: records,
|
|
81
|
+
}))
|
|
82
|
+
const cached = api.readIndexCache()
|
|
83
|
+
assert.equal(cached.fingerprint, 'manager:test')
|
|
84
|
+
assert.equal(cached.harvest.get('settings.general.item')?.get('row1')?.title, 'Enable Workshop card')
|
|
85
|
+
assert.equal(cached.harvest.get('settings.general.item')?.has('broken'), false)
|
|
86
|
+
|
|
87
|
+
const ctx = {
|
|
88
|
+
effect: (fn) => fn(),
|
|
89
|
+
get: () => null,
|
|
90
|
+
inject: () => {},
|
|
91
|
+
locale: {
|
|
92
|
+
register: () => {},
|
|
93
|
+
bind: () => () => '',
|
|
94
|
+
subscribe: () => () => {},
|
|
95
|
+
},
|
|
96
|
+
slots: {
|
|
97
|
+
register: (name, id, options) => ({ name, id, options }),
|
|
98
|
+
inject: (_slot, registrar) => registrar(),
|
|
99
|
+
entries: (slot) => slot === 'settings.general.item'
|
|
100
|
+
? [{ options: { id: 'row1', order: 1 } }]
|
|
101
|
+
: [],
|
|
102
|
+
subscribe: () => () => {},
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
api.apply(ctx)
|
|
106
|
+
const model = api.__indexTestModel
|
|
107
|
+
assert.equal(model.indexFingerprintReady(), true)
|
|
108
|
+
assert.equal(model.applyIndexFingerprint('manager:test'), false)
|
|
109
|
+
assert.ok(model.readDeep().some((item) => item.label === 'Enable Workshop card'))
|
|
110
|
+
assert.equal(model.applyIndexFingerprint('manager:changed'), true)
|
|
111
|
+
assert.equal(model.readDeep().some((item) => item.label === 'Enable Workshop card'), false)
|
|
112
|
+
assert.equal(model.persistIndex('manager:changed'), false)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('readIndexCache rejects malformed persisted index payloads', () => {
|
|
116
|
+
const { api, sandbox } = load()
|
|
117
|
+
for (const value of ['{', '{"version":1}', 'null']) {
|
|
118
|
+
sandbox.localStorage.setItem('dsh-settings-search:index:v1', value)
|
|
119
|
+
assert.equal(api.readIndexCache(), null)
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
|
|
62
123
|
test('Ollama configuration persists and requires a model but no key', () => {
|
|
63
124
|
const { api, sandbox } = load()
|
|
64
125
|
api.setAiConfig({ ...ollama, model: ' local-model:7b ' })
|
|
@@ -88,6 +149,59 @@ test('Ollama accepts root, native, compatibility, proxy and IPv6 base URLs', ()
|
|
|
88
149
|
assert.equal(api.ollamaChatUrl('https://example.test/ollama/v1/'), 'https://example.test/ollama/api/chat')
|
|
89
150
|
})
|
|
90
151
|
|
|
152
|
+
test('users discover selectable models from each supported API type', async () => {
|
|
153
|
+
const cases = [
|
|
154
|
+
{
|
|
155
|
+
cfg: ollama,
|
|
156
|
+
payload: { models: [{ name: 'qwen3:8b' }, { name: 'llama3.1:8b' }, { name: 'qwen3:8b' }] },
|
|
157
|
+
url: 'http://127.0.0.1:11434/api/tags',
|
|
158
|
+
headers: {},
|
|
159
|
+
models: ['qwen3:8b', 'llama3.1:8b'],
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
cfg: openai,
|
|
163
|
+
payload: { data: [{ id: 'gpt-4o-mini' }, { id: 'gpt-4.1-mini' }] },
|
|
164
|
+
url: 'https://example.test/v1/models',
|
|
165
|
+
headers: { authorization: `Bearer ${openai.apiKey}` },
|
|
166
|
+
models: ['gpt-4o-mini', 'gpt-4.1-mini'],
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
cfg: anthropic,
|
|
170
|
+
payload: { data: [{ id: 'claude-sonnet-4-20250514' }] },
|
|
171
|
+
url: 'https://example.test/v1/models',
|
|
172
|
+
headers: {
|
|
173
|
+
'x-api-key': anthropic.apiKey,
|
|
174
|
+
'anthropic-version': '2023-06-01',
|
|
175
|
+
'anthropic-dangerous-direct-browser-access': 'true',
|
|
176
|
+
},
|
|
177
|
+
models: ['claude-sonnet-4-20250514'],
|
|
178
|
+
},
|
|
179
|
+
]
|
|
180
|
+
for (const expected of cases) {
|
|
181
|
+
const { api, calls } = load(async () => json(expected.payload))
|
|
182
|
+
assert.deepEqual(plain(await api.fetchAiModels(expected.cfg)), { ok: true, models: expected.models, code: 'ok', detail: '' })
|
|
183
|
+
const [call] = calls
|
|
184
|
+
assert.equal(call.url, expected.url)
|
|
185
|
+
assert.equal(call.method, 'GET')
|
|
186
|
+
assert.equal(call.redirect, 'error')
|
|
187
|
+
assert.deepEqual(plain(call.headers), expected.headers)
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('model discovery rejects unsafe or incomplete sources before fetching', async () => {
|
|
192
|
+
const { api, calls } = load(async () => json({ data: [{ id: 'should-not-load' }] }))
|
|
193
|
+
assert.equal(api.modelsUrl('ollama', 'javascript:alert(1)'), '')
|
|
194
|
+
assert.deepEqual(plain(await api.fetchAiModels({ ...ollama, baseUrl: 'file:///models' })), { ok: false, code: 'unsafe-url', models: [], detail: '' })
|
|
195
|
+
assert.deepEqual(plain(await api.fetchAiModels({ ...openai, apiKey: '' })), { ok: false, code: 'need-config', models: [], detail: '' })
|
|
196
|
+
assert.equal(calls.length, 0)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('model discovery reports provider failures without inventing choices', async () => {
|
|
200
|
+
const { api, calls } = load(async () => json({ error: 'denied' }, 401))
|
|
201
|
+
assert.deepEqual(plain(await api.fetchAiModels(ollama)), { ok: false, code: 'auth', models: [], detail: 'HTTP 401' })
|
|
202
|
+
assert.equal(calls.length, 1)
|
|
203
|
+
})
|
|
204
|
+
|
|
91
205
|
test('Ollama search uses native non-streaming JSON and never sends a stored cloud key', async () => {
|
|
92
206
|
const { api, calls, sandbox } = load(async () => json(reply('ollama', '{"keywords":["Language","Language"," Theme ",42]}')))
|
|
93
207
|
api.setAiConfig({ ...ollama, apiKey: 'test-secret' })
|