@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/README.en.md +8 -6
- package/README.md +8 -6
- package/lib/client.js +970 -95
- package/lib/index.js +448 -11
- package/package.json +5 -4
- package/scripts/ai.test.mjs +390 -5
- package/scripts/check.mjs +28 -0
- package/scripts/log.test.mjs +345 -0
package/lib/client.js
CHANGED
|
@@ -30,8 +30,10 @@ window.__ModuleLoader__.load({
|
|
|
30
30
|
|
|
31
31
|
const NS = 'settings-search-v5'
|
|
32
32
|
/** Single source of truth is package.json — scripts/check.mjs asserts this matches. */
|
|
33
|
-
const PKG_VERSION = '1.
|
|
33
|
+
const PKG_VERSION = '1.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
|
-
|
|
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
|
-
|
|
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?
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
1417
|
-
const
|
|
1418
|
-
|
|
1419
|
-
const
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
'
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
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' ? '
|
|
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' ? '
|
|
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-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
2538
|
-
|
|
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,
|