@objectivex666/dsh-settings-search 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -30,8 +30,10 @@ window.__ModuleLoader__.load({
30
30
 
31
31
  const NS = 'settings-search-v5'
32
32
  /** Single source of truth is package.json — scripts/check.mjs asserts this matches. */
33
- const PKG_VERSION = '1.11.0'
33
+ const PKG_VERSION = '1.13.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,12 +94,21 @@ 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: '正在导入…',
92
107
  aiDshImportOk: '已导入 Base URL 与模型名;API Key 未改动 ✓',
93
108
  aiDshImportFail: '导入失败:{reason}',
94
109
  aiDshImportEmpty: '未找到 DSH 当前默认模型配置',
110
+ aiDshImportApi: '当前 DSH 版本未提供模型配置接口(remote.settings)',
111
+ aiDshImportNoBaseUrl: '默认模型「{model}」在 DSH 设置中没有可导入的 Base URL',
95
112
  aiConfigSave: '保存配置',
96
113
  aiConfigSaved: '已保存 ✓',
97
114
  aiClear: '清除配置',
@@ -117,19 +134,35 @@ window.__ModuleLoader__.load({
117
134
  aiOllamaErrForbidden: 'Ollama 拒绝访问,请检查 OLLAMA_ORIGINS 是否允许当前 DSH 页面来源',
118
135
  aiOllamaErrTimeout: 'Ollama 响应超时,请检查本机服务及模型加载状态',
119
136
  logTitle: '日志',
120
- logDesc: '记录插件运行、搜索与 AI 请求的关键动作,便于排查问题。',
137
+ logCountHint: '磁盘日志文件中的条目数',
138
+ logDesc: '记录插件运行、搜索与 AI 请求的关键动作,便于排查问题;日志按天写入 DSH 的日志文件夹,文件名带日期。',
121
139
  logEmpty: '暂无日志',
122
140
  logExport: '导出日志',
141
+ logView: '查看日志',
142
+ logOpenDir: '打开日志文件夹',
143
+ logOpenOk: '已在文件管理器中打开日志文件夹 ✓',
144
+ logOpenFail: '打开日志文件夹失败',
145
+ logIssues: '反馈问题',
123
146
  logCopy: '复制日志',
124
147
  logCopied: '已复制 ✓',
125
148
  logCopyFail: '复制失败,请稍后重试。',
126
149
  logClear: '清空日志',
127
150
  logCleared: '已清空',
128
151
  logConfirmTitle: '清空日志',
129
- logConfirm: '确定要清空全部日志吗?此操作不可撤销。',
152
+ logConfirm: '确定要清空全部日志吗?磁盘上的日志文件也会一并清空。',
130
153
  logConfirmYes: '确认清空',
131
154
  logConfirmCancel: '取消',
132
155
  logExportFail: '导出失败,请重试。',
156
+ logViewerTitle: '运行日志',
157
+ logViewerRefresh: '刷新',
158
+ logViewerLoading: '正在读取…',
159
+ logViewerCopy: '复制',
160
+ logViewerCopied: '已复制 ✓',
161
+ logViewerClose: '关闭',
162
+ logViewerEmpty: '暂无日志。',
163
+ logViewerFile: '文件:{file}',
164
+ logViewerMemory: '宿主日志通道不可用,这里显示本次会话的记录,重启后不再保留。',
165
+ logViewerTruncated: '仅显示文件末尾部分,更早的内容请打开日志文件夹查看。',
133
166
  wlTitle: '欢迎使用设置搜索',
134
167
  wlSubtitle: '为 DSH 设置面板提供即时搜索与导航,几秒钟就能找到任何设置。',
135
168
  wlFeature1Title: '实时搜索',
@@ -160,6 +193,11 @@ window.__ModuleLoader__.load({
160
193
  shortcutTitle: 'Ctrl+F / ⌘F shortcut',
161
194
  shortcutHint: 'Focus the search box with Ctrl+F or ⌘F inside the settings panel; turn it off to keep the browser default.',
162
195
  warming: 'Importing section options…',
196
+ indexTitle: 'Settings index',
197
+ indexDesc: 'The index is stored locally; it rebuilds only after another plugin is installed or updated. You can also rebuild it manually.',
198
+ indexRebuild: 'Rebuild index',
199
+ indexRebuilding: 'Rebuilding…',
200
+ indexDone: 'Index rebuilt ✓',
163
201
  empty: 'No matching settings',
164
202
  pages: 'Settings pages',
165
203
  options: 'In-page options',
@@ -184,6 +222,7 @@ window.__ModuleLoader__.load({
184
222
  updNpx: 'npx',
185
223
  updRelease: 'View release notes',
186
224
  updReleaseNotes: 'View changelog',
225
+ updRepo: 'GitHub repository',
187
226
  updFetchingNotes: 'Fetching…',
188
227
  updRestart: 'Restart DSH after updating for the new version to take effect.',
189
228
  aiSearch: 'AI-assisted search',
@@ -201,12 +240,21 @@ window.__ModuleLoader__.load({
201
240
  aiBaseUrl: 'Base URL',
202
241
  aiBaseUrlHint: 'Only http(s) URLs are allowed; remote hosts must use https to avoid exposing your API key.',
203
242
  aiModel: 'Model',
243
+ aiModelHint: 'Choose a model returned by the API, or enter a complete model name.',
244
+ aiModelsLoad: 'Get models',
245
+ aiModelsLoading: 'Loading…',
246
+ aiModelsLoaded: 'Loaded {count} models; choose one below.',
247
+ aiModelsEmpty: 'The service returned no models; enter one manually.',
248
+ aiModelsFail: 'Failed to load models: {hint}{detail}',
249
+ aiModelsNeedConfig: 'Enter a valid base URL first; cloud APIs also need an API key.',
204
250
  aiApiKey: 'API Key',
205
251
  aiDshImport: 'Import DSH model config',
206
252
  aiDshImporting: 'Importing…',
207
253
  aiDshImportOk: 'Imported Base URL and model; API key unchanged ✓',
208
254
  aiDshImportFail: 'Import failed: {reason}',
209
255
  aiDshImportEmpty: 'No current DSH default model config found',
256
+ aiDshImportApi: 'This DSH build exposes no model config API (remote.settings)',
257
+ aiDshImportNoBaseUrl: 'The default model "{model}" has no Base URL in DSH settings to import',
210
258
  aiConfigSave: 'Save config',
211
259
  aiConfigSaved: 'Saved ✓',
212
260
  aiClear: 'Clear config',
@@ -232,19 +280,35 @@ window.__ModuleLoader__.load({
232
280
  aiOllamaErrForbidden: 'Ollama denied access. Check that OLLAMA_ORIGINS allows this DSH page origin',
233
281
  aiOllamaErrTimeout: 'Ollama timed out. Check the local service and model loading status',
234
282
  logTitle: 'Logs',
235
- logDesc: 'Logs key plugin actions, searches, and AI requests to help troubleshoot.',
283
+ logCountHint: 'Entries in the log file on disk',
284
+ logDesc: 'Logs key plugin actions, searches, and AI requests to help troubleshoot; one dated file per day is written to DSH\u2019s log folder.',
236
285
  logEmpty: 'No logs yet',
237
286
  logExport: 'Export logs',
287
+ logView: 'View logs',
288
+ logOpenDir: 'Open log folder',
289
+ logOpenOk: 'Log folder opened in your file manager ✓',
290
+ logOpenFail: 'Could not open the log folder',
291
+ logIssues: 'Report issue',
238
292
  logCopy: 'Copy logs',
239
293
  logCopied: 'Copied ✓',
240
294
  logCopyFail: 'Copy failed. Please try again.',
241
295
  logClear: 'Clear logs',
242
296
  logCleared: 'Cleared',
243
297
  logConfirmTitle: 'Clear logs',
244
- logConfirm: 'Clear all logs? This cannot be undone.',
298
+ logConfirm: 'Clear all logs? The log file on disk is truncated as well.',
245
299
  logConfirmYes: 'Clear logs',
246
300
  logConfirmCancel: 'Cancel',
247
301
  logExportFail: 'Export failed. Please try again.',
302
+ logViewerTitle: 'Run log',
303
+ logViewerRefresh: 'Refresh',
304
+ logViewerLoading: 'Loading…',
305
+ logViewerCopy: 'Copy',
306
+ logViewerCopied: 'Copied ✓',
307
+ logViewerClose: 'Close',
308
+ logViewerEmpty: 'No logs yet.',
309
+ logViewerFile: 'File: {file}',
310
+ logViewerMemory: 'The host log channel is unavailable, so this shows the current session only; it is not kept after a reload.',
311
+ logViewerTruncated: 'Showing the end of the file only — open the log folder for earlier lines.',
248
312
  wlTitle: 'Welcome to Settings Search',
249
313
  wlSubtitle: 'Instant search and navigation for the DSH settings panel — find any setting in seconds.',
250
314
  wlFeature1Title: 'Instant search',
@@ -263,7 +327,14 @@ window.__ModuleLoader__.load({
263
327
  wlFeature6Desc: 'On first open it automatically passes through every settings section, so in-page options are searchable without opening each page manually.',
264
328
  }
265
329
 
266
- /** Inner-row slots that represent specific options inside one page. */
330
+ /**
331
+ * Inner-row slots that represent specific options inside one page.
332
+ * DSH 0.2 keeps the plugin cards in its standalone Plugins panel
333
+ * (`plugins.item`) and renders the Settings → Plugins inventory tab itself,
334
+ * so only the tab seat is inside the settings panel the search reaches.
335
+ * `settings.plugin.item` and `web-ui.plugin.item` are the pre-0.2 card
336
+ * seats — 0.2 declares neither, and they stay indexed for older builds.
337
+ */
267
338
  const DEEP_SLOTS = [
268
339
  { slot: 'settings.plugins.tab', parent: 'plugins' },
269
340
  { slot: 'settings.plugin.item', parent: 'plugins' },
@@ -442,16 +513,192 @@ window.__ModuleLoader__.load({
442
513
 
443
514
  const AI_TOGGLE_EVENT = 'dsh-settings-search:ai-toggle'
444
515
  const AI_CONFIG_EVENT = 'dsh-settings-search:ai-config'
445
- /** Simple in-memory log ring used by the settings panel's export/log UI. */
516
+ /**
517
+ * Log recording has two sinks: the in-memory ring that feeds the panel
518
+ * instantly, and the host log file the panel can view and open. Every entry
519
+ * is formatted once ({@link logLine}), so the file, the viewer, and the
520
+ * exported text all read the same. Lines are handed to the host in small
521
+ * batches — a settings panel produces bursts, and one request per line
522
+ * would be wasteful — and the transport stays deliberately silent about its
523
+ * own failures so a broken channel can never log about itself.
524
+ */
446
525
  const LOG_EVENT = 'dsh-settings-search:log'
526
+ /** Fires when the on-disk entry count changes, so the panel's badge follows it. */
527
+ const LOG_COUNT_EVENT = 'dsh-settings-search:log-count'
447
528
  const LOG_MAX = 500
529
+ const LOG_LINE_MAX = 800
530
+ const LOG_BATCH_MAX = 40
531
+ const LOG_FLUSH_MS = 1200
532
+ /** Document-relative form of the host half's `/api/settings-search-log`. */
533
+ const LOG_ROUTE = 'api/settings-search-log'
534
+ /** Per page-load run id, so one file can tell concurrent runs apart. */
535
+ const LOG_RUN = Math.floor(Math.random() * 0xffffffff).toString(16).padStart(8, '0')
448
536
  const LOGS = []
537
+ let logSeq = 0
538
+
539
+ const safeJson = (value) => {
540
+ try {
541
+ const text = JSON.stringify(value)
542
+ return typeof text === 'string' ? text : ''
543
+ } catch {
544
+ return '"<unserializable>"'
545
+ }
546
+ }
547
+
548
+ /** One line per entry: `time [level] [run:seq] message {meta}`. */
549
+ const logLine = (entry) => {
550
+ const meta = entry.meta === undefined ? '' : ` ${safeJson(entry.meta)}`
551
+ const seq = Number.isFinite(entry.seq) ? entry.seq : 0
552
+ return `${entry.time} [${entry.level}] [${LOG_RUN}:${seq}] ${entry.msg}${meta}`.slice(0, LOG_LINE_MAX)
553
+ }
554
+
555
+ /** Host-log transport state: pending lines, its timer, and the last outcome. */
556
+ const logTransport = { pending: [], timer: null, state: 'idle', dir: '', file: '', detail: '', count: null }
557
+
558
+ const setLogOutcome = (failed, payload, detail) => {
559
+ logTransport.state = failed ? 'fail' : 'ok'
560
+ logTransport.detail = failed ? detail : ''
561
+ if (typeof payload?.dir === 'string') logTransport.dir = payload.dir
562
+ if (typeof payload?.file === 'string') logTransport.file = payload.file
563
+ // The host answers the on-disk entry count on every call, so the panel's
564
+ // badge reflects the log that exists rather than this page's ring.
565
+ if (Number.isFinite(payload?.count) && payload.count !== logTransport.count) {
566
+ logTransport.count = payload.count
567
+ try { window.dispatchEvent(new CustomEvent(LOG_COUNT_EVENT)) } catch {}
568
+ }
569
+ }
570
+
571
+ /** One request to the host log route; never throws, never logs. */
572
+ const postLog = async (body) => {
573
+ try {
574
+ const response = await fetch(LOG_ROUTE, {
575
+ method: 'POST',
576
+ headers: { 'content-type': 'application/json' },
577
+ body: JSON.stringify(body),
578
+ })
579
+ const payload = await response.json().catch(() => null)
580
+ if (!response.ok || payload?.ok !== true) {
581
+ setLogOutcome(true, null, String(payload?.detail ?? payload?.reason ?? `HTTP ${response.status}`))
582
+ return null
583
+ }
584
+ setLogOutcome(false, payload, '')
585
+ return payload
586
+ } catch (error) {
587
+ setLogOutcome(true, null, String(error?.message ?? error))
588
+ return null
589
+ }
590
+ }
591
+
592
+ /** Ship the queued lines now; the host stops serving once its batch is full. */
593
+ const flushLogs = () => {
594
+ if (logTransport.timer !== null) {
595
+ clearTimeout(logTransport.timer)
596
+ logTransport.timer = null
597
+ }
598
+ if (logTransport.pending.length === 0) return
599
+ const lines = logTransport.pending.splice(0, logTransport.pending.length)
600
+ void postLog({ action: 'append', lines })
601
+ }
602
+
603
+ /** Drop queued lines without shipping them (used by 清空日志). */
604
+ const discardPendingLogs = () => {
605
+ if (logTransport.timer !== null) {
606
+ clearTimeout(logTransport.timer)
607
+ logTransport.timer = null
608
+ }
609
+ logTransport.pending.length = 0
610
+ }
611
+
612
+ const queueLogLine = (line) => {
613
+ logTransport.pending.push(line)
614
+ if (logTransport.pending.length >= LOG_BATCH_MAX) {
615
+ flushLogs()
616
+ return
617
+ }
618
+ if (logTransport.timer === null) logTransport.timer = setTimeout(flushLogs, LOG_FLUSH_MS)
619
+ }
620
+
621
+ /** Read the host log tail (viewer, and the folder path the panel offers). */
622
+ const readLogFile = async () => {
623
+ try {
624
+ const response = await fetch(LOG_ROUTE, { method: 'GET' })
625
+ const payload = await response.json().catch(() => null)
626
+ if (!response.ok || payload?.ok !== true) {
627
+ setLogOutcome(true, null, String(payload?.detail ?? payload?.reason ?? `HTTP ${response.status}`))
628
+ return null
629
+ }
630
+ setLogOutcome(false, payload, '')
631
+ return {
632
+ text: String(payload.text ?? ''),
633
+ size: Number(payload.size ?? 0),
634
+ truncated: payload.truncated === true,
635
+ }
636
+ } catch (error) {
637
+ setLogOutcome(true, null, String(error?.message ?? error))
638
+ return null
639
+ }
640
+ }
641
+
642
+ /**
643
+ * Refresh the on-disk entry count without transferring the file tail.
644
+ * @returns the count, or null when the host channel is unavailable.
645
+ */
646
+ const readLogCount = async () => {
647
+ try {
648
+ const response = await fetch(`${LOG_ROUTE}?count=1`, { method: 'GET' })
649
+ const payload = await response.json().catch(() => null)
650
+ if (!response.ok || payload?.ok !== true) {
651
+ setLogOutcome(true, null, String(payload?.detail ?? payload?.reason ?? `HTTP ${response.status}`))
652
+ return null
653
+ }
654
+ setLogOutcome(false, payload, '')
655
+ return Number.isFinite(payload.count) ? payload.count : null
656
+ } catch (error) {
657
+ setLogOutcome(true, null, String(error?.message ?? error))
658
+ return null
659
+ }
660
+ }
661
+
662
+ /** Open the log folder in the OS file manager. */
663
+ const openLogFolder = async () => {
664
+ const payload = await postLog({ action: 'open' })
665
+ return { ok: payload !== null, dir: logTransport.dir, file: logTransport.file, detail: logTransport.detail }
666
+ }
667
+
668
+ /** Truncate the on-disk log. */
669
+ const clearLogFile = async () => { await postLog({ action: 'clear' }) }
670
+
671
+ /** Current host-log target and last transport outcome, for the panel. */
672
+ const logTarget = () => ({
673
+ dir: logTransport.dir,
674
+ file: logTransport.file,
675
+ state: logTransport.state,
676
+ detail: logTransport.detail,
677
+ count: logTransport.count,
678
+ })
679
+
680
+ /** This session's entries in the same one-line format the file uses. */
681
+ const sessionLogText = () => LOGS.map(logLine).join('\n')
682
+
449
683
  const log = (level, msg, meta) => {
450
684
  if (typeof window === 'undefined') return
451
- LOGS.push({ time: new Date().toISOString(), level, msg, meta })
685
+ const entry = { time: new Date().toISOString(), level, msg: String(msg), meta, seq: ++logSeq }
686
+ LOGS.push(entry)
452
687
  if (LOGS.length > LOG_MAX) LOGS.splice(0, LOGS.length - LOG_MAX)
688
+ queueLogLine(logLine(entry))
453
689
  try { window.dispatchEvent(new CustomEvent(LOG_EVENT, { detail: LOGS.length })) } catch {}
454
690
  }
691
+
692
+ if (typeof window !== 'undefined') {
693
+ // A page leaving the document is the last chance to ship queued lines.
694
+ try { window.addEventListener('pagehide', flushLogs) } catch {}
695
+ try {
696
+ window.addEventListener('visibilitychange', () => {
697
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') flushLogs()
698
+ })
699
+ } catch {}
700
+ }
701
+
455
702
  const formatLogs = (logs) => {
456
703
  const lines = [
457
704
  'dsh-settings-search log',
@@ -460,9 +707,7 @@ window.__ModuleLoader__.load({
460
707
  `count: ${logs.length}`,
461
708
  '',
462
709
  ]
463
- for (const entry of logs) {
464
- lines.push(`[${entry.time}] [${entry.level}] ${entry.msg}${entry.meta ? ' ' + JSON.stringify(entry.meta) : ''}`)
465
- }
710
+ for (const entry of logs) lines.push(logLine(entry))
466
711
  return lines.join('\n')
467
712
  }
468
713
  const AI_CACHE = new Map()
@@ -539,6 +784,10 @@ window.__ModuleLoader__.load({
539
784
  const SEARCH_STYLE_EVENT = 'dsh-settings-search:style-change'
540
785
  const SEARCH_SHORTCUT_KEY = 'dsh-settings-search:shortcut-enabled'
541
786
  const SEARCH_SHORTCUT_EVENT = 'dsh-settings-search:shortcut-change'
787
+ const INDEX_STORAGE_KEY = 'dsh-settings-search:index:v1'
788
+ const INDEX_INVALID_EVENT = 'dsh-settings-search:index-invalid'
789
+ const INDEX_REBUILD_EVENT = 'dsh-settings-search:index-rebuild'
790
+ const INDEX_REBUILD_DONE_EVENT = 'dsh-settings-search:index-rebuild-done'
542
791
  const SEARCH_STYLES = ['sidebar', 'top', 'page']
543
792
  const readSearchStyle = () => {
544
793
  if (typeof localStorage === 'undefined') return 'sidebar'
@@ -628,6 +877,101 @@ window.__ModuleLoader__.load({
628
877
  // Accept the base URL used by Ollama's OpenAI-compatible endpoint too.
629
878
  return `${safe.replace(/\/v1(?:\/chat\/completions)?$/i, '')}/api/chat`
630
879
  }
880
+ const ollamaModelsUrl = (baseUrl) => {
881
+ const safe = safeBaseUrl(baseUrl)
882
+ if (!safe) return ''
883
+ const root = safe.replace(/\/v1(?:\/models|\/chat\/completions)?$/i, '').replace(/\/api(?:\/tags|\/chat)?$/i, '')
884
+ return `${root}/api/tags`
885
+ }
886
+ const chatModelsUrl = (baseUrl) => {
887
+ const safe = safeBaseUrl(baseUrl)
888
+ if (!safe) return ''
889
+ const root = safe.replace(/\/(?:models|chat\/completions)$/i, '')
890
+ return /\/v1$/i.test(root) ? `${root}/models` : `${root}/v1/models`
891
+ }
892
+ const anthropicModelsUrl = (baseUrl) => {
893
+ const safe = safeBaseUrl(baseUrl)
894
+ if (!safe) return ''
895
+ const root = safe.replace(/\/v1\/(?:models|messages)$/i, '')
896
+ return `${root}/v1/models`
897
+ }
898
+ const modelsUrl = (provider, baseUrl) => provider === 'ollama'
899
+ ? ollamaModelsUrl(baseUrl)
900
+ : provider === 'anthropic' ? anthropicModelsUrl(baseUrl) : chatModelsUrl(baseUrl)
901
+ const extractAiModels = (provider, data) => {
902
+ const rows = provider === 'ollama'
903
+ ? (Array.isArray(data?.models) ? data.models : [])
904
+ : (Array.isArray(data?.data) ? data.data : Array.isArray(data?.models) ? data.models : [])
905
+ return [...new Set(rows
906
+ .map((row) => typeof row === 'string' ? row : row?.id ?? row?.name ?? row?.model)
907
+ .filter((value) => typeof value === 'string')
908
+ .map((value) => value.trim())
909
+ .filter(Boolean))].slice(0, 500)
910
+ }
911
+ const fetchAiModels = async (cfg, signal) => {
912
+ cfg = normalizeAiConfig(cfg)
913
+ const { provider, baseUrl, apiKey } = cfg
914
+ const url = modelsUrl(provider, baseUrl)
915
+ if (!url) return { ok: false, code: 'unsafe-url', models: [], detail: '' }
916
+ if (provider !== 'ollama' && !apiKey) return { ok: false, code: 'need-config', models: [], detail: '' }
917
+ const headers = provider === 'anthropic'
918
+ ? {
919
+ 'x-api-key': apiKey,
920
+ 'anthropic-version': '2023-06-01',
921
+ 'anthropic-dangerous-direct-browser-access': 'true',
922
+ }
923
+ : provider === 'openai' ? { authorization: `Bearer ${apiKey}` } : {}
924
+ const controller = new AbortController()
925
+ const abort = () => controller.abort()
926
+ if (signal?.aborted) abort()
927
+ else signal?.addEventListener('abort', abort, { once: true })
928
+ let timer
929
+ const deadline = new Promise((_, reject) => {
930
+ timer = setTimeout(() => {
931
+ const error = new Error('AI model list timed out')
932
+ error.name = 'AI_TIMEOUT'
933
+ reject(error)
934
+ controller.abort()
935
+ }, 12000)
936
+ })
937
+ try {
938
+ const models = await Promise.race([
939
+ (async () => {
940
+ const response = await fetch(url, {
941
+ method: 'GET',
942
+ headers,
943
+ signal: controller.signal,
944
+ redirect: 'error',
945
+ })
946
+ if (!response.ok) {
947
+ const error = new Error(`HTTP ${response.status}`)
948
+ error.status = response.status
949
+ throw error
950
+ }
951
+ const data = await response.json().catch((error) => {
952
+ if (error.name === 'AbortError') throw error
953
+ return null
954
+ })
955
+ if (data?.error) {
956
+ const error = new Error('Unexpected model list response')
957
+ error.name = 'AI_BAD_RESPONSE'
958
+ throw error
959
+ }
960
+ return extractAiModels(provider, data)
961
+ })(),
962
+ deadline,
963
+ ])
964
+ return { ok: true, models, code: models.length === 0 ? 'empty' : 'ok', detail: '' }
965
+ } catch (error) {
966
+ if (error.name === 'AbortError') return { ok: false, code: 'cancelled', models: [], detail: '' }
967
+ if (error.name === 'AI_TIMEOUT') return { ok: false, code: 'timeout', models: [], detail: '' }
968
+ const code = error.status ? testHttpCode(error.status) : error.name === 'AI_BAD_RESPONSE' ? 'bad-response' : 'network'
969
+ return { ok: false, code, models: [], detail: String(error?.message ?? error).slice(0, 160) }
970
+ } finally {
971
+ clearTimeout(timer)
972
+ signal?.removeEventListener('abort', abort)
973
+ }
974
+ }
631
975
  const aiRequest = (cfg, userContent, testing = false) => {
632
976
  const { provider, baseUrl, model, apiKey } = normalizeAiConfig(cfg)
633
977
  const messages = [{ role: 'user', content: userContent }]
@@ -830,7 +1174,7 @@ window.__ModuleLoader__.load({
830
1174
  .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
1175
  .sss-trigger:hover { border-color: var(--dsw-alias-label-dimmed); color: var(--dsw-alias-label-secondary); }
832
1176
  .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%)); }
1177
+ .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-1, rgb(0 0 0 / 36%)); }
834
1178
  .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
1179
  .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
1180
  .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); }
@@ -891,7 +1235,7 @@ window.__ModuleLoader__.load({
891
1235
  .sss-vr-link { appearance: none; font: inherit; cursor: pointer; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; padding: 5px 14px; font-size: 13px; line-height: 1.5; color: var(--dsw-alias-label-secondary); background: 0 0; text-decoration: none; display: inline-flex; align-items: center; }
892
1236
  .sss-vr-link:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
893
1237
  .sss-vr-notes { box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-layer-3); color: var(--dsw-alias-label-primary); padding: 10px 12px; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; max-height: 240px; overflow-y: auto; margin-top: 8px; }
894
- .sss-wl-overlay { position: fixed; inset: 0; z-index: 999; display: flex; align-items: center; justify-content: center; padding: 20px; background: var(--dsw-alias-bg-mask, rgb(0 0 0 / 36%)); box-sizing: border-box; }
1238
+ .sss-wl-overlay { position: fixed; inset: 0; z-index: 999; display: flex; align-items: center; justify-content: center; padding: 20px; background: var(--dsw-alias-bg-mask-1, rgb(0 0 0 / 36%)); box-sizing: border-box; }
895
1239
  .sss-wl { width: 100%; max-width: 520px; max-height: min(88vh, 720px); overflow: auto; box-sizing: border-box; background: var(--dsw-alias-bg-layer-3); border: 1px solid var(--dsw-alias-border-l2); border-radius: 16px; box-shadow: var(--dsw-shadow-lv3); padding: 22px 24px 20px; }
896
1240
  .sss-wl-header { display: flex; align-items: flex-start; gap: 12px; }
897
1241
  .sss-wl-headText { flex: 1; min-width: 0; }
@@ -912,7 +1256,7 @@ window.__ModuleLoader__.load({
912
1256
  .sss-wl-primary:hover { color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
913
1257
  .sss-wl-skip { margin-left: auto; border: none; background: transparent; color: var(--dsw-alias-label-tertiary); padding: 7px 4px; }
914
1258
  .sss-wl-skip:hover { color: var(--dsw-alias-label-primary); border-color: transparent; }
915
- .sss-cf-overlay { position: fixed; inset: 0; z-index: 999; display: flex; align-items: center; justify-content: center; padding: 20px; background: var(--dsw-alias-bg-mask, rgb(0 0 0 / 36%)); box-sizing: border-box; }
1259
+ .sss-cf-overlay { position: fixed; inset: 0; z-index: 999; display: flex; align-items: center; justify-content: center; padding: 20px; background: var(--dsw-alias-bg-mask-1, rgb(0 0 0 / 36%)); box-sizing: border-box; }
916
1260
  .sss-cf { width: 100%; max-width: 400px; box-sizing: border-box; background: var(--dsw-alias-bg-layer-3); border: 1px solid var(--dsw-alias-border-l2); border-radius: 14px; box-shadow: var(--dsw-shadow-lv3); padding: 20px 22px 18px; }
917
1261
  .sss-cf-header { display: flex; align-items: flex-start; gap: 12px; }
918
1262
  .sss-cf-headText { flex: 1; min-width: 0; }
@@ -923,6 +1267,15 @@ window.__ModuleLoader__.load({
923
1267
  .sss-cf-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 18px; }
924
1268
  .sss-cf-btn { appearance: none; font: inherit; cursor: pointer; border: 1px solid var(--dsw-alias-border-l2); border-radius: 9px; padding: 7px 16px; font-size: 13px; line-height: 1.5; color: var(--dsw-alias-label-secondary); background: 0 0; }
925
1269
  .sss-cf-btn:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
1270
+ .sss-lv-overlay { position: fixed; inset: 0; z-index: 1002; display: flex; align-items: center; justify-content: center; padding: 20px; background: var(--dsw-alias-bg-mask-1, rgb(0 0 0 / 36%)); box-sizing: border-box; }
1271
+ .sss-lv { display: flex; flex-direction: column; width: min(880px, 100%); max-height: min(760px, calc(100vh - 40px)); box-sizing: border-box; background: var(--dsw-alias-bg-layer-3); border: 1px solid var(--dsw-alias-border-l2); border-radius: 14px; box-shadow: var(--dsw-shadow-lv3); padding: 16px 18px 14px; }
1272
+ .sss-lv-header { flex: none; display: flex; align-items: center; gap: 10px; }
1273
+ .sss-lv-title { flex: 1; min-width: 0; margin: 0; color: var(--dsw-alias-label-primary); font-size: 16px; font-weight: 650; line-height: 1.4; }
1274
+ .sss-lv-close { flex: none; appearance: none; cursor: pointer; width: 28px; height: 28px; padding: 0; border: none; border-radius: 7px; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 15px; line-height: 1; }
1275
+ .sss-lv-close:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
1276
+ .sss-lv-meta { flex: none; margin: 6px 0 0; color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 1.5; word-break: break-all; }
1277
+ .sss-lv-body { flex: 1; min-height: 120px; overflow: auto; margin: 10px 0 0; padding: 10px 12px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 11px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
1278
+ .sss-lv-actions { flex: none; display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
926
1279
  .sss-cf-danger { background: #c0392b; color: #fff; border-color: transparent; }
927
1280
  .sss-cf-danger:hover { background: #a93226; color: #fff; border-color: transparent; }
928
1281
  `
@@ -951,9 +1304,70 @@ window.__ModuleLoader__.load({
951
1304
  return typeof value === 'string' ? value : ''
952
1305
  }
953
1306
 
1307
+ const serializeHarvest = (harvest) => {
1308
+ const raw = {}
1309
+ for (const [slot, map] of harvest) {
1310
+ if (!(map instanceof Map) || map.size === 0) continue
1311
+ raw[slot] = Object.fromEntries([...map].filter(([, record]) => record && typeof record.title === 'string' && record.title !== ''))
1312
+ }
1313
+ return raw
1314
+ }
1315
+ const deserializeHarvest = (raw) => {
1316
+ const harvest = new Map()
1317
+ if (!raw || typeof raw !== 'object') return harvest
1318
+ for (const [slot, slotValue] of Object.entries(raw)) {
1319
+ if (!slotValue || typeof slotValue !== 'object') continue
1320
+ const map = new Map()
1321
+ for (const [key, record] of Object.entries(slotValue)) {
1322
+ if (!record || typeof record !== 'object' || typeof record.title !== 'string' || record.title === '') continue
1323
+ const inner = Array.isArray(record.inner)
1324
+ ? record.inner.filter((op) => op && typeof op.label === 'string').map((op) => ({ label: op.label, text: typeof op.text === 'string' ? op.text : '' }))
1325
+ : []
1326
+ map.set(key, {
1327
+ title: record.title,
1328
+ idx: Number.isFinite(record.idx) ? record.idx : 0,
1329
+ text: typeof record.text === 'string' ? record.text : '',
1330
+ inner,
1331
+ })
1332
+ }
1333
+ if (map.size > 0) harvest.set(slot, map)
1334
+ }
1335
+ return harvest
1336
+ }
1337
+
1338
+ const readIndexCache = () => {
1339
+ if (typeof localStorage === 'undefined') return null
1340
+ try {
1341
+ const raw = JSON.parse(localStorage.getItem(INDEX_STORAGE_KEY) ?? 'null')
1342
+ if (!raw || raw.version !== 1 || typeof raw.fingerprint !== 'string' || raw.fingerprint === '') return null
1343
+ return {
1344
+ fingerprint: typeof raw.fingerprint === 'string' ? raw.fingerprint : null,
1345
+ harvest: deserializeHarvest(raw.harvest),
1346
+ }
1347
+ } catch {
1348
+ return null
1349
+ }
1350
+ }
1351
+
1352
+ const otherPluginFingerprint = (plugins) => {
1353
+ const rows = Array.isArray(plugins) ? plugins : []
1354
+ const isSelf = (row) => [
1355
+ row?.id, row?.name, row?.npm, row?.package, row?.packageName,
1356
+ ].some((value) => typeof value === 'string' && value.includes(NPM_NAME))
1357
+ return JSON.stringify(rows.filter((row) => row && !isSelf(row)).map((row) => [
1358
+ String(row.id ?? row.name ?? ''),
1359
+ String(row.npm ?? row.package ?? row.packageName ?? ''),
1360
+ String(row.version ?? ''),
1361
+ row.enabled === undefined ? '' : String(row.enabled),
1362
+ ].sort()).sort())
1363
+ }
1364
+
954
1365
  function createModel(ctx) {
1366
+ const initialCache = readIndexCache()
1367
+ const cachedFingerprint = initialCache?.fingerprint ?? null
955
1368
  /** id → harvested visible title, per slot. */
956
- const harvest = new Map()
1369
+ const harvest = initialCache?.harvest ?? new Map()
1370
+ let fingerprint = cachedFingerprint
957
1371
 
958
1372
  const entriesOf = (slot) => {
959
1373
  try { return ctx.slots.entries(slot) ?? [] } catch { return [] }
@@ -1264,7 +1678,62 @@ window.__ModuleLoader__.load({
1264
1678
  : a.order - b.order)
1265
1679
  }
1266
1680
 
1267
- return { readSections, readDeep, runHarvest }
1681
+ /**
1682
+ * Fingerprint of the seats the index is built from: every settings page
1683
+ * plus each in-page seat this build declares. It is the change signal for
1684
+ * compositions that expose no plugin inventory at all (see
1685
+ * hasPluginInventory), so pages and rows appearing or disappearing still
1686
+ * invalidate a stored index. The pre-0.2 card seats alone would be empty
1687
+ * on DSH 0.2, which declares neither.
1688
+ */
1689
+ const slotsFingerprint = () => {
1690
+ const rows = []
1691
+ for (const slot of ['settings.section', ...DEEP_SLOTS.map((config) => config.slot)]) {
1692
+ for (const entry of sortEntries(entriesOf(slot))) {
1693
+ rows.push([
1694
+ slot,
1695
+ String(entry.options?.id ?? entry.options?.key ?? ''),
1696
+ optionLabel(entry.options?.label),
1697
+ ])
1698
+ }
1699
+ }
1700
+ return JSON.stringify(rows.sort())
1701
+ }
1702
+
1703
+ const resetHarvest = () => { harvest.clear(); warmed = false }
1704
+
1705
+ const persistIndex = (nextFingerprint = fingerprint) => {
1706
+ if (typeof nextFingerprint !== 'string' || nextFingerprint === '' || harvest.size === 0) return false
1707
+ fingerprint = nextFingerprint
1708
+ if (typeof localStorage === 'undefined') return false
1709
+ try {
1710
+ localStorage.setItem(INDEX_STORAGE_KEY, JSON.stringify({
1711
+ version: 1,
1712
+ fingerprint,
1713
+ updatedAt: Date.now(),
1714
+ harvest: serializeHarvest(harvest),
1715
+ }))
1716
+ return true
1717
+ } catch {
1718
+ return false
1719
+ }
1720
+ }
1721
+
1722
+ const indexFingerprintReady = () => typeof fingerprint === 'string' && fingerprint !== ''
1723
+
1724
+ const applyIndexFingerprint = (nextFingerprint) => {
1725
+ if (typeof nextFingerprint !== 'string' || nextFingerprint === '') return false
1726
+ if (nextFingerprint === fingerprint) {
1727
+ if (nextFingerprint === cachedFingerprint) warmed = true
1728
+ return false
1729
+ }
1730
+ const wasPopulated = harvest.size > 0
1731
+ fingerprint = nextFingerprint
1732
+ resetHarvest()
1733
+ return wasPopulated
1734
+ }
1735
+
1736
+ return { readSections, readDeep, runHarvest, persistIndex, applyIndexFingerprint, resetHarvest, slotsFingerprint, indexFingerprintReady }
1268
1737
  }
1269
1738
 
1270
1739
  /* ---------- DOM-level navigation into the settings shell ---------- */
@@ -1316,7 +1785,7 @@ window.__ModuleLoader__.load({
1316
1785
  const warmAllSections = async (model, onStart, onDone) => {
1317
1786
  if (warmed || typeof document === 'undefined') return true
1318
1787
  const sections = model.readSections()
1319
- if (sections.length === 0) return true
1788
+ if (sections.length === 0) return false
1320
1789
  const original = activeSectionLabel()
1321
1790
  if (original === '') return false // settings panel not open yet; retry on next mount
1322
1791
  warmed = true
@@ -1336,6 +1805,8 @@ window.__ModuleLoader__.load({
1336
1805
  } finally {
1337
1806
  if (typeof onDone === 'function') onDone()
1338
1807
  }
1808
+ if (visited === 0) return false
1809
+ model.persistIndex()
1339
1810
  log('info', '预载设置索引', { total: sections.length, visited })
1340
1811
  return true
1341
1812
  }
@@ -1401,6 +1872,97 @@ window.__ModuleLoader__.load({
1401
1872
  return { jumpToPage, jumpToOption }
1402
1873
  }
1403
1874
 
1875
+ /**
1876
+ * Resolve the running DSH build's settings/LLM read faces.
1877
+ *
1878
+ * DSH 0.2 (the official desktop and `dsh web` release) exposes host
1879
+ * capabilities as Typert Remote namespaces: `remote.settings.describe()`
1880
+ * and `remote.llm.listConfigurableProviders()` each resolve to
1881
+ * `{ ok: true, value } | { ok: false, error }`. Older builds exposed the
1882
+ * same data through the unary client carried by the connection service
1883
+ * (`connection.api.settings.describe({})`, resolving to
1884
+ * `{ result: { ok, value } }`). Both generations answer with the same
1885
+ * namespace views, so one small face is resolved per call.
1886
+ *
1887
+ * @param ctx - the client plugin context.
1888
+ * @returns the read face, or null when this build offers neither generation.
1889
+ */
1890
+ const resolveDshModelFace = (ctx) => {
1891
+ const remoteSettings = ctx.get('remote.settings')
1892
+ if (remoteSettings !== undefined && remoteSettings !== null) {
1893
+ const remoteLlm = ctx.get('remote.llm')
1894
+ return {
1895
+ describe: async () => {
1896
+ const response = await remoteSettings.describe()
1897
+ if (!response?.ok) throw new Error(response?.error?.message || 'settings.describe failed')
1898
+ return response.value
1899
+ },
1900
+ configuredProviders: async () => {
1901
+ if (typeof remoteLlm?.listConfigurableProviders !== 'function') return null
1902
+ const response = await remoteLlm.listConfigurableProviders().catch(() => null)
1903
+ return response?.ok ? response.value : null
1904
+ },
1905
+ }
1906
+ }
1907
+ const legacyApi = ctx.get('connection')?.api
1908
+ if (legacyApi?.settings?.describe) {
1909
+ return {
1910
+ describe: async () => {
1911
+ const response = await legacyApi.settings.describe({})
1912
+ if (!response?.result?.ok) throw new Error(response?.result?.error?.message || 'settings.describe failed')
1913
+ return response.result.value
1914
+ },
1915
+ configuredProviders: async () => {
1916
+ if (typeof legacyApi.llm?.providers !== 'function') return null
1917
+ const response = await legacyApi.llm.providers({}).catch(() => null)
1918
+ return response?.result?.ok ? response.result.value?.providers ?? null : null
1919
+ },
1920
+ }
1921
+ }
1922
+ return null
1923
+ }
1924
+
1925
+ /**
1926
+ * Read the model fields of DSH's current default model; credentials are
1927
+ * never requested. `agent-default-model` names the provider and the model,
1928
+ * the provider's own settings namespace carries the Base URL, and the model
1929
+ * profile inside that namespace may override the wire protocol.
1930
+ *
1931
+ * @param ctx - the client plugin context.
1932
+ * @param t - translate function for the failure messages.
1933
+ * @returns `{ provider, baseUrl, model }`, or null when DSH has no default model.
1934
+ * @throws when this build exposes no settings API, or the default model has no Base URL.
1935
+ */
1936
+ const readDshModelConfig = async (ctx, t) => {
1937
+ const face = resolveDshModelFace(ctx)
1938
+ if (face === null) throw new Error(t('aiDshImportApi'))
1939
+ const described = await face.describe()
1940
+ const namespaces = new Map((described?.namespaces ?? []).map((view) => [view.ns, view]))
1941
+ const defaultModel = namespaces.get('agent-default-model')?.value ?? {}
1942
+ const providerId = String(defaultModel.provider ?? '').trim()
1943
+ const modelId = String(defaultModel.model ?? '').trim()
1944
+ if (!modelId) return null
1945
+ const readPath = (value, path) => (path ?? []).reduce((current, key) => current?.[key], value)
1946
+ const providers = await face.configuredProviders()
1947
+ const providerRow = Array.isArray(providers) ? providers.find((entry) => entry.provider === providerId) : undefined
1948
+ const modelNamespace = namespaces.get(providerRow?.settingsNs || 'llm-pi-ai')
1949
+ const providerProfile = providerRow
1950
+ ? readPath(modelNamespace?.value, providerRow.settingsPath) ?? {}
1951
+ : modelNamespace?.value?.providers?.[providerId] ?? {}
1952
+ const configuredModels = Array.isArray(providerProfile.models) ? providerProfile.models : []
1953
+ const modelProfile = configuredModels.find((entry) => entry?.id === modelId) ?? {}
1954
+ const protocol = String(modelProfile.api ?? providerProfile.api ?? '').trim()
1955
+ const baseUrl = String(providerProfile.baseURL ?? '').trim()
1956
+ if (!baseUrl) throw new Error(t('aiDshImportNoBaseUrl').replace('{model}', modelId))
1957
+ return {
1958
+ provider: providerId.toLowerCase() === 'ollama' || protocol === 'ollama'
1959
+ ? 'ollama'
1960
+ : protocol === 'anthropic-messages' ? 'anthropic' : 'openai',
1961
+ baseUrl,
1962
+ model: modelId,
1963
+ }
1964
+ }
1965
+
1404
1966
  function apply(ctx) {
1405
1967
  log('info', '应用装载', { version: PKG_VERSION })
1406
1968
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'settings-search: dictionaries')
@@ -1411,40 +1973,104 @@ window.__ModuleLoader__.load({
1411
1973
  }, 'settings-search: styles')
1412
1974
 
1413
1975
  const model = createModel(ctx)
1976
+ const indexControls = { resetHarvest: model.resetHarvest, persistIndex: model.persistIndex }
1414
1977
  const navigator = createNavigator(ctx)
1978
+ let pluginInventoryToken = 0
1979
+
1980
+ /** Apply one plugin inventory as the index fingerprint, invalidating on change. */
1981
+ const applyPluginFingerprint = (rows) => {
1982
+ if (!Array.isArray(rows) || rows.length === 0) return
1983
+ if (model.applyIndexFingerprint('manager:' + otherPluginFingerprint(rows))) {
1984
+ window.dispatchEvent(new CustomEvent(INDEX_INVALID_EVENT))
1985
+ log('info', '检测到其它插件变化,将重建设置索引', {})
1986
+ }
1987
+ }
1415
1988
 
1416
- /** Read the model fields from DSH; credentials are never requested. */
1417
- const readDshModelConfig = async () => {
1418
- const connection = ctx.get('connection')
1419
- const response = await connection.api.settings.describe({})
1420
- if (!response.result.ok) throw new Error(response.result.error?.message || 'settings.describe failed')
1421
- const namespaces = new Map(response.result.value.namespaces.map((view) => [view.ns, view]))
1422
- const defaultModel = namespaces.get('agent-default-model')?.value ?? {}
1423
- const providerId = String(defaultModel.provider ?? '').trim()
1424
- const modelId = String(defaultModel.model ?? '').trim()
1425
- const readPath = (value, path) => (path ?? []).reduce((current, key) => current?.[key], value)
1426
- const providersResponse = await connection.api.llm.providers({}).catch(() => null)
1427
- const providerRow = providersResponse?.result?.ok
1428
- ? providersResponse.result.value.providers.find((entry) => entry.provider === providerId)
1429
- : undefined
1430
- const modelNamespace = namespaces.get(providerRow?.settingsNs || 'llm-pi-ai')
1431
- const providerProfile = providerRow
1432
- ? readPath(modelNamespace?.value, providerRow.settingsPath) ?? {}
1433
- : modelNamespace?.value?.providers?.[providerId] ?? {}
1434
- const configuredModels = Array.isArray(providerProfile.models) ? providerProfile.models : []
1435
- const modelProfile = configuredModels.find((entry) => entry?.id === modelId) ?? {}
1436
- const protocol = String(modelProfile.api ?? providerProfile.api ?? '').trim()
1437
- const baseUrl = String(providerProfile.baseURL ?? '').trim()
1438
- if (!modelId || !baseUrl) return null
1439
- return {
1440
- provider: providerId.toLowerCase() === 'ollama' || protocol === 'ollama'
1441
- ? 'ollama'
1442
- : protocol === 'anthropic-messages' ? 'anthropic' : 'openai',
1443
- baseUrl,
1444
- model: modelId,
1989
+ /** Legacy (pre-0.2) client plugin manager service: `list()` plus change events. */
1990
+ const syncLegacyPluginManagerIndex = async (face) => {
1991
+ if (face === null || !face.isLoopback) return
1992
+ const token = ++pluginInventoryToken
1993
+ try {
1994
+ const rows = await face.list()
1995
+ if (token !== pluginInventoryToken) return
1996
+ applyPluginFingerprint(rows)
1997
+ } catch {
1998
+ // Keep the last trusted fingerprint when manager inspection fails.
1445
1999
  }
1446
2000
  }
1447
2001
 
2002
+ /**
2003
+ * Read the installed plugin inventory from DSH 0.2's `remote.pluginManager`
2004
+ * namespace. Both calls answer `{ ok, value }`: bundles carry `name` and
2005
+ * `version`, plugin rows carry `entryId`/`moduleName` and a fiber phase.
2006
+ *
2007
+ * @param face - the `remote.pluginManager` service.
2008
+ * @returns inventory rows for otherPluginFingerprint().
2009
+ */
2010
+ const readRemotePluginInventory = async (face) => {
2011
+ const [bundles, plugins] = await Promise.all([
2012
+ typeof face?.listBundles === 'function' ? face.listBundles().catch(() => null) : null,
2013
+ typeof face?.listPlugins === 'function' ? face.listPlugins().catch(() => null) : null,
2014
+ ])
2015
+ const rows = []
2016
+ if (bundles?.ok === true) {
2017
+ for (const row of bundles.value ?? []) rows.push({ id: row?.name, npm: row?.name, version: row?.version, enabled: row?.enabled })
2018
+ }
2019
+ if (plugins?.ok === true) {
2020
+ for (const row of plugins.value ?? []) rows.push({ id: row?.entryId, npm: row?.moduleName, enabled: row?.enabled })
2021
+ }
2022
+ return rows
2023
+ }
2024
+
2025
+ const syncRemotePluginInventory = async (face) => {
2026
+ const token = ++pluginInventoryToken
2027
+ try {
2028
+ const rows = await readRemotePluginInventory(face)
2029
+ if (token !== pluginInventoryToken) return
2030
+ applyPluginFingerprint(rows)
2031
+ } catch {
2032
+ // Keep the last trusted fingerprint when the inventory read fails.
2033
+ }
2034
+ }
2035
+
2036
+ /**
2037
+ * Whether this build exposes a plugin inventory to watch: the 0.2 Remote
2038
+ * namespace or the legacy client service. Without one the seat fingerprint
2039
+ * is the only change signal.
2040
+ */
2041
+ const hasPluginInventory = () => {
2042
+ const remote = ctx.get('remote.pluginManager')
2043
+ if (remote !== undefined && remote !== null) return true
2044
+ const legacy = ctx.get('pluginManager')
2045
+ return legacy !== undefined && legacy !== null
2046
+ }
2047
+
2048
+ /**
2049
+ * Re-check the DSH 0.2 inventory. The Remote namespace has no change event,
2050
+ * so the check runs when the namespace arrives and whenever the settings
2051
+ * panel mounts (installs and updates land between the two).
2052
+ */
2053
+ const refreshPluginInventory = () => {
2054
+ const face = ctx.get('remote.pluginManager')
2055
+ if (face !== undefined && face !== null) void syncRemotePluginInventory(face)
2056
+ }
2057
+
2058
+ ctx.inject(['remote.pluginManager'], (inner) => {
2059
+ void syncRemotePluginInventory(inner.get('remote.pluginManager'))
2060
+ return () => { pluginInventoryToken += 1 }
2061
+ }, 'settings-search: remote plugin inventory fingerprint')
2062
+
2063
+ ctx.inject(['pluginManager'], (inner) => {
2064
+ const face = inner.pluginManager ?? null
2065
+ const sync = () => { void syncLegacyPluginManagerIndex(face) }
2066
+ sync()
2067
+ const unsubscribe = typeof face?.onChange === 'function' ? face.onChange(sync) : null
2068
+ return () => {
2069
+ pluginInventoryToken += 1
2070
+ if (typeof unsubscribe === 'function') unsubscribe()
2071
+ }
2072
+ }, 'settings-search: plugin manager index fingerprint (legacy)')
2073
+
1448
2074
  const SearchIcon = React.createElement('svg', {
1449
2075
  className: 'sss-icon',
1450
2076
  viewBox: '0 0 16.5 16.5',
@@ -1525,6 +2151,36 @@ window.__ModuleLoader__.load({
1525
2151
  ])
1526
2152
  }
1527
2153
 
2154
+ /**
2155
+ * Read-only log viewer. Shows the host log tail when the host answered and
2156
+ * the current session's ring otherwise, with the file path (or the reason
2157
+ * the file is unavailable) spelled out above the text.
2158
+ */
2159
+ function LogViewer({ t, view }) {
2160
+ return React.createElement('div', {
2161
+ className: 'sss-lv-overlay',
2162
+ onMouseDown: (event) => { if (event.target === event.currentTarget) view.onClose() },
2163
+ }, [
2164
+ React.createElement('div', { className: 'sss-lv', role: 'dialog', 'aria-modal': 'true' }, [
2165
+ React.createElement('div', { className: 'sss-lv-header' }, [
2166
+ React.createElement('span', { className: 'sss-lv-title' }, t('logViewerTitle')),
2167
+ React.createElement('button', { className: 'sss-lv-close', type: 'button', 'aria-label': t('logViewerClose'), onClick: () => view.onClose() }, '✕'),
2168
+ ]),
2169
+ React.createElement('p', { className: 'sss-lv-meta' }, view.metaText),
2170
+ view.truncated ? React.createElement('p', { className: 'sss-lv-meta' }, t('logViewerTruncated')) : null,
2171
+ React.createElement('pre', { className: 'sss-lv-body' }, view.text === '' ? t('logViewerEmpty') : view.text),
2172
+ React.createElement('div', { className: 'sss-lv-actions' }, [
2173
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: view.loading, onClick: () => view.onRefresh() },
2174
+ view.loading ? t('logViewerLoading') : t('logViewerRefresh')),
2175
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => view.onOpenFolder() }, t('logOpenDir')),
2176
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => view.onCopy() },
2177
+ view.copied ? t('logViewerCopied') : t('logViewerCopy')),
2178
+ React.createElement('button', { className: 'sss-vr-btn sss-vr-primary', type: 'button', onClick: () => view.onClose() }, t('logViewerClose')),
2179
+ ]),
2180
+ ]),
2181
+ ])
2182
+ }
2183
+
1528
2184
  function SettingsHeaderSearch() {
1529
2185
  const [query, setQuery] = React.useState('')
1530
2186
  const [open, setOpen] = React.useState(false)
@@ -1597,6 +2253,27 @@ window.__ModuleLoader__.load({
1597
2253
  }
1598
2254
  }, [])
1599
2255
 
2256
+ React.useEffect(() => {
2257
+ // DSH 0.2 answers the plugin inventory through `remote.pluginManager`
2258
+ // with no change event, so re-check it every time the panel mounts.
2259
+ refreshPluginInventory()
2260
+ // Without any plugin inventory, the seats the index was built from are
2261
+ // the only available change signal: re-apply them on every mount, so a
2262
+ // page or row that appeared since the last visit invalidates the index.
2263
+ const fallback = setTimeout(() => {
2264
+ if (hasPluginInventory()) return
2265
+ if (model.applyIndexFingerprint(model.slotsFingerprint())) {
2266
+ window.dispatchEvent(new CustomEvent(INDEX_INVALID_EVENT))
2267
+ }
2268
+ }, 3000)
2269
+ const onInvalid = () => { setWarming(false); setTick((value) => value + 1) }
2270
+ window.addEventListener(INDEX_INVALID_EVENT, onInvalid)
2271
+ return () => {
2272
+ clearTimeout(fallback)
2273
+ window.removeEventListener(INDEX_INVALID_EVENT, onInvalid)
2274
+ }
2275
+ }, [])
2276
+
1600
2277
  React.useEffect(() => {
1601
2278
  const onWelcome = (event) => { if (event?.detail === true) setShowWelcome(true) }
1602
2279
  window.addEventListener(WELCOME_EVENT, onWelcome)
@@ -1924,7 +2601,6 @@ window.__ModuleLoader__.load({
1924
2601
  ? React.createElement(Welcome, { t, onClose: dismissWelcome, onOpenPanel: openPanelFromWelcome })
1925
2602
  : null,
1926
2603
  ]),
1927
- React.createElement('div', { className: 'sss-wrap sss-top' }, [pageTriggerButton]),
1928
2604
  React.createElement('div', {
1929
2605
  className: 'sss-page-overlay',
1930
2606
  onMouseDown: (event) => { if (event.target === event.currentTarget) setPageOpen(false) },
@@ -2065,9 +2741,13 @@ window.__ModuleLoader__.load({
2065
2741
  const [apiKey, setApiKey] = React.useState(cfg.apiKey)
2066
2742
  const providerDrafts = React.useRef({})
2067
2743
  const testAbortRef = React.useRef(null)
2744
+ const modelsAbortRef = React.useRef(null)
2068
2745
  const [aiSaved, setAiSaved] = React.useState(false)
2069
2746
  const [testState, setTestState] = React.useState('idle') // idle | loading | ok | fail
2070
2747
  const [testMsg, setTestMsg] = React.useState('')
2748
+ const [aiModels, setAiModels] = React.useState([])
2749
+ const [modelsState, setModelsState] = React.useState('idle') // idle | loading | ready | fail
2750
+ const [modelsMsg, setModelsMsg] = React.useState('')
2071
2751
  const [dshImportState, setDshImportState] = React.useState('idle') // idle | loading | ok | fail
2072
2752
  const [dshImportMsg, setDshImportMsg] = React.useState('')
2073
2753
  const [, setLogVersion] = React.useState(0)
@@ -2075,6 +2755,12 @@ window.__ModuleLoader__.load({
2075
2755
  const [logCopyFail, setLogCopyFail] = React.useState(false)
2076
2756
  const [logExportFail, setLogExportFail] = React.useState(false)
2077
2757
  const [logCleared, setLogCleared] = React.useState(false)
2758
+ const [logViewer, setLogViewer] = React.useState(false)
2759
+ const [logViewerState, setLogViewerState] = React.useState({ status: 'idle', text: '', size: 0, truncated: false })
2760
+ const [logViewerCopied, setLogViewerCopied] = React.useState(false)
2761
+ const [logFolderMsg, setLogFolderMsg] = React.useState('')
2762
+ const [indexState, setIndexState] = React.useState('idle')
2763
+ const [warming, setWarming] = React.useState(false)
2078
2764
  const [confirmClear, setConfirmClear] = React.useState(false)
2079
2765
  const [confirmClearAi, setConfirmClearAi] = React.useState(false)
2080
2766
 
@@ -2106,15 +2792,24 @@ window.__ModuleLoader__.load({
2106
2792
  return () => testAbortRef.current?.abort()
2107
2793
  }, [provider, baseUrl, model, apiKey])
2108
2794
 
2795
+ React.useEffect(() => () => modelsAbortRef.current?.abort(), [provider, baseUrl, apiKey])
2796
+
2109
2797
  React.useEffect(() => {
2110
2798
  const sync = () => setLogVersion((v) => v + 1)
2111
2799
  window.addEventListener(LOG_EVENT, sync)
2112
- return () => window.removeEventListener(LOG_EVENT, sync)
2800
+ window.addEventListener(LOG_COUNT_EVENT, sync)
2801
+ // The badge counts what exists on disk, including earlier runs.
2802
+ void readLogCount()
2803
+ return () => {
2804
+ window.removeEventListener(LOG_EVENT, sync)
2805
+ window.removeEventListener(LOG_COUNT_EVENT, sync)
2806
+ }
2113
2807
  }, [])
2114
2808
 
2115
2809
  useAutoReset(logCopied, () => setLogCopied(false))
2116
2810
  useAutoReset(logCleared, () => setLogCleared(false))
2117
2811
  useAutoReset(logExportFail, () => setLogExportFail(false))
2812
+ useAutoReset(logViewerCopied, () => setLogViewerCopied(false))
2118
2813
 
2119
2814
  const changeProvider = (next) => {
2120
2815
  providerDrafts.current[provider] = { baseUrl, model }
@@ -2122,6 +2817,68 @@ window.__ModuleLoader__.load({
2122
2817
  setProvider(next)
2123
2818
  setBaseUrl(draft?.baseUrl ?? (next === 'ollama' ? OLLAMA_BASE_URL : ''))
2124
2819
  setModel(draft?.model ?? '')
2820
+ resetModelList()
2821
+ }
2822
+
2823
+ const resetModelList = () => {
2824
+ modelsAbortRef.current?.abort()
2825
+ setAiModels([])
2826
+ setModelsState('idle')
2827
+ setModelsMsg('')
2828
+ }
2829
+
2830
+ const modelsErrorHint = (code) => {
2831
+ if (code === 'need-config') return t('aiModelsNeedConfig')
2832
+ const cloudKeys = {
2833
+ auth: 'aiTestErrAuth',
2834
+ forbidden: 'aiTestErrForbidden',
2835
+ 'not-found': 'aiTestErrNotFound',
2836
+ 'rate-limit': 'aiTestErrRateLimit',
2837
+ 'bad-request': 'aiTestErrBadRequest',
2838
+ server: 'aiTestErrServer',
2839
+ timeout: 'aiTestErrTimeout',
2840
+ network: 'aiTestErrNetwork',
2841
+ 'bad-response': 'aiTestErrBadResponse',
2842
+ 'unsafe-url': 'aiTestErrUnsafeUrl',
2843
+ }
2844
+ const ollamaKeys = { ...cloudKeys, network: 'aiOllamaErrNetwork', forbidden: 'aiOllamaErrForbidden', timeout: 'aiOllamaErrTimeout' }
2845
+ return t((provider === 'ollama' ? ollamaKeys : cloudKeys)[code] ?? 'aiTestErrHttp')
2846
+ }
2847
+
2848
+ const loadModels = async () => {
2849
+ if (modelsState === 'loading') return
2850
+ const source = normalizeAiConfig({ provider, baseUrl, model, apiKey })
2851
+ if (!modelsUrl(source.provider, source.baseUrl) || (source.provider !== 'ollama' && !source.apiKey)) {
2852
+ setModelsState('fail')
2853
+ setModelsMsg(t('aiModelsFail').replace('{hint}', t('aiModelsNeedConfig')).replace('{detail}', ''))
2854
+ return
2855
+ }
2856
+ const controller = new AbortController()
2857
+ modelsAbortRef.current?.abort()
2858
+ modelsAbortRef.current = controller
2859
+ setModelsState('loading')
2860
+ setModelsMsg('')
2861
+ const startedAt = Date.now()
2862
+ log('info', '获取 AI 模型列表', { provider: source.provider })
2863
+ const result = await fetchAiModels(source, controller.signal)
2864
+ if (controller.signal.aborted) return
2865
+ if (modelsAbortRef.current !== controller) return
2866
+ modelsAbortRef.current = null
2867
+ if (result.ok) {
2868
+ setAiModels(result.models)
2869
+ setModelsState('ready')
2870
+ setModelsMsg(result.models.length === 0
2871
+ ? t('aiModelsEmpty')
2872
+ : t('aiModelsLoaded').replace('{count}', String(result.models.length)))
2873
+ log('info', '获取 AI 模型列表成功', { count: result.models.length, duration: Date.now() - startedAt })
2874
+ } else {
2875
+ setAiModels([])
2876
+ setModelsState('fail')
2877
+ const hint = modelsErrorHint(result.code)
2878
+ const detail = result.detail ? `(${result.detail})` : ''
2879
+ setModelsMsg(t('aiModelsFail').replace('{hint}', hint).replace('{detail}', detail))
2880
+ log('error', '获取 AI 模型列表失败', { code: result.code, detail: result.detail, duration: Date.now() - startedAt })
2881
+ }
2125
2882
  }
2126
2883
 
2127
2884
  const saveAi = () => {
@@ -2141,13 +2898,14 @@ window.__ModuleLoader__.load({
2141
2898
  setDshImportState('loading')
2142
2899
  setDshImportMsg('')
2143
2900
  try {
2144
- const imported = await readDshModelConfig()
2901
+ const imported = await readDshModelConfig(ctx, t)
2145
2902
  if (!imported) throw new Error(t('aiDshImportEmpty'))
2146
2903
  providerDrafts.current[provider] = { baseUrl, model }
2147
2904
  setAiConfig({ ...imported, apiKey })
2148
2905
  setProvider(imported.provider)
2149
2906
  setBaseUrl(imported.baseUrl)
2150
2907
  setModel(imported.model)
2908
+ resetModelList()
2151
2909
  setDshImportState('ok')
2152
2910
  setDshImportMsg(t('aiDshImportOk'))
2153
2911
  log('info', '导入 DSH 模型配置', { provider: imported.provider, model: imported.model })
@@ -2164,6 +2922,25 @@ window.__ModuleLoader__.load({
2164
2922
  setSearchStyleState(style)
2165
2923
  }
2166
2924
 
2925
+ const rebuildIndex = async () => {
2926
+ if (indexState === 'loading' || warming) return
2927
+ setIndexState('loading')
2928
+ setWarming(true)
2929
+ indexControls.resetHarvest()
2930
+ try {
2931
+ const done = await warmAllSections(model, () => setWarming(true), () => setWarming(false))
2932
+ if (!done) throw new Error('index rebuild did not visit any sections')
2933
+ indexControls.persistIndex()
2934
+ setIndexState('done')
2935
+ window.dispatchEvent(new CustomEvent(INDEX_REBUILD_DONE_EVENT))
2936
+ setTimeout(() => setIndexState((state) => state === 'done' ? 'idle' : state), 2400)
2937
+ } catch (error) {
2938
+ setIndexState('idle')
2939
+ setWarming(false)
2940
+ log('error', '手动重建设置索引失败', { detail: String(error?.message ?? error).slice(0, 160) })
2941
+ }
2942
+ }
2943
+
2167
2944
  const updateShortcutEnabled = (enabled) => {
2168
2945
  setSearchShortcutEnabled(enabled)
2169
2946
  setShortcutOn(enabled)
@@ -2180,6 +2957,7 @@ window.__ModuleLoader__.load({
2180
2957
  setModel('')
2181
2958
  setApiKey('')
2182
2959
  providerDrafts.current = {}
2960
+ resetModelList()
2183
2961
  setAiSaved(false)
2184
2962
  log('info', '清除 API 配置', {})
2185
2963
  }
@@ -2259,8 +3037,64 @@ window.__ModuleLoader__.load({
2259
3037
  const clearLogs = () => {
2260
3038
  setConfirmClear(false)
2261
3039
  LOGS.length = 0
3040
+ // Drop queued lines too: a late flush would resurrect what was cleared.
3041
+ discardPendingLogs()
2262
3042
  setLogVersion((v) => v + 1)
2263
3043
  setLogCleared(true)
3044
+ void clearLogFile()
3045
+ }
3046
+
3047
+ /** Read the on-disk tail for the viewer; fall back to this session's ring. */
3048
+ const refreshLogView = async () => {
3049
+ setLogViewerState((current) => ({ ...current, status: 'loading' }))
3050
+ const read = await readLogFile()
3051
+ if (read === null) {
3052
+ setLogViewerState({ status: 'fail', text: sessionLogText(), size: 0, truncated: false })
3053
+ return
3054
+ }
3055
+ setLogViewerState({ status: 'ok', text: read.text, size: read.size, truncated: read.truncated })
3056
+ }
3057
+
3058
+ const openLogViewer = () => {
3059
+ flushLogs()
3060
+ setLogViewer(true)
3061
+ void refreshLogView()
3062
+ }
3063
+
3064
+ const copyLogView = async () => {
3065
+ try {
3066
+ await navigator.clipboard.writeText(logViewerState.text)
3067
+ setLogViewerCopied(true)
3068
+ } catch {
3069
+ setLogCopyFail(true)
3070
+ }
3071
+ }
3072
+
3073
+ /** Open the on-disk log folder through the host half. */
3074
+ const openLogDir = async () => {
3075
+ flushLogs()
3076
+ setLogFolderMsg('')
3077
+ const result = await openLogFolder()
3078
+ setLogFolderMsg(result.ok
3079
+ ? t('logOpenOk')
3080
+ : `${t('logOpenFail')}${result.detail === '' ? '' : `(${result.detail})`}`)
3081
+ }
3082
+
3083
+ /** Badge value: entries that exist on disk, or this session's ring when the host is unavailable. */
3084
+ const logBadgeCount = logTarget().count ?? LOGS.length
3085
+
3086
+ const logView = {
3087
+ text: logViewerState.text,
3088
+ truncated: logViewerState.truncated,
3089
+ loading: logViewerState.status === 'loading',
3090
+ copied: logViewerCopied,
3091
+ metaText: logViewerState.status === 'fail' || logTarget().file === ''
3092
+ ? t('logViewerMemory')
3093
+ : t('logViewerFile').replace('{file}', logTarget().file),
3094
+ onClose: () => setLogViewer(false),
3095
+ onRefresh: () => { void refreshLogView() },
3096
+ onOpenFolder: () => { void openLogDir() },
3097
+ onCopy: () => { void copyLogView() },
2264
3098
  }
2265
3099
 
2266
3100
  const check = async () => {
@@ -2394,6 +3228,23 @@ window.__ModuleLoader__.load({
2394
3228
  ]),
2395
3229
  React.createElement('p', { className: 'sss-vr-hint' }, t('shortcutHint')),
2396
3230
  ]),
3231
+ React.createElement('div', { className: 'sss-vr-field' }, [
3232
+ React.createElement('div', { className: 'sss-vr-field-head' }, [
3233
+ React.createElement('span', { className: 'sss-vr-label' }, t('indexTitle')),
3234
+ ]),
3235
+ React.createElement('p', { className: 'sss-vr-hint' }, t('indexDesc')),
3236
+ React.createElement('div', { className: 'sss-vr-actions' }, [
3237
+ React.createElement('button', {
3238
+ className: 'sss-vr-btn',
3239
+ type: 'button',
3240
+ disabled: indexState === 'loading' || warming,
3241
+ onClick: () => { void rebuildIndex() },
3242
+ }, indexState === 'loading' || warming ? t('indexRebuilding') : t('indexRebuild')),
3243
+ ]),
3244
+ indexState === 'done'
3245
+ ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-state-success-primary)' } }, t('indexDone'))
3246
+ : null,
3247
+ ]),
2397
3248
  React.createElement('div', { className: 'sss-vr-field sss-vr-form' }, [
2398
3249
  React.createElement('div', { className: 'sss-vr-field-head' }, [
2399
3250
  React.createElement('label', { className: 'sss-vr-label' }, t('aiProvider')),
@@ -2428,44 +3279,56 @@ window.__ModuleLoader__.load({
2428
3279
  'data-lpignore': 'true',
2429
3280
  'data-1p-ignore': 'true',
2430
3281
  '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,
3282
+ onChange: (event) => { setBaseUrl(event.target.value); resetModelList() },
3283
+ }),
3284
+ React.createElement('p', { className: 'sss-vr-hint' }, t('aiBaseUrlHint')),
3285
+ React.createElement('div', { className: 'sss-vr-field-head' }, [
3286
+ React.createElement('label', { className: 'sss-vr-label' }, t('aiModel')),
3287
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: modelsState === 'loading', onClick: () => { void loadModels() } },
3288
+ modelsState === 'loading' ? t('aiModelsLoading') : t('aiModelsLoad')),
3289
+ ]),
3290
+ React.createElement('p', { className: 'sss-vr-hint' }, t('aiModelHint')),
3291
+ React.createElement('input', {
3292
+ className: 'sss-vr-input',
3293
+ type: 'text',
3294
+ name: 'dsh-settings-search-model',
3295
+ 'aria-label': t('aiModel'),
3296
+ value: model,
3297
+ list: 'dsh-settings-search-model-options',
3298
+ placeholder: provider === 'ollama' ? 'qwen3:8b'
3299
+ : provider === 'anthropic' ? 'claude-sonnet-4-20250514' : 'gpt-4o-mini',
3300
+ spellCheck: false,
3301
+ autoComplete: 'off',
3302
+ 'data-lpignore': 'true',
3303
+ 'data-1p-ignore': 'true',
3304
+ 'data-form-type': 'other',
3305
+ onChange: (event) => setModel(event.target.value),
3306
+ }),
3307
+ React.createElement('datalist', { id: 'dsh-settings-search-model-options' },
3308
+ aiModels.map((item) => React.createElement('option', { key: item, value: item }, item))),
3309
+ modelsMsg
3310
+ ? React.createElement('p', {
3311
+ className: modelsState === 'fail' ? 'sss-vr-note' : 'sss-vr-hint',
3312
+ style: modelsState === 'fail' ? { color: 'var(--dsw-alias-state-error-primary)' } : undefined,
3313
+ }, modelsMsg)
3314
+ : null,
3315
+ provider !== 'ollama' ? React.createElement('div', { className: 'sss-vr-field-head' }, [
3316
+ React.createElement('label', { className: 'sss-vr-label' }, t('aiApiKey')),
3317
+ ]) : null,
3318
+ provider !== 'ollama' ? React.createElement('input', {
3319
+ className: 'sss-vr-input sss-vr-mask',
3320
+ type: 'text',
3321
+ name: 'dsh-settings-search-api-key',
3322
+ 'aria-label': t('aiApiKey'),
3323
+ value: apiKey,
3324
+ placeholder: 'sk-…',
3325
+ spellCheck: false,
3326
+ autoComplete: 'off',
3327
+ 'data-lpignore': 'true',
3328
+ 'data-1p-ignore': 'true',
3329
+ 'data-form-type': 'other',
3330
+ onChange: (event) => { setApiKey(event.target.value); resetModelList() },
3331
+ }) : null,
2469
3332
  React.createElement('div', { className: 'sss-vr-actions' }, [
2470
3333
  React.createElement('button', { className: 'sss-vr-btn sss-vr-primary', type: 'button', onClick: () => { void saveAi() } },
2471
3334
  aiSaved ? t('aiConfigSaved') : t('aiConfigSave')),
@@ -2476,10 +3339,10 @@ window.__ModuleLoader__.load({
2476
3339
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => requestClearAi() }, t('aiClear')),
2477
3340
  ]),
2478
3341
  dshImportMsg
2479
- ? React.createElement('p', { className: 'sss-vr-note', style: { color: dshImportState === 'ok' ? '#1e8e3e' : 'var(--dsw-alias-label-danger)' } }, dshImportMsg)
3342
+ ? React.createElement('p', { className: 'sss-vr-note', style: { color: dshImportState === 'ok' ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)' } }, dshImportMsg)
2480
3343
  : null,
2481
3344
  testMsg
2482
- ? React.createElement('p', { className: 'sss-vr-note', style: { color: testState === 'ok' ? '#1e8e3e' : 'var(--dsw-alias-label-danger)' } }, testMsg)
3345
+ ? React.createElement('p', { className: 'sss-vr-note', style: { color: testState === 'ok' ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)' } }, testMsg)
2483
3346
  : null,
2484
3347
  ]),
2485
3348
  React.createElement('div', { className: 'sss-vr-field' }, [
@@ -2505,7 +3368,7 @@ window.__ModuleLoader__.load({
2505
3368
  copied ? t('updCopied') : t('updCopy')),
2506
3369
  React.createElement('a', { className: 'sss-vr-link', href: releasesUrl, target: '_blank', rel: 'noopener noreferrer' }, t('updRelease')),
2507
3370
  ]),
2508
- copyFailed ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-label-danger)' } }, t('updCopyFail')) : null,
3371
+ copyFailed ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-state-error-primary)' } }, t('updCopyFail')) : null,
2509
3372
  ])
2510
3373
  : null,
2511
3374
  React.createElement('div', { className: 'sss-vr-actions' }, [
@@ -2513,6 +3376,8 @@ window.__ModuleLoader__.load({
2513
3376
  state === 'loading' ? t('updChecking') : t('updCheck')),
2514
3377
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: notesState === 'loading', onClick: () => { void fetchNotes() } },
2515
3378
  notesState === 'loading' ? t('updFetchingNotes') : t('updReleaseNotes')),
3379
+ React.createElement('a', { className: 'sss-vr-link', href: REPO_URL, target: '_blank', rel: 'noopener noreferrer' },
3380
+ t('updRepo')),
2516
3381
  ]),
2517
3382
  notesState === 'loaded'
2518
3383
  ? React.createElement('div', { className: 'sss-vr-notes' }, notesBody)
@@ -2521,21 +3386,28 @@ window.__ModuleLoader__.load({
2521
3386
  React.createElement('div', { className: 'sss-vr-field' }, [
2522
3387
  React.createElement('div', { className: 'sss-vr-field-head' }, [
2523
3388
  React.createElement('span', { className: 'sss-vr-label' }, t('logTitle')),
2524
- LOGS.length > 0 ? React.createElement('span', { className: 'sss-vr-badge' }, String(LOGS.length)) : null,
3389
+ logBadgeCount > 0
3390
+ ? React.createElement('span', { className: 'sss-vr-badge', title: t('logCountHint') }, String(logBadgeCount))
3391
+ : null,
2525
3392
  ]),
2526
3393
  React.createElement('p', { className: 'sss-vr-hint' }, t('logDesc')),
2527
- LOGS.length === 0
3394
+ LOGS.length === 0 && logBadgeCount === 0
2528
3395
  ? React.createElement('p', { className: 'sss-vr-note' }, t('logEmpty'))
2529
3396
  : null,
2530
3397
  React.createElement('div', { className: 'sss-vr-actions' }, [
3398
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => openLogViewer() }, t('logView')),
3399
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void openLogDir() } }, t('logOpenDir')),
2531
3400
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void exportLogs() } }, t('logExport')),
3401
+ React.createElement('a', { className: 'sss-vr-link', href: ISSUES_URL, target: '_blank', rel: 'noopener noreferrer' },
3402
+ t('logIssues')),
2532
3403
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void copyLogs() } },
2533
3404
  logCopied ? t('logCopied') : t('logCopy')),
2534
- React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: LOGS.length === 0, onClick: () => requestClear() },
3405
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: logBadgeCount === 0, onClick: () => requestClear() },
2535
3406
  logCleared ? t('logCleared') : t('logClear')),
2536
3407
  ]),
2537
- logCopyFail ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-label-danger)' } }, t('logCopyFail')) : null,
2538
- logExportFail ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-label-danger)' } }, t('logExportFail')) : null,
3408
+ logFolderMsg ? React.createElement('p', { className: 'sss-vr-note' }, logFolderMsg) : null,
3409
+ logCopyFail ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-state-error-primary)' } }, t('logCopyFail')) : null,
3410
+ logExportFail ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-state-error-primary)' } }, t('logExportFail')) : null,
2539
3411
  ]),
2540
3412
  ]) : null,
2541
3413
  confirmClear
@@ -2549,6 +3421,9 @@ window.__ModuleLoader__.load({
2549
3421
  onCancel: () => cancelClear(),
2550
3422
  })
2551
3423
  : null,
3424
+ logViewer
3425
+ ? React.createElement(LogViewer, { t, view: logView })
3426
+ : null,
2552
3427
  confirmClearAi
2553
3428
  ? React.createElement(ConfirmModal, {
2554
3429
  t,