@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 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
- - **模型名**:填写本机已安装的完整模型名(包括标签),可用 `ollama list` 查看。
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.11.0'
33
+ const PKG_VERSION = '1.12.0'
34
34
  const NPM_NAME = '@objectivex666/dsh-settings-search'
35
+ const REPO_URL = 'https://github.com/objectivex666/dsh-settings-search'
36
+ const ISSUES_URL = 'https://github.com/objectivex666/dsh-settings-search/issues/new/choose'
35
37
  const zh = {
36
38
  title: '设置',
37
39
  placeholder: '搜索设置…',
@@ -44,6 +46,11 @@ window.__ModuleLoader__.load({
44
46
  openSearch: '打开搜索',
45
47
  shortcutTitle: 'Ctrl+F / ⌘F 快捷键',
46
48
  shortcutHint: '在设置面板内按 Ctrl+F 或 ⌘F 聚焦搜索框;关闭后保留浏览器默认查找。',
49
+ indexTitle: '设置索引',
50
+ indexDesc: '索引保存在本机浏览器;只在首次安装或更新其它插件后重建,也可以手动重建。',
51
+ indexRebuild: '手动重建索引',
52
+ indexRebuilding: '正在重建索引…',
53
+ indexDone: '索引已重建 ✓',
47
54
  warming: '正在导入各分区选项…',
48
55
  empty: '没有匹配的设置',
49
56
  pages: '设置页',
@@ -69,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: 900; display: flex; align-items: flex-start; justify-content: center; padding: 72px 20px 32px; background: var(--dsw-alias-bg-mask, rgb(0 0 0 / 36%)); }
962
+ .sss-page-overlay { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: flex-start; justify-content: center; padding: 72px 20px 32px; background: var(--dsw-alias-bg-mask, rgb(0 0 0 / 36%)); }
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
- return { readSections, readDeep, runHarvest }
1456
+ const slotsFingerprint = () => otherPluginFingerprint([
1457
+ ...sortEntries(entriesOf('settings.plugin.item')).map((entry) => ({
1458
+ id: String(entry.options?.id ?? entry.options?.key ?? ''),
1459
+ name: optionLabel(entry.options?.label),
1460
+ locale: String(entry.options?.locale ?? ''),
1461
+ })),
1462
+ ...sortEntries(entriesOf('web-ui.plugin.item')).map((entry) => ({
1463
+ id: String(entry.options?.id ?? entry.options?.key ?? ''),
1464
+ name: optionLabel(entry.options?.label),
1465
+ locale: String(entry.options?.locale ?? ''),
1466
+ })),
1467
+ ])
1468
+
1469
+ const resetHarvest = () => { harvest.clear(); warmed = false }
1470
+
1471
+ const persistIndex = (nextFingerprint = fingerprint) => {
1472
+ if (typeof nextFingerprint !== 'string' || nextFingerprint === '' || harvest.size === 0) return false
1473
+ fingerprint = nextFingerprint
1474
+ if (typeof localStorage === 'undefined') return false
1475
+ try {
1476
+ localStorage.setItem(INDEX_STORAGE_KEY, JSON.stringify({
1477
+ version: 1,
1478
+ fingerprint,
1479
+ updatedAt: Date.now(),
1480
+ harvest: serializeHarvest(harvest),
1481
+ }))
1482
+ return true
1483
+ } catch {
1484
+ return false
1485
+ }
1486
+ }
1487
+
1488
+ const indexFingerprintReady = () => typeof fingerprint === 'string' && fingerprint !== ''
1489
+
1490
+ const applyIndexFingerprint = (nextFingerprint) => {
1491
+ if (typeof nextFingerprint !== 'string' || nextFingerprint === '') return false
1492
+ if (nextFingerprint === fingerprint) {
1493
+ if (nextFingerprint === cachedFingerprint) warmed = true
1494
+ return false
1495
+ }
1496
+ const wasPopulated = harvest.size > 0
1497
+ fingerprint = nextFingerprint
1498
+ resetHarvest()
1499
+ return wasPopulated
1500
+ }
1501
+
1502
+ return { readSections, readDeep, runHarvest, persistIndex, applyIndexFingerprint, resetHarvest, slotsFingerprint, indexFingerprintReady }
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 true
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
- onChange: (event) => setBaseUrl(event.target.value),
2432
- }),
2433
- React.createElement('p', { className: 'sss-vr-hint' }, t('aiBaseUrlHint')),
2434
- React.createElement('div', { className: 'sss-vr-field-head' }, [
2435
- React.createElement('label', { className: 'sss-vr-label' }, t('aiModel')),
2436
- ]),
2437
- React.createElement('input', {
2438
- className: 'sss-vr-input',
2439
- type: 'text',
2440
- name: 'dsh-settings-search-model',
2441
- 'aria-label': t('aiModel'),
2442
- value: model,
2443
- placeholder: provider === 'ollama' ? 'qwen3:8b'
2444
- : provider === 'anthropic' ? 'claude-sonnet-4-20250514' : 'gpt-4o-mini',
2445
- spellCheck: false,
2446
- autoComplete: 'off',
2447
- 'data-lpignore': 'true',
2448
- 'data-1p-ignore': 'true',
2449
- 'data-form-type': 'other',
2450
- onChange: (event) => setModel(event.target.value),
2451
- }),
2452
- provider !== 'ollama' ? React.createElement('div', { className: 'sss-vr-field-head' }, [
2453
- React.createElement('label', { className: 'sss-vr-label' }, t('aiApiKey')),
2454
- ]) : null,
2455
- provider !== 'ollama' ? React.createElement('input', {
2456
- className: 'sss-vr-input sss-vr-mask',
2457
- type: 'text',
2458
- name: 'dsh-settings-search-api-key',
2459
- 'aria-label': t('aiApiKey'),
2460
- value: apiKey,
2461
- placeholder: 'sk-…',
2462
- spellCheck: false,
2463
- autoComplete: 'off',
2464
- 'data-lpignore': 'true',
2465
- 'data-1p-ignore': 'true',
2466
- 'data-form-type': 'other',
2467
- onChange: (event) => setApiKey(event.target.value),
2468
- }) : null,
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.11.0",
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",
@@ -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
- vm.runInNewContext(instrumented, sandbox, { filename: 'lib/client.js' })
58
- const api = handoff.factory(() => ({}))
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' })