@objectivex666/dsh-settings-search 1.12.0 → 1.13.1

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.1'
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,9 @@ window.__ModuleLoader__.load({
107
107
  aiDshImportOk: '已导入 Base URL 与模型名;API Key 未改动 ✓',
108
108
  aiDshImportFail: '导入失败:{reason}',
109
109
  aiDshImportEmpty: '未找到 DSH 当前默认模型配置',
110
+ aiDshImportBuiltin: '已导入 DeepSeek 内置地址与模型名;API Key 未改动,插件内使用需填写自己的 DeepSeek API Key',
111
+ aiDshImportApi: '当前 DSH 版本未提供模型配置接口(remote.settings)',
112
+ aiDshImportNoBaseUrl: '默认模型「{model}」在 DSH 设置中没有可导入的 Base URL',
110
113
  aiConfigSave: '保存配置',
111
114
  aiConfigSaved: '已保存 ✓',
112
115
  aiClear: '清除配置',
@@ -132,9 +135,14 @@ window.__ModuleLoader__.load({
132
135
  aiOllamaErrForbidden: 'Ollama 拒绝访问,请检查 OLLAMA_ORIGINS 是否允许当前 DSH 页面来源',
133
136
  aiOllamaErrTimeout: 'Ollama 响应超时,请检查本机服务及模型加载状态',
134
137
  logTitle: '日志',
135
- logDesc: '记录插件运行、搜索与 AI 请求的关键动作,便于排查问题。',
138
+ logCountHint: '磁盘日志文件中的条目数',
139
+ logDesc: '记录插件运行、搜索与 AI 请求的关键动作,便于排查问题;日志按天写入 DSH 的日志文件夹,文件名带日期。',
136
140
  logEmpty: '暂无日志',
137
141
  logExport: '导出日志',
142
+ logView: '查看日志',
143
+ logOpenDir: '打开日志文件夹',
144
+ logOpenOk: '已在文件管理器中打开日志文件夹 ✓',
145
+ logOpenFail: '打开日志文件夹失败',
138
146
  logIssues: '反馈问题',
139
147
  logCopy: '复制日志',
140
148
  logCopied: '已复制 ✓',
@@ -142,10 +150,20 @@ window.__ModuleLoader__.load({
142
150
  logClear: '清空日志',
143
151
  logCleared: '已清空',
144
152
  logConfirmTitle: '清空日志',
145
- logConfirm: '确定要清空全部日志吗?此操作不可撤销。',
153
+ logConfirm: '确定要清空全部日志吗?磁盘上的日志文件也会一并清空。',
146
154
  logConfirmYes: '确认清空',
147
155
  logConfirmCancel: '取消',
148
156
  logExportFail: '导出失败,请重试。',
157
+ logViewerTitle: '运行日志',
158
+ logViewerRefresh: '刷新',
159
+ logViewerLoading: '正在读取…',
160
+ logViewerCopy: '复制',
161
+ logViewerCopied: '已复制 ✓',
162
+ logViewerClose: '关闭',
163
+ logViewerEmpty: '暂无日志。',
164
+ logViewerFile: '文件:{file}',
165
+ logViewerMemory: '宿主日志通道不可用,这里显示本次会话的记录,重启后不再保留。',
166
+ logViewerTruncated: '仅显示文件末尾部分,更早的内容请打开日志文件夹查看。',
149
167
  wlTitle: '欢迎使用设置搜索',
150
168
  wlSubtitle: '为 DSH 设置面板提供即时搜索与导航,几秒钟就能找到任何设置。',
151
169
  wlFeature1Title: '实时搜索',
@@ -236,6 +254,9 @@ window.__ModuleLoader__.load({
236
254
  aiDshImportOk: 'Imported Base URL and model; API key unchanged ✓',
237
255
  aiDshImportFail: 'Import failed: {reason}',
238
256
  aiDshImportEmpty: 'No current DSH default model config found',
257
+ aiDshImportBuiltin: 'Imported the built-in DeepSeek address and model; the API key is unchanged, so fill in your own DeepSeek key to use it here',
258
+ aiDshImportApi: 'This DSH build exposes no model config API (remote.settings)',
259
+ aiDshImportNoBaseUrl: 'The default model "{model}" has no Base URL in DSH settings to import',
239
260
  aiConfigSave: 'Save config',
240
261
  aiConfigSaved: 'Saved ✓',
241
262
  aiClear: 'Clear config',
@@ -261,9 +282,14 @@ window.__ModuleLoader__.load({
261
282
  aiOllamaErrForbidden: 'Ollama denied access. Check that OLLAMA_ORIGINS allows this DSH page origin',
262
283
  aiOllamaErrTimeout: 'Ollama timed out. Check the local service and model loading status',
263
284
  logTitle: 'Logs',
264
- logDesc: 'Logs key plugin actions, searches, and AI requests to help troubleshoot.',
285
+ logCountHint: 'Entries in the log file on disk',
286
+ 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
287
  logEmpty: 'No logs yet',
266
288
  logExport: 'Export logs',
289
+ logView: 'View logs',
290
+ logOpenDir: 'Open log folder',
291
+ logOpenOk: 'Log folder opened in your file manager ✓',
292
+ logOpenFail: 'Could not open the log folder',
267
293
  logIssues: 'Report issue',
268
294
  logCopy: 'Copy logs',
269
295
  logCopied: 'Copied ✓',
@@ -271,10 +297,20 @@ window.__ModuleLoader__.load({
271
297
  logClear: 'Clear logs',
272
298
  logCleared: 'Cleared',
273
299
  logConfirmTitle: 'Clear logs',
274
- logConfirm: 'Clear all logs? This cannot be undone.',
300
+ logConfirm: 'Clear all logs? The log file on disk is truncated as well.',
275
301
  logConfirmYes: 'Clear logs',
276
302
  logConfirmCancel: 'Cancel',
277
303
  logExportFail: 'Export failed. Please try again.',
304
+ logViewerTitle: 'Run log',
305
+ logViewerRefresh: 'Refresh',
306
+ logViewerLoading: 'Loading…',
307
+ logViewerCopy: 'Copy',
308
+ logViewerCopied: 'Copied ✓',
309
+ logViewerClose: 'Close',
310
+ logViewerEmpty: 'No logs yet.',
311
+ logViewerFile: 'File: {file}',
312
+ logViewerMemory: 'The host log channel is unavailable, so this shows the current session only; it is not kept after a reload.',
313
+ logViewerTruncated: 'Showing the end of the file only — open the log folder for earlier lines.',
278
314
  wlTitle: 'Welcome to Settings Search',
279
315
  wlSubtitle: 'Instant search and navigation for the DSH settings panel — find any setting in seconds.',
280
316
  wlFeature1Title: 'Instant search',
@@ -293,7 +329,14 @@ window.__ModuleLoader__.load({
293
329
  wlFeature6Desc: 'On first open it automatically passes through every settings section, so in-page options are searchable without opening each page manually.',
294
330
  }
295
331
 
296
- /** Inner-row slots that represent specific options inside one page. */
332
+ /**
333
+ * Inner-row slots that represent specific options inside one page.
334
+ * DSH 0.2 keeps the plugin cards in its standalone Plugins panel
335
+ * (`plugins.item`) and renders the Settings → Plugins inventory tab itself,
336
+ * so only the tab seat is inside the settings panel the search reaches.
337
+ * `settings.plugin.item` and `web-ui.plugin.item` are the pre-0.2 card
338
+ * seats — 0.2 declares neither, and they stay indexed for older builds.
339
+ */
297
340
  const DEEP_SLOTS = [
298
341
  { slot: 'settings.plugins.tab', parent: 'plugins' },
299
342
  { slot: 'settings.plugin.item', parent: 'plugins' },
@@ -472,16 +515,192 @@ window.__ModuleLoader__.load({
472
515
 
473
516
  const AI_TOGGLE_EVENT = 'dsh-settings-search:ai-toggle'
474
517
  const AI_CONFIG_EVENT = 'dsh-settings-search:ai-config'
475
- /** Simple in-memory log ring used by the settings panel's export/log UI. */
518
+ /**
519
+ * Log recording has two sinks: the in-memory ring that feeds the panel
520
+ * instantly, and the host log file the panel can view and open. Every entry
521
+ * is formatted once ({@link logLine}), so the file, the viewer, and the
522
+ * exported text all read the same. Lines are handed to the host in small
523
+ * batches — a settings panel produces bursts, and one request per line
524
+ * would be wasteful — and the transport stays deliberately silent about its
525
+ * own failures so a broken channel can never log about itself.
526
+ */
476
527
  const LOG_EVENT = 'dsh-settings-search:log'
528
+ /** Fires when the on-disk entry count changes, so the panel's badge follows it. */
529
+ const LOG_COUNT_EVENT = 'dsh-settings-search:log-count'
477
530
  const LOG_MAX = 500
531
+ const LOG_LINE_MAX = 800
532
+ const LOG_BATCH_MAX = 40
533
+ const LOG_FLUSH_MS = 1200
534
+ /** Document-relative form of the host half's `/api/settings-search-log`. */
535
+ const LOG_ROUTE = 'api/settings-search-log'
536
+ /** Per page-load run id, so one file can tell concurrent runs apart. */
537
+ const LOG_RUN = Math.floor(Math.random() * 0xffffffff).toString(16).padStart(8, '0')
478
538
  const LOGS = []
539
+ let logSeq = 0
540
+
541
+ const safeJson = (value) => {
542
+ try {
543
+ const text = JSON.stringify(value)
544
+ return typeof text === 'string' ? text : ''
545
+ } catch {
546
+ return '"<unserializable>"'
547
+ }
548
+ }
549
+
550
+ /** One line per entry: `time [level] [run:seq] message {meta}`. */
551
+ const logLine = (entry) => {
552
+ const meta = entry.meta === undefined ? '' : ` ${safeJson(entry.meta)}`
553
+ const seq = Number.isFinite(entry.seq) ? entry.seq : 0
554
+ return `${entry.time} [${entry.level}] [${LOG_RUN}:${seq}] ${entry.msg}${meta}`.slice(0, LOG_LINE_MAX)
555
+ }
556
+
557
+ /** Host-log transport state: pending lines, its timer, and the last outcome. */
558
+ const logTransport = { pending: [], timer: null, state: 'idle', dir: '', file: '', detail: '', count: null }
559
+
560
+ const setLogOutcome = (failed, payload, detail) => {
561
+ logTransport.state = failed ? 'fail' : 'ok'
562
+ logTransport.detail = failed ? detail : ''
563
+ if (typeof payload?.dir === 'string') logTransport.dir = payload.dir
564
+ if (typeof payload?.file === 'string') logTransport.file = payload.file
565
+ // The host answers the on-disk entry count on every call, so the panel's
566
+ // badge reflects the log that exists rather than this page's ring.
567
+ if (Number.isFinite(payload?.count) && payload.count !== logTransport.count) {
568
+ logTransport.count = payload.count
569
+ try { window.dispatchEvent(new CustomEvent(LOG_COUNT_EVENT)) } catch {}
570
+ }
571
+ }
572
+
573
+ /** One request to the host log route; never throws, never logs. */
574
+ const postLog = async (body) => {
575
+ try {
576
+ const response = await fetch(LOG_ROUTE, {
577
+ method: 'POST',
578
+ headers: { 'content-type': 'application/json' },
579
+ body: JSON.stringify(body),
580
+ })
581
+ const payload = await response.json().catch(() => null)
582
+ if (!response.ok || payload?.ok !== true) {
583
+ setLogOutcome(true, null, String(payload?.detail ?? payload?.reason ?? `HTTP ${response.status}`))
584
+ return null
585
+ }
586
+ setLogOutcome(false, payload, '')
587
+ return payload
588
+ } catch (error) {
589
+ setLogOutcome(true, null, String(error?.message ?? error))
590
+ return null
591
+ }
592
+ }
593
+
594
+ /** Ship the queued lines now; the host stops serving once its batch is full. */
595
+ const flushLogs = () => {
596
+ if (logTransport.timer !== null) {
597
+ clearTimeout(logTransport.timer)
598
+ logTransport.timer = null
599
+ }
600
+ if (logTransport.pending.length === 0) return
601
+ const lines = logTransport.pending.splice(0, logTransport.pending.length)
602
+ void postLog({ action: 'append', lines })
603
+ }
604
+
605
+ /** Drop queued lines without shipping them (used by 清空日志). */
606
+ const discardPendingLogs = () => {
607
+ if (logTransport.timer !== null) {
608
+ clearTimeout(logTransport.timer)
609
+ logTransport.timer = null
610
+ }
611
+ logTransport.pending.length = 0
612
+ }
613
+
614
+ const queueLogLine = (line) => {
615
+ logTransport.pending.push(line)
616
+ if (logTransport.pending.length >= LOG_BATCH_MAX) {
617
+ flushLogs()
618
+ return
619
+ }
620
+ if (logTransport.timer === null) logTransport.timer = setTimeout(flushLogs, LOG_FLUSH_MS)
621
+ }
622
+
623
+ /** Read the host log tail (viewer, and the folder path the panel offers). */
624
+ const readLogFile = async () => {
625
+ try {
626
+ const response = await fetch(LOG_ROUTE, { method: 'GET' })
627
+ const payload = await response.json().catch(() => null)
628
+ if (!response.ok || payload?.ok !== true) {
629
+ setLogOutcome(true, null, String(payload?.detail ?? payload?.reason ?? `HTTP ${response.status}`))
630
+ return null
631
+ }
632
+ setLogOutcome(false, payload, '')
633
+ return {
634
+ text: String(payload.text ?? ''),
635
+ size: Number(payload.size ?? 0),
636
+ truncated: payload.truncated === true,
637
+ }
638
+ } catch (error) {
639
+ setLogOutcome(true, null, String(error?.message ?? error))
640
+ return null
641
+ }
642
+ }
643
+
644
+ /**
645
+ * Refresh the on-disk entry count without transferring the file tail.
646
+ * @returns the count, or null when the host channel is unavailable.
647
+ */
648
+ const readLogCount = async () => {
649
+ try {
650
+ const response = await fetch(`${LOG_ROUTE}?count=1`, { method: 'GET' })
651
+ const payload = await response.json().catch(() => null)
652
+ if (!response.ok || payload?.ok !== true) {
653
+ setLogOutcome(true, null, String(payload?.detail ?? payload?.reason ?? `HTTP ${response.status}`))
654
+ return null
655
+ }
656
+ setLogOutcome(false, payload, '')
657
+ return Number.isFinite(payload.count) ? payload.count : null
658
+ } catch (error) {
659
+ setLogOutcome(true, null, String(error?.message ?? error))
660
+ return null
661
+ }
662
+ }
663
+
664
+ /** Open the log folder in the OS file manager. */
665
+ const openLogFolder = async () => {
666
+ const payload = await postLog({ action: 'open' })
667
+ return { ok: payload !== null, dir: logTransport.dir, file: logTransport.file, detail: logTransport.detail }
668
+ }
669
+
670
+ /** Truncate the on-disk log. */
671
+ const clearLogFile = async () => { await postLog({ action: 'clear' }) }
672
+
673
+ /** Current host-log target and last transport outcome, for the panel. */
674
+ const logTarget = () => ({
675
+ dir: logTransport.dir,
676
+ file: logTransport.file,
677
+ state: logTransport.state,
678
+ detail: logTransport.detail,
679
+ count: logTransport.count,
680
+ })
681
+
682
+ /** This session's entries in the same one-line format the file uses. */
683
+ const sessionLogText = () => LOGS.map(logLine).join('\n')
684
+
479
685
  const log = (level, msg, meta) => {
480
686
  if (typeof window === 'undefined') return
481
- LOGS.push({ time: new Date().toISOString(), level, msg, meta })
687
+ const entry = { time: new Date().toISOString(), level, msg: String(msg), meta, seq: ++logSeq }
688
+ LOGS.push(entry)
482
689
  if (LOGS.length > LOG_MAX) LOGS.splice(0, LOGS.length - LOG_MAX)
690
+ queueLogLine(logLine(entry))
483
691
  try { window.dispatchEvent(new CustomEvent(LOG_EVENT, { detail: LOGS.length })) } catch {}
484
692
  }
693
+
694
+ if (typeof window !== 'undefined') {
695
+ // A page leaving the document is the last chance to ship queued lines.
696
+ try { window.addEventListener('pagehide', flushLogs) } catch {}
697
+ try {
698
+ window.addEventListener('visibilitychange', () => {
699
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') flushLogs()
700
+ })
701
+ } catch {}
702
+ }
703
+
485
704
  const formatLogs = (logs) => {
486
705
  const lines = [
487
706
  'dsh-settings-search log',
@@ -490,9 +709,7 @@ window.__ModuleLoader__.load({
490
709
  `count: ${logs.length}`,
491
710
  '',
492
711
  ]
493
- for (const entry of logs) {
494
- lines.push(`[${entry.time}] [${entry.level}] ${entry.msg}${entry.meta ? ' ' + JSON.stringify(entry.meta) : ''}`)
495
- }
712
+ for (const entry of logs) lines.push(logLine(entry))
496
713
  return lines.join('\n')
497
714
  }
498
715
  const AI_CACHE = new Map()
@@ -959,7 +1176,7 @@ window.__ModuleLoader__.load({
959
1176
  .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
1177
  .sss-trigger:hover { border-color: var(--dsw-alias-label-dimmed); color: var(--dsw-alias-label-secondary); }
961
1178
  .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%)); }
1179
+ .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
1180
  .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
1181
  .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
1182
  .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 +1237,7 @@ window.__ModuleLoader__.load({
1020
1237
  .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
1238
  .sss-vr-link:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
1022
1239
  .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; }
1240
+ .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
1241
  .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
1242
  .sss-wl-header { display: flex; align-items: flex-start; gap: 12px; }
1026
1243
  .sss-wl-headText { flex: 1; min-width: 0; }
@@ -1041,7 +1258,7 @@ window.__ModuleLoader__.load({
1041
1258
  .sss-wl-primary:hover { color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
1042
1259
  .sss-wl-skip { margin-left: auto; border: none; background: transparent; color: var(--dsw-alias-label-tertiary); padding: 7px 4px; }
1043
1260
  .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; }
1261
+ .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
1262
  .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
1263
  .sss-cf-header { display: flex; align-items: flex-start; gap: 12px; }
1047
1264
  .sss-cf-headText { flex: 1; min-width: 0; }
@@ -1052,6 +1269,15 @@ window.__ModuleLoader__.load({
1052
1269
  .sss-cf-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 18px; }
1053
1270
  .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
1271
  .sss-cf-btn:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
1272
+ .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; }
1273
+ .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; }
1274
+ .sss-lv-header { flex: none; display: flex; align-items: center; gap: 10px; }
1275
+ .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; }
1276
+ .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; }
1277
+ .sss-lv-close:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
1278
+ .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; }
1279
+ .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; }
1280
+ .sss-lv-actions { flex: none; display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
1055
1281
  .sss-cf-danger { background: #c0392b; color: #fff; border-color: transparent; }
1056
1282
  .sss-cf-danger:hover { background: #a93226; color: #fff; border-color: transparent; }
1057
1283
  `
@@ -1134,6 +1360,7 @@ window.__ModuleLoader__.load({
1134
1360
  String(row.id ?? row.name ?? ''),
1135
1361
  String(row.npm ?? row.package ?? row.packageName ?? ''),
1136
1362
  String(row.version ?? ''),
1363
+ row.enabled === undefined ? '' : String(row.enabled),
1137
1364
  ].sort()).sort())
1138
1365
  }
1139
1366
 
@@ -1453,18 +1680,27 @@ window.__ModuleLoader__.load({
1453
1680
  : a.order - b.order)
1454
1681
  }
1455
1682
 
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
- ])
1683
+ /**
1684
+ * Fingerprint of the seats the index is built from: every settings page
1685
+ * plus each in-page seat this build declares. It is the change signal for
1686
+ * compositions that expose no plugin inventory at all (see
1687
+ * hasPluginInventory), so pages and rows appearing or disappearing still
1688
+ * invalidate a stored index. The pre-0.2 card seats alone would be empty
1689
+ * on DSH 0.2, which declares neither.
1690
+ */
1691
+ const slotsFingerprint = () => {
1692
+ const rows = []
1693
+ for (const slot of ['settings.section', ...DEEP_SLOTS.map((config) => config.slot)]) {
1694
+ for (const entry of sortEntries(entriesOf(slot))) {
1695
+ rows.push([
1696
+ slot,
1697
+ String(entry.options?.id ?? entry.options?.key ?? ''),
1698
+ optionLabel(entry.options?.label),
1699
+ ])
1700
+ }
1701
+ }
1702
+ return JSON.stringify(rows.sort())
1703
+ }
1468
1704
 
1469
1705
  const resetHarvest = () => { harvest.clear(); warmed = false }
1470
1706
 
@@ -1638,6 +1874,130 @@ window.__ModuleLoader__.load({
1638
1874
  return { jumpToPage, jumpToOption }
1639
1875
  }
1640
1876
 
1877
+ /**
1878
+ * Resolve the running DSH build's settings/LLM read faces.
1879
+ *
1880
+ * DSH 0.2 (the official desktop and `dsh web` release) exposes host
1881
+ * capabilities as Typert Remote namespaces: `remote.settings.describe()`
1882
+ * and `remote.llm.listConfigurableProviders()` each resolve to
1883
+ * `{ ok: true, value } | { ok: false, error }`. Older builds exposed the
1884
+ * same data through the unary client carried by the connection service
1885
+ * (`connection.api.settings.describe({})`, resolving to
1886
+ * `{ result: { ok, value } }`). Both generations answer with the same
1887
+ * namespace views, so one small face is resolved per call.
1888
+ *
1889
+ * @param ctx - the client plugin context.
1890
+ * @returns the read face, or null when this build offers neither generation.
1891
+ */
1892
+ const resolveDshModelFace = (ctx) => {
1893
+ const remoteSettings = ctx.get('remote.settings')
1894
+ if (remoteSettings !== undefined && remoteSettings !== null) {
1895
+ const remoteLlm = ctx.get('remote.llm')
1896
+ return {
1897
+ describe: async () => {
1898
+ const response = await remoteSettings.describe()
1899
+ if (!response?.ok) throw new Error(response?.error?.message || 'settings.describe failed')
1900
+ return response.value
1901
+ },
1902
+ configuredProviders: async () => {
1903
+ if (typeof remoteLlm?.listConfigurableProviders !== 'function') return null
1904
+ const response = await remoteLlm.listConfigurableProviders().catch(() => null)
1905
+ return response?.ok ? response.value : null
1906
+ },
1907
+ }
1908
+ }
1909
+ const legacyApi = ctx.get('connection')?.api
1910
+ if (legacyApi?.settings?.describe) {
1911
+ return {
1912
+ describe: async () => {
1913
+ const response = await legacyApi.settings.describe({})
1914
+ if (!response?.result?.ok) throw new Error(response?.result?.error?.message || 'settings.describe failed')
1915
+ return response.result.value
1916
+ },
1917
+ configuredProviders: async () => {
1918
+ if (typeof legacyApi.llm?.providers !== 'function') return null
1919
+ const response = await legacyApi.llm.providers({}).catch(() => null)
1920
+ return response?.result?.ok ? response.result.value?.providers ?? null : null
1921
+ },
1922
+ }
1923
+ }
1924
+ return null
1925
+ }
1926
+
1927
+ /** DSH's built-in DeepSeek routes and the namespaces that own them. */
1928
+ const DEEPSEEK_PROVIDERS = new Set(['deepseek-official', 'deepseek-account'])
1929
+ const DEEPSEEK_SETTINGS = new Set(['llm-deepseek', 'llm-deepseek-api-key', 'llm-deepseek-account'])
1930
+ /** The endpoint the shipped DeepSeek adapter falls back to (`PUBLIC_BASE_URL`). */
1931
+ const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com/anthropic'
1932
+ /**
1933
+ * Whether one provider route is a built-in DeepSeek one.
1934
+ * @param providerId - the route's provider id.
1935
+ * @param settingsNs - the settings namespace the route reads, when known.
1936
+ * @returns true for the shipped DeepSeek routes.
1937
+ */
1938
+ const isBuiltInDeepseekRoute = (providerId, settingsNs) =>
1939
+ DEEPSEEK_PROVIDERS.has(String(providerId ?? '')) || DEEPSEEK_SETTINGS.has(String(settingsNs ?? ''))
1940
+
1941
+ /**
1942
+ * Read the model fields of DSH's current default model; credentials are
1943
+ * never requested. `agent-default-model` names the provider and the model,
1944
+ * the provider's own settings namespace carries the Base URL, and the model
1945
+ * profile inside that namespace may override the wire protocol.
1946
+ *
1947
+ * A built-in DeepSeek route keeps its endpoint in the adapter rather than in
1948
+ * settings, so nothing is importable from the namespace there. Those routes
1949
+ * fall back to the endpoint DSH itself calls — the adapter resolves
1950
+ * `config.baseURL ?? $DEEPSEEK_BASE_URL ?? https://api.deepseek.com/anthropic`,
1951
+ * and only the last of those is visible to a browser — so importing the
1952
+ * default DeepSeek model still yields a usable address.
1953
+ *
1954
+ * @param ctx - the client plugin context.
1955
+ * @param t - translate function for the failure messages.
1956
+ * @returns `{ provider, baseUrl, model, builtin? }`, or null when DSH has no default model.
1957
+ * @throws when this build exposes no settings API, or the default model has no Base URL.
1958
+ */
1959
+ const readDshModelConfig = async (ctx, t) => {
1960
+ const face = resolveDshModelFace(ctx)
1961
+ if (face === null) throw new Error(t('aiDshImportApi'))
1962
+ const described = await face.describe()
1963
+ const namespaces = new Map((described?.namespaces ?? []).map((view) => [view.ns, view]))
1964
+ const defaultModel = namespaces.get('agent-default-model')?.value ?? {}
1965
+ const providerId = String(defaultModel.provider ?? '').trim()
1966
+ const modelId = String(defaultModel.model ?? '').trim()
1967
+ if (!modelId) return null
1968
+ const readPath = (value, path) => (path ?? []).reduce((current, key) => current?.[key], value)
1969
+ const providers = await face.configuredProviders()
1970
+ const providerRow = Array.isArray(providers) ? providers.find((entry) => entry.provider === providerId) : undefined
1971
+ const modelNamespace = namespaces.get(providerRow?.settingsNs || 'llm-pi-ai')
1972
+ const providerProfile = providerRow
1973
+ ? readPath(modelNamespace?.value, providerRow.settingsPath) ?? {}
1974
+ : modelNamespace?.value?.providers?.[providerId] ?? {}
1975
+ const configuredModels = Array.isArray(providerProfile.models) ? providerProfile.models : []
1976
+ const modelProfile = configuredModels.find((entry) => entry?.id === modelId) ?? {}
1977
+ // The built-in DeepSeek routes fix their protocol in the adapter: whatever
1978
+ // address they use, they speak Messages.
1979
+ const deepseekRoute = isBuiltInDeepseekRoute(providerId, providerRow?.settingsNs)
1980
+ let protocol = String(modelProfile.api ?? providerProfile.api ?? '').trim()
1981
+ let baseUrl = String(providerProfile.baseURL ?? '').trim()
1982
+ if (deepseekRoute && protocol === '') protocol = 'anthropic-messages'
1983
+ let builtin = false
1984
+ if (baseUrl === '' && deepseekRoute) {
1985
+ // Nothing is importable from settings, so mirror the endpoint the
1986
+ // adapter itself falls back to (`PUBLIC_BASE_URL`).
1987
+ baseUrl = DEEPSEEK_PUBLIC_BASE_URL
1988
+ builtin = true
1989
+ }
1990
+ if (!baseUrl) throw new Error(t('aiDshImportNoBaseUrl').replace('{model}', modelId))
1991
+ return {
1992
+ provider: providerId.toLowerCase() === 'ollama' || protocol === 'ollama'
1993
+ ? 'ollama'
1994
+ : protocol === 'anthropic-messages' ? 'anthropic' : 'openai',
1995
+ baseUrl,
1996
+ model: modelId,
1997
+ ...builtin ? { builtin: true } : {},
1998
+ }
1999
+ }
2000
+
1641
2001
  function apply(ctx) {
1642
2002
  log('info', '应用装载', { version: PKG_VERSION })
1643
2003
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'settings-search: dictionaries')
@@ -1650,65 +2010,101 @@ window.__ModuleLoader__.load({
1650
2010
  const model = createModel(ctx)
1651
2011
  const indexControls = { resetHarvest: model.resetHarvest, persistIndex: model.persistIndex }
1652
2012
  const navigator = createNavigator(ctx)
1653
- let pluginManagerSyncToken = 0
2013
+ let pluginInventoryToken = 0
2014
+
2015
+ /** Apply one plugin inventory as the index fingerprint, invalidating on change. */
2016
+ const applyPluginFingerprint = (rows) => {
2017
+ if (!Array.isArray(rows) || rows.length === 0) return
2018
+ if (model.applyIndexFingerprint('manager:' + otherPluginFingerprint(rows))) {
2019
+ window.dispatchEvent(new CustomEvent(INDEX_INVALID_EVENT))
2020
+ log('info', '检测到其它插件变化,将重建设置索引', {})
2021
+ }
2022
+ }
1654
2023
 
1655
- const syncPluginManagerIndex = async (face) => {
2024
+ /** Legacy (pre-0.2) client plugin manager service: `list()` plus change events. */
2025
+ const syncLegacyPluginManagerIndex = async (face) => {
1656
2026
  if (face === null || !face.isLoopback) return
1657
- const token = ++pluginManagerSyncToken
2027
+ const token = ++pluginInventoryToken
1658
2028
  try {
1659
2029
  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
- }
2030
+ if (token !== pluginInventoryToken) return
2031
+ applyPluginFingerprint(rows)
1665
2032
  } catch {
1666
2033
  // Keep the last trusted fingerprint when manager inspection fails.
1667
2034
  }
1668
2035
  }
1669
2036
 
2037
+ /**
2038
+ * Read the installed plugin inventory from DSH 0.2's `remote.pluginManager`
2039
+ * namespace. Both calls answer `{ ok, value }`: bundles carry `name` and
2040
+ * `version`, plugin rows carry `entryId`/`moduleName` and a fiber phase.
2041
+ *
2042
+ * @param face - the `remote.pluginManager` service.
2043
+ * @returns inventory rows for otherPluginFingerprint().
2044
+ */
2045
+ const readRemotePluginInventory = async (face) => {
2046
+ const [bundles, plugins] = await Promise.all([
2047
+ typeof face?.listBundles === 'function' ? face.listBundles().catch(() => null) : null,
2048
+ typeof face?.listPlugins === 'function' ? face.listPlugins().catch(() => null) : null,
2049
+ ])
2050
+ const rows = []
2051
+ if (bundles?.ok === true) {
2052
+ for (const row of bundles.value ?? []) rows.push({ id: row?.name, npm: row?.name, version: row?.version, enabled: row?.enabled })
2053
+ }
2054
+ if (plugins?.ok === true) {
2055
+ for (const row of plugins.value ?? []) rows.push({ id: row?.entryId, npm: row?.moduleName, enabled: row?.enabled })
2056
+ }
2057
+ return rows
2058
+ }
2059
+
2060
+ const syncRemotePluginInventory = async (face) => {
2061
+ const token = ++pluginInventoryToken
2062
+ try {
2063
+ const rows = await readRemotePluginInventory(face)
2064
+ if (token !== pluginInventoryToken) return
2065
+ applyPluginFingerprint(rows)
2066
+ } catch {
2067
+ // Keep the last trusted fingerprint when the inventory read fails.
2068
+ }
2069
+ }
2070
+
2071
+ /**
2072
+ * Whether this build exposes a plugin inventory to watch: the 0.2 Remote
2073
+ * namespace or the legacy client service. Without one the seat fingerprint
2074
+ * is the only change signal.
2075
+ */
2076
+ const hasPluginInventory = () => {
2077
+ const remote = ctx.get('remote.pluginManager')
2078
+ if (remote !== undefined && remote !== null) return true
2079
+ const legacy = ctx.get('pluginManager')
2080
+ return legacy !== undefined && legacy !== null
2081
+ }
2082
+
2083
+ /**
2084
+ * Re-check the DSH 0.2 inventory. The Remote namespace has no change event,
2085
+ * so the check runs when the namespace arrives and whenever the settings
2086
+ * panel mounts (installs and updates land between the two).
2087
+ */
2088
+ const refreshPluginInventory = () => {
2089
+ const face = ctx.get('remote.pluginManager')
2090
+ if (face !== undefined && face !== null) void syncRemotePluginInventory(face)
2091
+ }
2092
+
2093
+ ctx.inject(['remote.pluginManager'], (inner) => {
2094
+ void syncRemotePluginInventory(inner.get('remote.pluginManager'))
2095
+ return () => { pluginInventoryToken += 1 }
2096
+ }, 'settings-search: remote plugin inventory fingerprint')
2097
+
1670
2098
  ctx.inject(['pluginManager'], (inner) => {
1671
2099
  const face = inner.pluginManager ?? null
1672
- const sync = () => { void syncPluginManagerIndex(face) }
2100
+ const sync = () => { void syncLegacyPluginManagerIndex(face) }
1673
2101
  sync()
1674
2102
  const unsubscribe = typeof face?.onChange === 'function' ? face.onChange(sync) : null
1675
2103
  return () => {
1676
- pluginManagerSyncToken += 1
2104
+ pluginInventoryToken += 1
1677
2105
  if (typeof unsubscribe === 'function') unsubscribe()
1678
2106
  }
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
- }
2107
+ }, 'settings-search: plugin manager index fingerprint (legacy)')
1712
2108
 
1713
2109
  const SearchIcon = React.createElement('svg', {
1714
2110
  className: 'sss-icon',
@@ -1790,6 +2186,36 @@ window.__ModuleLoader__.load({
1790
2186
  ])
1791
2187
  }
1792
2188
 
2189
+ /**
2190
+ * Read-only log viewer. Shows the host log tail when the host answered and
2191
+ * the current session's ring otherwise, with the file path (or the reason
2192
+ * the file is unavailable) spelled out above the text.
2193
+ */
2194
+ function LogViewer({ t, view }) {
2195
+ return React.createElement('div', {
2196
+ className: 'sss-lv-overlay',
2197
+ onMouseDown: (event) => { if (event.target === event.currentTarget) view.onClose() },
2198
+ }, [
2199
+ React.createElement('div', { className: 'sss-lv', role: 'dialog', 'aria-modal': 'true' }, [
2200
+ React.createElement('div', { className: 'sss-lv-header' }, [
2201
+ React.createElement('span', { className: 'sss-lv-title' }, t('logViewerTitle')),
2202
+ React.createElement('button', { className: 'sss-lv-close', type: 'button', 'aria-label': t('logViewerClose'), onClick: () => view.onClose() }, '✕'),
2203
+ ]),
2204
+ React.createElement('p', { className: 'sss-lv-meta' }, view.metaText),
2205
+ view.truncated ? React.createElement('p', { className: 'sss-lv-meta' }, t('logViewerTruncated')) : null,
2206
+ React.createElement('pre', { className: 'sss-lv-body' }, view.text === '' ? t('logViewerEmpty') : view.text),
2207
+ React.createElement('div', { className: 'sss-lv-actions' }, [
2208
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: view.loading, onClick: () => view.onRefresh() },
2209
+ view.loading ? t('logViewerLoading') : t('logViewerRefresh')),
2210
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => view.onOpenFolder() }, t('logOpenDir')),
2211
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => view.onCopy() },
2212
+ view.copied ? t('logViewerCopied') : t('logViewerCopy')),
2213
+ React.createElement('button', { className: 'sss-vr-btn sss-vr-primary', type: 'button', onClick: () => view.onClose() }, t('logViewerClose')),
2214
+ ]),
2215
+ ]),
2216
+ ])
2217
+ }
2218
+
1793
2219
  function SettingsHeaderSearch() {
1794
2220
  const [query, setQuery] = React.useState('')
1795
2221
  const [open, setOpen] = React.useState(false)
@@ -1863,11 +2289,14 @@ window.__ModuleLoader__.load({
1863
2289
  }, [])
1864
2290
 
1865
2291
  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.
2292
+ // DSH 0.2 answers the plugin inventory through `remote.pluginManager`
2293
+ // with no change event, so re-check it every time the panel mounts.
2294
+ refreshPluginInventory()
2295
+ // Without any plugin inventory, the seats the index was built from are
2296
+ // the only available change signal: re-apply them on every mount, so a
2297
+ // page or row that appeared since the last visit invalidates the index.
1869
2298
  const fallback = setTimeout(() => {
1870
- if (model.indexFingerprintReady()) return
2299
+ if (hasPluginInventory()) return
1871
2300
  if (model.applyIndexFingerprint(model.slotsFingerprint())) {
1872
2301
  window.dispatchEvent(new CustomEvent(INDEX_INVALID_EVENT))
1873
2302
  }
@@ -2361,6 +2790,10 @@ window.__ModuleLoader__.load({
2361
2790
  const [logCopyFail, setLogCopyFail] = React.useState(false)
2362
2791
  const [logExportFail, setLogExportFail] = React.useState(false)
2363
2792
  const [logCleared, setLogCleared] = React.useState(false)
2793
+ const [logViewer, setLogViewer] = React.useState(false)
2794
+ const [logViewerState, setLogViewerState] = React.useState({ status: 'idle', text: '', size: 0, truncated: false })
2795
+ const [logViewerCopied, setLogViewerCopied] = React.useState(false)
2796
+ const [logFolderMsg, setLogFolderMsg] = React.useState('')
2364
2797
  const [indexState, setIndexState] = React.useState('idle')
2365
2798
  const [warming, setWarming] = React.useState(false)
2366
2799
  const [confirmClear, setConfirmClear] = React.useState(false)
@@ -2399,12 +2832,19 @@ window.__ModuleLoader__.load({
2399
2832
  React.useEffect(() => {
2400
2833
  const sync = () => setLogVersion((v) => v + 1)
2401
2834
  window.addEventListener(LOG_EVENT, sync)
2402
- return () => window.removeEventListener(LOG_EVENT, sync)
2835
+ window.addEventListener(LOG_COUNT_EVENT, sync)
2836
+ // The badge counts what exists on disk, including earlier runs.
2837
+ void readLogCount()
2838
+ return () => {
2839
+ window.removeEventListener(LOG_EVENT, sync)
2840
+ window.removeEventListener(LOG_COUNT_EVENT, sync)
2841
+ }
2403
2842
  }, [])
2404
2843
 
2405
2844
  useAutoReset(logCopied, () => setLogCopied(false))
2406
2845
  useAutoReset(logCleared, () => setLogCleared(false))
2407
2846
  useAutoReset(logExportFail, () => setLogExportFail(false))
2847
+ useAutoReset(logViewerCopied, () => setLogViewerCopied(false))
2408
2848
 
2409
2849
  const changeProvider = (next) => {
2410
2850
  providerDrafts.current[provider] = { baseUrl, model }
@@ -2493,17 +2933,20 @@ window.__ModuleLoader__.load({
2493
2933
  setDshImportState('loading')
2494
2934
  setDshImportMsg('')
2495
2935
  try {
2496
- const imported = await readDshModelConfig()
2936
+ const imported = await readDshModelConfig(ctx, t)
2497
2937
  if (!imported) throw new Error(t('aiDshImportEmpty'))
2938
+ const { builtin, ...config } = imported
2498
2939
  providerDrafts.current[provider] = { baseUrl, model }
2499
- setAiConfig({ ...imported, apiKey })
2500
- setProvider(imported.provider)
2501
- setBaseUrl(imported.baseUrl)
2502
- setModel(imported.model)
2940
+ setAiConfig({ ...config, apiKey })
2941
+ setProvider(config.provider)
2942
+ setBaseUrl(config.baseUrl)
2943
+ setModel(config.model)
2503
2944
  resetModelList()
2504
2945
  setDshImportState('ok')
2505
- setDshImportMsg(t('aiDshImportOk'))
2506
- log('info', '导入 DSH 模型配置', { provider: imported.provider, model: imported.model })
2946
+ // A built-in DeepSeek route has no stored address and no API key of
2947
+ // its own, so the note says where the address came from.
2948
+ setDshImportMsg(builtin === true ? t('aiDshImportBuiltin') : t('aiDshImportOk'))
2949
+ log('info', '导入 DSH 模型配置', { provider: config.provider, model: config.model, builtin: builtin === true })
2507
2950
  } catch (error) {
2508
2951
  const reason = String(error?.message ?? error).slice(0, 160)
2509
2952
  setDshImportState('fail')
@@ -2632,8 +3075,64 @@ window.__ModuleLoader__.load({
2632
3075
  const clearLogs = () => {
2633
3076
  setConfirmClear(false)
2634
3077
  LOGS.length = 0
3078
+ // Drop queued lines too: a late flush would resurrect what was cleared.
3079
+ discardPendingLogs()
2635
3080
  setLogVersion((v) => v + 1)
2636
3081
  setLogCleared(true)
3082
+ void clearLogFile()
3083
+ }
3084
+
3085
+ /** Read the on-disk tail for the viewer; fall back to this session's ring. */
3086
+ const refreshLogView = async () => {
3087
+ setLogViewerState((current) => ({ ...current, status: 'loading' }))
3088
+ const read = await readLogFile()
3089
+ if (read === null) {
3090
+ setLogViewerState({ status: 'fail', text: sessionLogText(), size: 0, truncated: false })
3091
+ return
3092
+ }
3093
+ setLogViewerState({ status: 'ok', text: read.text, size: read.size, truncated: read.truncated })
3094
+ }
3095
+
3096
+ const openLogViewer = () => {
3097
+ flushLogs()
3098
+ setLogViewer(true)
3099
+ void refreshLogView()
3100
+ }
3101
+
3102
+ const copyLogView = async () => {
3103
+ try {
3104
+ await navigator.clipboard.writeText(logViewerState.text)
3105
+ setLogViewerCopied(true)
3106
+ } catch {
3107
+ setLogCopyFail(true)
3108
+ }
3109
+ }
3110
+
3111
+ /** Open the on-disk log folder through the host half. */
3112
+ const openLogDir = async () => {
3113
+ flushLogs()
3114
+ setLogFolderMsg('')
3115
+ const result = await openLogFolder()
3116
+ setLogFolderMsg(result.ok
3117
+ ? t('logOpenOk')
3118
+ : `${t('logOpenFail')}${result.detail === '' ? '' : `(${result.detail})`}`)
3119
+ }
3120
+
3121
+ /** Badge value: entries that exist on disk, or this session's ring when the host is unavailable. */
3122
+ const logBadgeCount = logTarget().count ?? LOGS.length
3123
+
3124
+ const logView = {
3125
+ text: logViewerState.text,
3126
+ truncated: logViewerState.truncated,
3127
+ loading: logViewerState.status === 'loading',
3128
+ copied: logViewerCopied,
3129
+ metaText: logViewerState.status === 'fail' || logTarget().file === ''
3130
+ ? t('logViewerMemory')
3131
+ : t('logViewerFile').replace('{file}', logTarget().file),
3132
+ onClose: () => setLogViewer(false),
3133
+ onRefresh: () => { void refreshLogView() },
3134
+ onOpenFolder: () => { void openLogDir() },
3135
+ onCopy: () => { void copyLogView() },
2637
3136
  }
2638
3137
 
2639
3138
  const check = async () => {
@@ -2781,7 +3280,7 @@ window.__ModuleLoader__.load({
2781
3280
  }, indexState === 'loading' || warming ? t('indexRebuilding') : t('indexRebuild')),
2782
3281
  ]),
2783
3282
  indexState === 'done'
2784
- ? React.createElement('p', { className: 'sss-vr-note', style: { color: '#1e8e3e' } }, t('indexDone'))
3283
+ ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-state-success-primary)' } }, t('indexDone'))
2785
3284
  : null,
2786
3285
  ]),
2787
3286
  React.createElement('div', { className: 'sss-vr-field sss-vr-form' }, [
@@ -2848,7 +3347,7 @@ window.__ModuleLoader__.load({
2848
3347
  modelsMsg
2849
3348
  ? React.createElement('p', {
2850
3349
  className: modelsState === 'fail' ? 'sss-vr-note' : 'sss-vr-hint',
2851
- style: modelsState === 'fail' ? { color: 'var(--dsw-alias-label-danger)' } : undefined,
3350
+ style: modelsState === 'fail' ? { color: 'var(--dsw-alias-state-error-primary)' } : undefined,
2852
3351
  }, modelsMsg)
2853
3352
  : null,
2854
3353
  provider !== 'ollama' ? React.createElement('div', { className: 'sss-vr-field-head' }, [
@@ -2878,10 +3377,10 @@ window.__ModuleLoader__.load({
2878
3377
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => requestClearAi() }, t('aiClear')),
2879
3378
  ]),
2880
3379
  dshImportMsg
2881
- ? React.createElement('p', { className: 'sss-vr-note', style: { color: dshImportState === 'ok' ? '#1e8e3e' : 'var(--dsw-alias-label-danger)' } }, dshImportMsg)
3380
+ ? 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
3381
  : null,
2883
3382
  testMsg
2884
- ? React.createElement('p', { className: 'sss-vr-note', style: { color: testState === 'ok' ? '#1e8e3e' : 'var(--dsw-alias-label-danger)' } }, testMsg)
3383
+ ? 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
3384
  : null,
2886
3385
  ]),
2887
3386
  React.createElement('div', { className: 'sss-vr-field' }, [
@@ -2907,7 +3406,7 @@ window.__ModuleLoader__.load({
2907
3406
  copied ? t('updCopied') : t('updCopy')),
2908
3407
  React.createElement('a', { className: 'sss-vr-link', href: releasesUrl, target: '_blank', rel: 'noopener noreferrer' }, t('updRelease')),
2909
3408
  ]),
2910
- copyFailed ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-label-danger)' } }, t('updCopyFail')) : null,
3409
+ copyFailed ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-state-error-primary)' } }, t('updCopyFail')) : null,
2911
3410
  ])
2912
3411
  : null,
2913
3412
  React.createElement('div', { className: 'sss-vr-actions' }, [
@@ -2925,23 +3424,28 @@ window.__ModuleLoader__.load({
2925
3424
  React.createElement('div', { className: 'sss-vr-field' }, [
2926
3425
  React.createElement('div', { className: 'sss-vr-field-head' }, [
2927
3426
  React.createElement('span', { className: 'sss-vr-label' }, t('logTitle')),
2928
- LOGS.length > 0 ? React.createElement('span', { className: 'sss-vr-badge' }, String(LOGS.length)) : null,
3427
+ logBadgeCount > 0
3428
+ ? React.createElement('span', { className: 'sss-vr-badge', title: t('logCountHint') }, String(logBadgeCount))
3429
+ : null,
2929
3430
  ]),
2930
3431
  React.createElement('p', { className: 'sss-vr-hint' }, t('logDesc')),
2931
- LOGS.length === 0
3432
+ LOGS.length === 0 && logBadgeCount === 0
2932
3433
  ? React.createElement('p', { className: 'sss-vr-note' }, t('logEmpty'))
2933
3434
  : null,
2934
3435
  React.createElement('div', { className: 'sss-vr-actions' }, [
3436
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => openLogViewer() }, t('logView')),
3437
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void openLogDir() } }, t('logOpenDir')),
2935
3438
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void exportLogs() } }, t('logExport')),
2936
3439
  React.createElement('a', { className: 'sss-vr-link', href: ISSUES_URL, target: '_blank', rel: 'noopener noreferrer' },
2937
3440
  t('logIssues')),
2938
3441
  React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void copyLogs() } },
2939
3442
  logCopied ? t('logCopied') : t('logCopy')),
2940
- React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: LOGS.length === 0, onClick: () => requestClear() },
3443
+ React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: logBadgeCount === 0, onClick: () => requestClear() },
2941
3444
  logCleared ? t('logCleared') : t('logClear')),
2942
3445
  ]),
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,
3446
+ logFolderMsg ? React.createElement('p', { className: 'sss-vr-note' }, logFolderMsg) : null,
3447
+ logCopyFail ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-state-error-primary)' } }, t('logCopyFail')) : null,
3448
+ logExportFail ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-state-error-primary)' } }, t('logExportFail')) : null,
2945
3449
  ]),
2946
3450
  ]) : null,
2947
3451
  confirmClear
@@ -2955,6 +3459,9 @@ window.__ModuleLoader__.load({
2955
3459
  onCancel: () => cancelClear(),
2956
3460
  })
2957
3461
  : null,
3462
+ logViewer
3463
+ ? React.createElement(LogViewer, { t, view: logView })
3464
+ : null,
2958
3465
  confirmClearAi
2959
3466
  ? React.createElement(ConfirmModal, {
2960
3467
  t,