@objectivex666/dsh-settings-search 1.12.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,7 +30,7 @@ window.__ModuleLoader__.load({
30
30
 
31
31
  const NS = 'settings-search-v5'
32
32
  /** Single source of truth is package.json — scripts/check.mjs asserts this matches. */
33
- const PKG_VERSION = '1.12.0'
33
+ const PKG_VERSION = '1.13.0'
34
34
  const NPM_NAME = '@objectivex666/dsh-settings-search'
35
35
  const REPO_URL = 'https://github.com/objectivex666/dsh-settings-search'
36
36
  const ISSUES_URL = 'https://github.com/objectivex666/dsh-settings-search/issues/new/choose'
@@ -107,6 +107,8 @@ window.__ModuleLoader__.load({
107
107
  aiDshImportOk: '已导入 Base URL 与模型名;API Key 未改动 ✓',
108
108
  aiDshImportFail: '导入失败:{reason}',
109
109
  aiDshImportEmpty: '未找到 DSH 当前默认模型配置',
110
+ aiDshImportApi: '当前 DSH 版本未提供模型配置接口(remote.settings)',
111
+ aiDshImportNoBaseUrl: '默认模型「{model}」在 DSH 设置中没有可导入的 Base URL',
110
112
  aiConfigSave: '保存配置',
111
113
  aiConfigSaved: '已保存 ✓',
112
114
  aiClear: '清除配置',
@@ -132,9 +134,14 @@ window.__ModuleLoader__.load({
132
134
  aiOllamaErrForbidden: 'Ollama 拒绝访问,请检查 OLLAMA_ORIGINS 是否允许当前 DSH 页面来源',
133
135
  aiOllamaErrTimeout: 'Ollama 响应超时,请检查本机服务及模型加载状态',
134
136
  logTitle: '日志',
135
- logDesc: '记录插件运行、搜索与 AI 请求的关键动作,便于排查问题。',
137
+ logCountHint: '磁盘日志文件中的条目数',
138
+ logDesc: '记录插件运行、搜索与 AI 请求的关键动作,便于排查问题;日志按天写入 DSH 的日志文件夹,文件名带日期。',
136
139
  logEmpty: '暂无日志',
137
140
  logExport: '导出日志',
141
+ logView: '查看日志',
142
+ logOpenDir: '打开日志文件夹',
143
+ logOpenOk: '已在文件管理器中打开日志文件夹 ✓',
144
+ logOpenFail: '打开日志文件夹失败',
138
145
  logIssues: '反馈问题',
139
146
  logCopy: '复制日志',
140
147
  logCopied: '已复制 ✓',
@@ -142,10 +149,20 @@ window.__ModuleLoader__.load({
142
149
  logClear: '清空日志',
143
150
  logCleared: '已清空',
144
151
  logConfirmTitle: '清空日志',
145
- logConfirm: '确定要清空全部日志吗?此操作不可撤销。',
152
+ logConfirm: '确定要清空全部日志吗?磁盘上的日志文件也会一并清空。',
146
153
  logConfirmYes: '确认清空',
147
154
  logConfirmCancel: '取消',
148
155
  logExportFail: '导出失败,请重试。',
156
+ logViewerTitle: '运行日志',
157
+ logViewerRefresh: '刷新',
158
+ logViewerLoading: '正在读取…',
159
+ logViewerCopy: '复制',
160
+ logViewerCopied: '已复制 ✓',
161
+ logViewerClose: '关闭',
162
+ logViewerEmpty: '暂无日志。',
163
+ logViewerFile: '文件:{file}',
164
+ logViewerMemory: '宿主日志通道不可用,这里显示本次会话的记录,重启后不再保留。',
165
+ logViewerTruncated: '仅显示文件末尾部分,更早的内容请打开日志文件夹查看。',
149
166
  wlTitle: '欢迎使用设置搜索',
150
167
  wlSubtitle: '为 DSH 设置面板提供即时搜索与导航,几秒钟就能找到任何设置。',
151
168
  wlFeature1Title: '实时搜索',
@@ -236,6 +253,8 @@ window.__ModuleLoader__.load({
236
253
  aiDshImportOk: 'Imported Base URL and model; API key unchanged ✓',
237
254
  aiDshImportFail: 'Import failed: {reason}',
238
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',
239
258
  aiConfigSave: 'Save config',
240
259
  aiConfigSaved: 'Saved ✓',
241
260
  aiClear: 'Clear config',
@@ -261,9 +280,14 @@ window.__ModuleLoader__.load({
261
280
  aiOllamaErrForbidden: 'Ollama denied access. Check that OLLAMA_ORIGINS allows this DSH page origin',
262
281
  aiOllamaErrTimeout: 'Ollama timed out. Check the local service and model loading status',
263
282
  logTitle: 'Logs',
264
- 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.',
265
285
  logEmpty: 'No logs yet',
266
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',
267
291
  logIssues: 'Report issue',
268
292
  logCopy: 'Copy logs',
269
293
  logCopied: 'Copied ✓',
@@ -271,10 +295,20 @@ window.__ModuleLoader__.load({
271
295
  logClear: 'Clear logs',
272
296
  logCleared: 'Cleared',
273
297
  logConfirmTitle: 'Clear logs',
274
- logConfirm: 'Clear all logs? This cannot be undone.',
298
+ logConfirm: 'Clear all logs? The log file on disk is truncated as well.',
275
299
  logConfirmYes: 'Clear logs',
276
300
  logConfirmCancel: 'Cancel',
277
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.',
278
312
  wlTitle: 'Welcome to Settings Search',
279
313
  wlSubtitle: 'Instant search and navigation for the DSH settings panel — find any setting in seconds.',
280
314
  wlFeature1Title: 'Instant search',
@@ -293,7 +327,14 @@ window.__ModuleLoader__.load({
293
327
  wlFeature6Desc: 'On first open it automatically passes through every settings section, so in-page options are searchable without opening each page manually.',
294
328
  }
295
329
 
296
- /** 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
+ */
297
338
  const DEEP_SLOTS = [
298
339
  { slot: 'settings.plugins.tab', parent: 'plugins' },
299
340
  { slot: 'settings.plugin.item', parent: 'plugins' },
@@ -472,16 +513,192 @@ window.__ModuleLoader__.load({
472
513
 
473
514
  const AI_TOGGLE_EVENT = 'dsh-settings-search:ai-toggle'
474
515
  const AI_CONFIG_EVENT = 'dsh-settings-search:ai-config'
475
- /** 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
+ */
476
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'
477
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')
478
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
+
479
683
  const log = (level, msg, meta) => {
480
684
  if (typeof window === 'undefined') return
481
- 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)
482
687
  if (LOGS.length > LOG_MAX) LOGS.splice(0, LOGS.length - LOG_MAX)
688
+ queueLogLine(logLine(entry))
483
689
  try { window.dispatchEvent(new CustomEvent(LOG_EVENT, { detail: LOGS.length })) } catch {}
484
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
+
485
702
  const formatLogs = (logs) => {
486
703
  const lines = [
487
704
  'dsh-settings-search log',
@@ -490,9 +707,7 @@ window.__ModuleLoader__.load({
490
707
  `count: ${logs.length}`,
491
708
  '',
492
709
  ]
493
- for (const entry of logs) {
494
- lines.push(`[${entry.time}] [${entry.level}] ${entry.msg}${entry.meta ? ' ' + JSON.stringify(entry.meta) : ''}`)
495
- }
710
+ for (const entry of logs) lines.push(logLine(entry))
496
711
  return lines.join('\n')
497
712
  }
498
713
  const AI_CACHE = new Map()
@@ -959,7 +1174,7 @@ window.__ModuleLoader__.load({
959
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; }
960
1175
  .sss-trigger:hover { border-color: var(--dsw-alias-label-dimmed); color: var(--dsw-alias-label-secondary); }
961
1176
  .sss-trigger:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; }
962
- .sss-page-overlay { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: flex-start; justify-content: center; padding: 72px 20px 32px; background: var(--dsw-alias-bg-mask, rgb(0 0 0 / 36%)); }
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%)); }
963
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); }
964
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); }
965
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); }
@@ -1020,7 +1235,7 @@ window.__ModuleLoader__.load({
1020
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; }
1021
1236
  .sss-vr-link:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
1022
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; }
1023
- .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; }
1024
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; }
1025
1240
  .sss-wl-header { display: flex; align-items: flex-start; gap: 12px; }
1026
1241
  .sss-wl-headText { flex: 1; min-width: 0; }
@@ -1041,7 +1256,7 @@ window.__ModuleLoader__.load({
1041
1256
  .sss-wl-primary:hover { color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
1042
1257
  .sss-wl-skip { margin-left: auto; border: none; background: transparent; color: var(--dsw-alias-label-tertiary); padding: 7px 4px; }
1043
1258
  .sss-wl-skip:hover { color: var(--dsw-alias-label-primary); border-color: transparent; }
1044
- .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; }
1045
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; }
1046
1261
  .sss-cf-header { display: flex; align-items: flex-start; gap: 12px; }
1047
1262
  .sss-cf-headText { flex: 1; min-width: 0; }
@@ -1052,6 +1267,15 @@ window.__ModuleLoader__.load({
1052
1267
  .sss-cf-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 18px; }
1053
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; }
1054
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; }
1055
1279
  .sss-cf-danger { background: #c0392b; color: #fff; border-color: transparent; }
1056
1280
  .sss-cf-danger:hover { background: #a93226; color: #fff; border-color: transparent; }
1057
1281
  `
@@ -1134,6 +1358,7 @@ window.__ModuleLoader__.load({
1134
1358
  String(row.id ?? row.name ?? ''),
1135
1359
  String(row.npm ?? row.package ?? row.packageName ?? ''),
1136
1360
  String(row.version ?? ''),
1361
+ row.enabled === undefined ? '' : String(row.enabled),
1137
1362
  ].sort()).sort())
1138
1363
  }
1139
1364
 
@@ -1453,18 +1678,27 @@ window.__ModuleLoader__.load({
1453
1678
  : a.order - b.order)
1454
1679
  }
1455
1680
 
1456
- const slotsFingerprint = () => otherPluginFingerprint([
1457
- ...sortEntries(entriesOf('settings.plugin.item')).map((entry) => ({
1458
- id: String(entry.options?.id ?? entry.options?.key ?? ''),
1459
- name: optionLabel(entry.options?.label),
1460
- locale: String(entry.options?.locale ?? ''),
1461
- })),
1462
- ...sortEntries(entriesOf('web-ui.plugin.item')).map((entry) => ({
1463
- id: String(entry.options?.id ?? entry.options?.key ?? ''),
1464
- name: optionLabel(entry.options?.label),
1465
- locale: String(entry.options?.locale ?? ''),
1466
- })),
1467
- ])
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
+ }
1468
1702
 
1469
1703
  const resetHarvest = () => { harvest.clear(); warmed = false }
1470
1704
 
@@ -1638,6 +1872,97 @@ window.__ModuleLoader__.load({
1638
1872
  return { jumpToPage, jumpToOption }
1639
1873
  }
1640
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
+
1641
1966
  function apply(ctx) {
1642
1967
  log('info', '应用装载', { version: PKG_VERSION })
1643
1968
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'settings-search: dictionaries')
@@ -1650,65 +1975,101 @@ window.__ModuleLoader__.load({
1650
1975
  const model = createModel(ctx)
1651
1976
  const indexControls = { resetHarvest: model.resetHarvest, persistIndex: model.persistIndex }
1652
1977
  const navigator = createNavigator(ctx)
1653
- let pluginManagerSyncToken = 0
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
+ }
1654
1988
 
1655
- const syncPluginManagerIndex = async (face) => {
1989
+ /** Legacy (pre-0.2) client plugin manager service: `list()` plus change events. */
1990
+ const syncLegacyPluginManagerIndex = async (face) => {
1656
1991
  if (face === null || !face.isLoopback) return
1657
- const token = ++pluginManagerSyncToken
1992
+ const token = ++pluginInventoryToken
1658
1993
  try {
1659
1994
  const rows = await face.list()
1660
- if (token !== pluginManagerSyncToken) return
1661
- if (model.applyIndexFingerprint('manager:' + otherPluginFingerprint(rows))) {
1662
- window.dispatchEvent(new CustomEvent(INDEX_INVALID_EVENT))
1663
- log('info', '检测到其它插件变化,将重建设置索引', {})
1664
- }
1995
+ if (token !== pluginInventoryToken) return
1996
+ applyPluginFingerprint(rows)
1665
1997
  } catch {
1666
1998
  // Keep the last trusted fingerprint when manager inspection fails.
1667
1999
  }
1668
2000
  }
1669
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
+
1670
2063
  ctx.inject(['pluginManager'], (inner) => {
1671
2064
  const face = inner.pluginManager ?? null
1672
- const sync = () => { void syncPluginManagerIndex(face) }
2065
+ const sync = () => { void syncLegacyPluginManagerIndex(face) }
1673
2066
  sync()
1674
2067
  const unsubscribe = typeof face?.onChange === 'function' ? face.onChange(sync) : null
1675
2068
  return () => {
1676
- pluginManagerSyncToken += 1
2069
+ pluginInventoryToken += 1
1677
2070
  if (typeof unsubscribe === 'function') unsubscribe()
1678
2071
  }
1679
- }, 'settings-search: plugin manager index fingerprint')
1680
-
1681
- /** Read the model fields from DSH; credentials are never requested. */
1682
- const readDshModelConfig = async () => {
1683
- const connection = ctx.get('connection')
1684
- const response = await connection.api.settings.describe({})
1685
- if (!response.result.ok) throw new Error(response.result.error?.message || 'settings.describe failed')
1686
- const namespaces = new Map(response.result.value.namespaces.map((view) => [view.ns, view]))
1687
- const defaultModel = namespaces.get('agent-default-model')?.value ?? {}
1688
- const providerId = String(defaultModel.provider ?? '').trim()
1689
- const modelId = String(defaultModel.model ?? '').trim()
1690
- const readPath = (value, path) => (path ?? []).reduce((current, key) => current?.[key], value)
1691
- const providersResponse = await connection.api.llm.providers({}).catch(() => null)
1692
- const providerRow = providersResponse?.result?.ok
1693
- ? providersResponse.result.value.providers.find((entry) => entry.provider === providerId)
1694
- : undefined
1695
- const modelNamespace = namespaces.get(providerRow?.settingsNs || 'llm-pi-ai')
1696
- const providerProfile = providerRow
1697
- ? readPath(modelNamespace?.value, providerRow.settingsPath) ?? {}
1698
- : modelNamespace?.value?.providers?.[providerId] ?? {}
1699
- const configuredModels = Array.isArray(providerProfile.models) ? providerProfile.models : []
1700
- const modelProfile = configuredModels.find((entry) => entry?.id === modelId) ?? {}
1701
- const protocol = String(modelProfile.api ?? providerProfile.api ?? '').trim()
1702
- const baseUrl = String(providerProfile.baseURL ?? '').trim()
1703
- if (!modelId || !baseUrl) return null
1704
- return {
1705
- provider: providerId.toLowerCase() === 'ollama' || protocol === 'ollama'
1706
- ? 'ollama'
1707
- : protocol === 'anthropic-messages' ? 'anthropic' : 'openai',
1708
- baseUrl,
1709
- model: modelId,
1710
- }
1711
- }
2072
+ }, 'settings-search: plugin manager index fingerprint (legacy)')
1712
2073
 
1713
2074
  const SearchIcon = React.createElement('svg', {
1714
2075
  className: 'sss-icon',
@@ -1790,6 +2151,36 @@ window.__ModuleLoader__.load({
1790
2151
  ])
1791
2152
  }
1792
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
+
1793
2184
  function SettingsHeaderSearch() {
1794
2185
  const [query, setQuery] = React.useState('')
1795
2186
  const [open, setOpen] = React.useState(false)
@@ -1863,11 +2254,14 @@ window.__ModuleLoader__.load({
1863
2254
  }, [])
1864
2255
 
1865
2256
  React.useEffect(() => {
1866
- // If no plugin manager is present, the slot registrations are the
1867
- // only available change signal. Waiting briefly avoids racing the
1868
- // optional manager during startup.
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.
1869
2263
  const fallback = setTimeout(() => {
1870
- if (model.indexFingerprintReady()) return
2264
+ if (hasPluginInventory()) return
1871
2265
  if (model.applyIndexFingerprint(model.slotsFingerprint())) {
1872
2266
  window.dispatchEvent(new CustomEvent(INDEX_INVALID_EVENT))
1873
2267
  }
@@ -2361,6 +2755,10 @@ window.__ModuleLoader__.load({
2361
2755
  const [logCopyFail, setLogCopyFail] = React.useState(false)
2362
2756
  const [logExportFail, setLogExportFail] = React.useState(false)
2363
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('')
2364
2762
  const [indexState, setIndexState] = React.useState('idle')
2365
2763
  const [warming, setWarming] = React.useState(false)
2366
2764
  const [confirmClear, setConfirmClear] = React.useState(false)
@@ -2399,12 +2797,19 @@ window.__ModuleLoader__.load({
2399
2797
  React.useEffect(() => {
2400
2798
  const sync = () => setLogVersion((v) => v + 1)
2401
2799
  window.addEventListener(LOG_EVENT, sync)
2402
- 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
+ }
2403
2807
  }, [])
2404
2808
 
2405
2809
  useAutoReset(logCopied, () => setLogCopied(false))
2406
2810
  useAutoReset(logCleared, () => setLogCleared(false))
2407
2811
  useAutoReset(logExportFail, () => setLogExportFail(false))
2812
+ useAutoReset(logViewerCopied, () => setLogViewerCopied(false))
2408
2813
 
2409
2814
  const changeProvider = (next) => {
2410
2815
  providerDrafts.current[provider] = { baseUrl, model }
@@ -2493,7 +2898,7 @@ window.__ModuleLoader__.load({
2493
2898
  setDshImportState('loading')
2494
2899
  setDshImportMsg('')
2495
2900
  try {
2496
- const imported = await readDshModelConfig()
2901
+ const imported = await readDshModelConfig(ctx, t)
2497
2902
  if (!imported) throw new Error(t('aiDshImportEmpty'))
2498
2903
  providerDrafts.current[provider] = { baseUrl, model }
2499
2904
  setAiConfig({ ...imported, apiKey })
@@ -2632,8 +3037,64 @@ window.__ModuleLoader__.load({
2632
3037
  const clearLogs = () => {
2633
3038
  setConfirmClear(false)
2634
3039
  LOGS.length = 0
3040
+ // Drop queued lines too: a late flush would resurrect what was cleared.
3041
+ discardPendingLogs()
2635
3042
  setLogVersion((v) => v + 1)
2636
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() },
2637
3098
  }
2638
3099
 
2639
3100
  const check = async () => {
@@ -2781,7 +3242,7 @@ window.__ModuleLoader__.load({
2781
3242
  }, indexState === 'loading' || warming ? t('indexRebuilding') : t('indexRebuild')),
2782
3243
  ]),
2783
3244
  indexState === 'done'
2784
- ? React.createElement('p', { className: 'sss-vr-note', style: { color: '#1e8e3e' } }, t('indexDone'))
3245
+ ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-state-success-primary)' } }, t('indexDone'))
2785
3246
  : null,
2786
3247
  ]),
2787
3248
  React.createElement('div', { className: 'sss-vr-field sss-vr-form' }, [
@@ -2848,7 +3309,7 @@ window.__ModuleLoader__.load({
2848
3309
  modelsMsg
2849
3310
  ? React.createElement('p', {
2850
3311
  className: modelsState === 'fail' ? 'sss-vr-note' : 'sss-vr-hint',
2851
- style: modelsState === 'fail' ? { color: 'var(--dsw-alias-label-danger)' } : undefined,
3312
+ style: modelsState === 'fail' ? { color: 'var(--dsw-alias-state-error-primary)' } : undefined,
2852
3313
  }, modelsMsg)
2853
3314
  : null,
2854
3315
  provider !== 'ollama' ? React.createElement('div', { className: 'sss-vr-field-head' }, [
@@ -2878,10 +3339,10 @@ window.__ModuleLoader__.load({
2878
3339
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => requestClearAi() }, t('aiClear')),
2879
3340
  ]),
2880
3341
  dshImportMsg
2881
- ? 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)
2882
3343
  : null,
2883
3344
  testMsg
2884
- ? 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)
2885
3346
  : null,
2886
3347
  ]),
2887
3348
  React.createElement('div', { className: 'sss-vr-field' }, [
@@ -2907,7 +3368,7 @@ window.__ModuleLoader__.load({
2907
3368
  copied ? t('updCopied') : t('updCopy')),
2908
3369
  React.createElement('a', { className: 'sss-vr-link', href: releasesUrl, target: '_blank', rel: 'noopener noreferrer' }, t('updRelease')),
2909
3370
  ]),
2910
- 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,
2911
3372
  ])
2912
3373
  : null,
2913
3374
  React.createElement('div', { className: 'sss-vr-actions' }, [
@@ -2925,23 +3386,28 @@ window.__ModuleLoader__.load({
2925
3386
  React.createElement('div', { className: 'sss-vr-field' }, [
2926
3387
  React.createElement('div', { className: 'sss-vr-field-head' }, [
2927
3388
  React.createElement('span', { className: 'sss-vr-label' }, t('logTitle')),
2928
- 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,
2929
3392
  ]),
2930
3393
  React.createElement('p', { className: 'sss-vr-hint' }, t('logDesc')),
2931
- LOGS.length === 0
3394
+ LOGS.length === 0 && logBadgeCount === 0
2932
3395
  ? React.createElement('p', { className: 'sss-vr-note' }, t('logEmpty'))
2933
3396
  : null,
2934
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')),
2935
3400
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void exportLogs() } }, t('logExport')),
2936
3401
  React.createElement('a', { className: 'sss-vr-link', href: ISSUES_URL, target: '_blank', rel: 'noopener noreferrer' },
2937
3402
  t('logIssues')),
2938
3403
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void copyLogs() } },
2939
3404
  logCopied ? t('logCopied') : t('logCopy')),
2940
- 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() },
2941
3406
  logCleared ? t('logCleared') : t('logClear')),
2942
3407
  ]),
2943
- logCopyFail ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-label-danger)' } }, t('logCopyFail')) : null,
2944
- 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,
2945
3411
  ]),
2946
3412
  ]) : null,
2947
3413
  confirmClear
@@ -2955,6 +3421,9 @@ window.__ModuleLoader__.load({
2955
3421
  onCancel: () => cancelClear(),
2956
3422
  })
2957
3423
  : null,
3424
+ logViewer
3425
+ ? React.createElement(LogViewer, { t, view: logView })
3426
+ : null,
2958
3427
  confirmClearAi
2959
3428
  ? React.createElement(ConfirmModal, {
2960
3429
  t,