dsh-instruction-memory 1.2.0 → 1.3.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
@@ -153,6 +153,26 @@ window.__ModuleLoader__.load({
153
153
  .im-backup { display:flex; flex-direction:column; gap:8px; }
154
154
  .im-backup-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
155
155
  .im-backup-row .im-input { flex:1; min-width:200px; }
156
+ /* ---- F12 诊断与安全状态 ---- */
157
+ .im-hostwarn { display:flex; gap:8px; align-items:flex-start; padding:9px 12px; border-radius:8px; font-size:12px;
158
+ color:var(--dsw-alias-state-warn-primary,#d97706); border:1px solid var(--dsw-alias-state-warn-primary,#d97706);
159
+ background:var(--dsw-alias-state-warn-bg,rgba(217,119,6,.12)); font-weight:500; }
160
+ .im-diag { display:flex; flex-direction:column; gap:8px; }
161
+ .im-diag-group { display:flex; flex-direction:column; gap:3px; padding:8px 10px; border-radius:8px;
162
+ border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.1)); background:var(--dsw-alias-bg-base,#fff); }
163
+ .im-diag-sub { color:var(--dsw-alias-label-secondary,#8a9099); font-size:11.5px; }
164
+ .im-diag-fallback { display:flex; flex-direction:column; gap:5px; }
165
+ .im-diag-fallback .im-textarea { min-height:170px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:11px; }
166
+ /* ---- F7 临时静音 ---- */
167
+ .im-master-col { display:flex; flex-direction:column; align-items:flex-end; gap:6px; }
168
+ .im-pause-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; justify-content:flex-end; }
169
+ .im-pause-menu { display:flex; flex-direction:column; gap:4px; padding:8px 10px; border-radius:8px;
170
+ border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.18)); background:var(--dsw-alias-bg-base,#fff);
171
+ box-shadow:0 4px 14px rgba(0,0,0,.1); }
172
+ .im-pause-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; justify-content:flex-end;
173
+ padding:6px 10px; border-radius:8px; font-size:12px;
174
+ border:1px solid var(--dsw-alias-state-warn-primary,#d97706);
175
+ background:var(--dsw-alias-state-warn-bg,rgba(217,119,6,.12)); color:var(--dsw-alias-state-warn-primary,#d97706); }
156
176
  @media (max-width:640px) { .im-grid2 { grid-template-columns:1fr; } }
157
177
  `
158
178
 
@@ -315,6 +335,50 @@ window.__ModuleLoader__.load({
315
335
  'sets.copySuffix': ' 副本',
316
336
  'sets.deleteConfirm': '确定删除配置集「{name}」?其中的 {entries} 条指令与 {profiles} 个用户形象会一并删除,且无法撤销。',
317
337
  'sets.deleteActive': '当前激活的配置集不能删除;请先切换到其他配置集。',
338
+ 'sets.draftsClosed': '已关闭切换前打开的编辑草稿',
339
+ // ---- F12 诊断与安全状态 ----
340
+ 'diag.section': '诊断信息',
341
+ 'diag.show': '展开诊断',
342
+ 'diag.hide': '收起诊断',
343
+ 'diag.copy': '复制诊断信息',
344
+ 'diag.copied': '诊断信息已复制到剪贴板',
345
+ 'diag.copyManual': '自动复制不可用;请手动全选复制下方文本。',
346
+ 'diag.hostWarn': '安全提示:设置路由当前绑定在非回环地址({host}),同一网络内的设备可以无鉴权访问此页面,请勿在不可信网络暴露;如需对外发布,请阅读 README 的「经反向代理对外发布」。',
347
+ 'diag.groupStorage': '存储',
348
+ 'diag.groupInjection': '注入',
349
+ 'diag.groupBackup': '备份与历史',
350
+ 'diag.groupIssues': '错误与警告',
351
+ 'diag.none': '(无)',
352
+ 'diag.storagePath': '存储文件',
353
+ 'diag.onDisk': '存储文件已在磁盘',
354
+ 'diag.loadedFrom': '载入来源',
355
+ 'diag.savedAt': '最近保存',
356
+ 'diag.rev': '修订号(rev)',
357
+ 'diag.registered': '注入已注册',
358
+ 'diag.yes': '是',
359
+ 'diag.no': '否',
360
+ 'diag.pulls': '注入拉取次数(自本次插件启动计数)',
361
+ 'diag.chars': '当前注入字符数 / 上限',
362
+ 'diag.backupDir': '备份位置',
363
+ 'diag.backupDefault': '默认(与存储文件同目录)',
364
+ 'diag.backupPath': '覆盖副本(.bak)',
365
+ 'diag.historyDir': '版本历史目录',
366
+ 'diag.historyCount': '历史快照数',
367
+ 'diag.schemaFile': 'Schema 文件',
368
+ 'diag.issueStorage': '存储错误',
369
+ 'diag.issueWarning': '存储警告',
370
+ 'diag.issueHistory': '历史错误',
371
+ 'diag.issueSection': '注入错误',
372
+ // ---- F7 临时静音 ----
373
+ 'mute.menu': '暂停注入…',
374
+ 'mute.30m': '30 分钟',
375
+ 'mute.1h': '1 小时',
376
+ 'mute.today': '到今天结束',
377
+ 'mute.resume': '立即恢复',
378
+ 'mute.active': '注入已暂停,剩余 {remaining}',
379
+ 'mute.hint': '暂停期间不注入任何指令或用户形象,到期后自动恢复;此状态跨重启保留。',
380
+ 'mute.remainingMs': '{m} 分 {s} 秒',
381
+ 'mute.remainingH': '{h} 小时 {m} 分',
318
382
  // ---- F14 版本历史 ----
319
383
  'history.section': '版本历史',
320
384
  'history.subtitle': '每次保存都会留一份带时间戳的快照(每台设备最多保留 {max} 份,逐出最旧的)。可在此查看并一键回滚。',
@@ -522,6 +586,50 @@ window.__ModuleLoader__.load({
522
586
  'sets.copySuffix': ' copy',
523
587
  'sets.deleteConfirm': 'Delete the set “{name}”? Its {entries} instructions and {profiles} personas are deleted with it. This cannot be undone.',
524
588
  'sets.deleteActive': 'The active set cannot be deleted; switch to another set first.',
589
+ 'sets.draftsClosed': 'The editor draft open before the switch was closed',
590
+ // ---- F12 diagnostics & security panel ----
591
+ 'diag.section': 'Diagnostics',
592
+ 'diag.show': 'Show diagnostics',
593
+ 'diag.hide': 'Hide diagnostics',
594
+ 'diag.copy': 'Copy diagnostics',
595
+ 'diag.copied': 'Diagnostics copied to the clipboard',
596
+ 'diag.copyManual': 'Automatic copy is unavailable; select and copy the text below manually.',
597
+ 'diag.hostWarn': 'Security notice: the settings route is bound to a non-loopback address ({host}); other devices on the same network can reach this page without authentication. Do not expose it on an untrusted network — for public deployments read “Publishing behind a reverse proxy” in the README.',
598
+ 'diag.groupStorage': 'Storage',
599
+ 'diag.groupInjection': 'Injection',
600
+ 'diag.groupBackup': 'Backup & history',
601
+ 'diag.groupIssues': 'Errors & warnings',
602
+ 'diag.none': '(none)',
603
+ 'diag.storagePath': 'Store file',
604
+ 'diag.onDisk': 'Store file on disk',
605
+ 'diag.loadedFrom': 'Loaded from',
606
+ 'diag.savedAt': 'Last saved',
607
+ 'diag.rev': 'Revision (rev)',
608
+ 'diag.registered': 'Section registered',
609
+ 'diag.yes': 'yes',
610
+ 'diag.no': 'no',
611
+ 'diag.pulls': 'Prompt pulls (counted since this plugin started)',
612
+ 'diag.chars': 'Injected chars / budget',
613
+ 'diag.backupDir': 'Backup location',
614
+ 'diag.backupDefault': 'default (next to the store file)',
615
+ 'diag.backupPath': 'Rolling copy (.bak)',
616
+ 'diag.historyDir': 'History directory',
617
+ 'diag.historyCount': 'History snapshots',
618
+ 'diag.schemaFile': 'Schema file',
619
+ 'diag.issueStorage': 'Storage error',
620
+ 'diag.issueWarning': 'Storage warning',
621
+ 'diag.issueHistory': 'History error',
622
+ 'diag.issueSection': 'Injection error',
623
+ // ---- F7 temporary mute ----
624
+ 'mute.menu': 'Pause injection…',
625
+ 'mute.30m': '30 minutes',
626
+ 'mute.1h': '1 hour',
627
+ 'mute.today': 'Until the end of today',
628
+ 'mute.resume': 'Resume now',
629
+ 'mute.active': 'Injection paused — {remaining} left',
630
+ 'mute.hint': 'While paused, neither instructions nor personas are injected. It resumes automatically and survives a restart.',
631
+ 'mute.remainingMs': '{m}m {s}s',
632
+ 'mute.remainingH': '{h}h {m}m',
525
633
  // ---- F14 version history ----
526
634
  'history.section': 'Version history',
527
635
  'history.subtitle': 'Every save leaves a timestamped snapshot ({max} kept per device, oldest evicted first). Review them here and roll back with one click.',
@@ -924,6 +1032,71 @@ window.__ModuleLoader__.load({
924
1032
  return { budget, used, omitted, truncated, percent, tokens }
925
1033
  }
926
1034
 
1035
+ /**
1036
+ * F12: serialize a diagnostic snapshot into plain text for an issue report.
1037
+ * Pinned as a pure export so verify.mjs can hold the contract: the dump
1038
+ * carries the operational fields (store shape, storage, injection,
1039
+ * security, load origin) plus the reporter's environment, and NEVER
1040
+ * carries user text — no entry titles, no entry or persona bodies. Paths
1041
+ * (including the absolute backup directory) are machine-local but are the
1042
+ * whole point of a support dump, so they stay.
1043
+ */
1044
+ function diagnosticsText(view, env) {
1045
+ const sec = (root, name) => (root !== null && typeof root === 'object'
1046
+ && root[name] !== null && typeof root[name] === 'object' ? root[name] : {})
1047
+ const data = sec(view, 'data')
1048
+ const storage = sec(view, 'storage')
1049
+ const injection = sec(view, 'injection')
1050
+ const security = sec(view, 'security')
1051
+ const diagnostics = sec(view, 'diagnostics')
1052
+ const environment = env !== null && typeof env === 'object' ? env : {}
1053
+ const v = (value) => (value === null || value === undefined ? '' : String(value))
1054
+ const count = (value) => (Array.isArray(value) ? String(value.length) : '0')
1055
+ return [
1056
+ 'dsh-instruction-memory diagnostics',
1057
+ '--- store ---',
1058
+ 'version: ' + v(data.version),
1059
+ 'rev: ' + v(data.rev),
1060
+ 'enabled: ' + v(data.enabled),
1061
+ 'budgetChars: ' + v(data.budgetChars),
1062
+ 'disabledUntil: ' + v(data.disabledUntil),
1063
+ 'sets: ' + count(data.sets),
1064
+ 'entries(active set): ' + count(data.entries),
1065
+ 'profiles(active set): ' + count(data.profiles),
1066
+ '--- storage ---',
1067
+ 'path: ' + v(storage.path),
1068
+ 'onDisk: ' + v(storage.onDisk),
1069
+ 'loadedFrom: ' + v(diagnostics.loadedFrom),
1070
+ 'rev: ' + v(diagnostics.rev),
1071
+ 'savedAt: ' + (typeof storage.savedAt === 'number' && storage.savedAt > 0 ? new Date(storage.savedAt).toISOString() : ''),
1072
+ 'migratedFrom: ' + v(storage.migratedFrom),
1073
+ 'backupDir: ' + v(storage.backupDir),
1074
+ 'isDefaultBackupDir: ' + v(storage.isDefaultBackupDir),
1075
+ 'backupPath: ' + v(storage.backupPath),
1076
+ 'historyDir: ' + v(storage.historyDir),
1077
+ 'historyCount: ' + v(storage.historyCount),
1078
+ 'schemaFile: ' + v(storage.schemaFile),
1079
+ '--- injection ---',
1080
+ 'registered: ' + v(injection.registered),
1081
+ 'muted: ' + v(injection.muted),
1082
+ 'error: ' + v(injection.error),
1083
+ 'chars: ' + v(injection.chars),
1084
+ 'budget: ' + v(injection.budget),
1085
+ 'pulls(since boot): ' + v(injection.pulls),
1086
+ '--- security ---',
1087
+ 'hostGuarded: ' + v(security.hostGuarded),
1088
+ 'boundHost: ' + v(security.boundHost),
1089
+ '--- errors/warnings ---',
1090
+ 'storage.error: ' + v(storage.error),
1091
+ 'storage.warning: ' + v(storage.warning),
1092
+ 'historyError: ' + v(storage.historyError),
1093
+ '--- environment ---',
1094
+ 'userAgent: ' + v(environment.userAgent),
1095
+ 'language: ' + v(environment.language),
1096
+ 'time: ' + v(environment.now),
1097
+ ].join('\n')
1098
+ }
1099
+
927
1100
  /* ---------------- F17: local relevance preview ---------------- */
928
1101
 
929
1102
  /** The most terms one query may be split into; keeps matching cheap. */
@@ -1318,6 +1491,16 @@ window.__ModuleLoader__.load({
1318
1491
  // draft: editing an instruction never closes an open persona and vice
1319
1492
  // versa is handled by closing one when opening the other (see handlers).
1320
1493
  const [profileDraft, setProfileDraft] = React.useState(null)
1494
+ // Latest live values of the two editor drafts. switchSet's success
1495
+ // callback is a closure created at click time; between the click and the
1496
+ // response landing, a draft can still be opened (the busy flag only
1497
+ // re-renders on the next pass), so the callback must read the drafts
1498
+ // through refs to see what is REALLY open when the switch lands — a
1499
+ // stale `draft === null` would leave the old set's form on screen.
1500
+ const draftRef = React.useRef(null)
1501
+ draftRef.current = draft
1502
+ const profileDraftRef = React.useRef(null)
1503
+ profileDraftRef.current = profileDraft
1321
1504
  const [budgetText, setBudgetText] = React.useState('')
1322
1505
  // Latest live value of the draft, because the SSE onmessage closure below
1323
1506
  // is created once and must not read a stale first-render budgetText.
@@ -1354,6 +1537,17 @@ window.__ModuleLoader__.load({
1354
1537
  const [historyOpen, setHistoryOpen] = React.useState(false)
1355
1538
  const [history, setHistory] = React.useState(null)
1356
1539
  const [historyBusy, setHistoryBusy] = React.useState(false)
1540
+ // F12: the diagnostics card is collapsed by default; a failed automatic
1541
+ // copy parks the dump here so it can be selected manually instead of
1542
+ // pretending the copy happened.
1543
+ const [diagOpen, setDiagOpen] = React.useState(false)
1544
+ const [diagFallback, setDiagFallback] = React.useState(null)
1545
+ // F7: the pause menu (null-state boolean) and a 1s clock tick that exists
1546
+ // ONLY while a pause is live (its effect cleans up after itself). The
1547
+ // tick drives nothing but the remaining-time text; authority stays with
1548
+ // the host snapshot (injection.muted / data.disabledUntil).
1549
+ const [pauseMenuOpen, setPauseMenuOpen] = React.useState(false)
1550
+ const [nowTick, setNowTick] = React.useState(Date.now())
1357
1551
  // F18: the backup-location draft (focused-input protection like budget).
1358
1552
  const [backupText, setBackupText] = React.useState('')
1359
1553
  const backupTextRef = React.useRef('')
@@ -1396,6 +1590,27 @@ window.__ModuleLoader__.load({
1396
1590
  return () => clearTimeout(timer)
1397
1591
  }, [undo])
1398
1592
 
1593
+ // F7: tick once per second while a pause is live so the remaining-time
1594
+ // text counts down. The interval exists only while muted — the cleanup
1595
+ // clears it the moment the snapshot says otherwise. This effect sits
1596
+ // BEFORE the early returns (a hook skipped by a conditional return would
1597
+ // desync the hook order); it reads the snapshot through `view` because
1598
+ // the derived `injection` does not exist yet up here.
1599
+ const mutedNow = view !== null && view.injection !== null && typeof view.injection === 'object'
1600
+ && view.injection.muted === true
1601
+ React.useEffect(() => {
1602
+ if (!mutedNow) return undefined
1603
+ // A pause started from ANOTHER window replaces the menu row; the open
1604
+ // flag must not resurface when the row returns after expiry.
1605
+ setPauseMenuOpen(false)
1606
+ // Snapshot the clock the moment the pause lands: nowTick may be up to
1607
+ // a second stale from a PREVIOUS pause, which would render the first
1608
+ // frame with an inflated remaining time.
1609
+ setNowTick(Date.now())
1610
+ const tick = setInterval(() => setNowTick(Date.now()), 1000)
1611
+ return () => clearInterval(tick)
1612
+ }, [mutedNow])
1613
+
1399
1614
  /**
1400
1615
  * F16 live validation, debounced: parsing on every keystroke would stall
1401
1616
  * the panel on a multi-megabyte paste, and 250 ms is below the threshold
@@ -1566,6 +1781,22 @@ window.__ModuleLoader__.load({
1566
1781
  if (result !== null && result.ok === true) {
1567
1782
  setSelected([])
1568
1783
  setUndo(null)
1784
+ // Batch mode exists to act on listed ids; after a switch it would
1785
+ // act on ids from the wrong set, so it leaves with the selection.
1786
+ setBatchMode(false)
1787
+ // An editor left open across a switch is a form full of the OLD
1788
+ // set's content that would save into the NEW set. The server-side
1789
+ // cross-set guard means no data is lost, but the form itself is a
1790
+ // lie — so it closes with the switch, and the user is told. The
1791
+ // refs (not the closure state) say whether anything was open.
1792
+ setDraft(null)
1793
+ setProfileDraft(null)
1794
+ if (draftRef.current !== null || profileDraftRef.current !== null) {
1795
+ const base = typeof result.message === 'string' && result.message !== ''
1796
+ ? result.message + ';'
1797
+ : ''
1798
+ setNotice(base + t('sets.draftsClosed'))
1799
+ }
1569
1800
  }
1570
1801
  })
1571
1802
  }
@@ -1729,6 +1960,55 @@ window.__ModuleLoader__.load({
1729
1960
  }
1730
1961
  }
1731
1962
 
1963
+ // ---- F12 diagnostics copy ----
1964
+
1965
+ function copyDiagnostics() {
1966
+ if (view === null) return
1967
+ const text = diagnosticsText(view, {
1968
+ userAgent: typeof navigator !== 'undefined' && navigator.userAgent !== undefined ? String(navigator.userAgent) : '',
1969
+ language: typeof navigator !== 'undefined' && navigator.language !== undefined ? String(navigator.language) : '',
1970
+ now: new Date().toISOString(),
1971
+ })
1972
+ const clipboard = typeof navigator !== 'undefined' && navigator.clipboard
1973
+ && typeof navigator.clipboard.writeText === 'function' ? navigator.clipboard : null
1974
+ if (clipboard !== null) {
1975
+ clipboard.writeText(text).then(() => setNotice(t('diag.copied')), () => fallbackCopyDiagnostics(text))
1976
+ return
1977
+ }
1978
+ fallbackCopyDiagnostics(text)
1979
+ }
1980
+
1981
+ /**
1982
+ * The webview may ship neither the async clipboard API nor
1983
+ * document.execCommand; then the only honest move is to surface the dump
1984
+ * in a selectable textarea instead of pretending the copy happened.
1985
+ */
1986
+ function fallbackCopyDiagnostics(text) {
1987
+ try {
1988
+ const area = document.createElement('textarea')
1989
+ area.value = text
1990
+ area.setAttribute('readonly', '')
1991
+ const host = document.body || document.head
1992
+ if (host && typeof host.appendChild === 'function') host.appendChild(area)
1993
+ if (typeof area.select === 'function') area.select()
1994
+ // execCommand may THROW (not merely return false); the detached
1995
+ // helper must leave the DOM on every path.
1996
+ let ok = false
1997
+ try {
1998
+ ok = typeof document.execCommand === 'function' && document.execCommand('copy') === true
1999
+ } finally {
2000
+ if (host && typeof host.removeChild === 'function' && area.parentNode === host) host.removeChild(area)
2001
+ }
2002
+ if (ok) {
2003
+ setNotice(t('diag.copied'))
2004
+ return
2005
+ }
2006
+ throw new Error('copy rejected')
2007
+ } catch {
2008
+ setDiagFallback(text)
2009
+ }
2010
+ }
2011
+
1732
2012
  async function doExport() {
1733
2013
  try {
1734
2014
  const result = await callHost('export-data', null)
@@ -1854,6 +2134,11 @@ window.__ModuleLoader__.load({
1854
2134
  // it here keeps the effect's dependency list down to the text itself.
1855
2135
  sourceCeilingRef.current = typeof limits.maxBodyChars === 'number' ? limits.maxBodyChars : 0
1856
2136
  const injection = view.injection || { registered: false, chars: 0, error: null }
2137
+ // F12: the security block is optional (older Host) and then means "no
2138
+ // known problem" — an absent field must not manufacture a warning.
2139
+ const security = view.security !== null && typeof view.security === 'object'
2140
+ ? view.security
2141
+ : { hostGuarded: true, boundHost: '' }
1857
2142
  // List order is display-only and never changes what the Host injects
1858
2143
  // (the injection order is decided host-side). The default mirrors the
1859
2144
  // previous fixed order; updated-time and title are the other sorts.
@@ -1972,20 +2257,90 @@ window.__ModuleLoader__.load({
1972
2257
  : (injection.registered
1973
2258
  ? t('status.mounted', { chars: injection.chars })
1974
2259
  : (activeCount === 0 ? t('status.notMountedEmpty') : t('status.notMounted')))
2260
+ // F12: display-only rows. Every value comes straight from the snapshot;
2261
+ // a missing field (older Host) degrades to (无) instead of throwing
2262
+ // mid-render.
2263
+ const diagValue = (value) => (value === null || value === undefined || value === '' ? t('diag.none') : String(value))
2264
+ const diagYesNo = (value) => (value === true ? t('diag.yes') : t('diag.no'))
2265
+ const diagRow = (label, value) => h('div', { className: 'im-meta' }, label + ':' + value)
2266
+ const diagLoadedFrom = view.diagnostics !== null && typeof view.diagnostics === 'object'
2267
+ ? view.diagnostics.loadedFrom
2268
+ : null
2269
+ const diagRev = view.diagnostics !== null && typeof view.diagnostics === 'object'
2270
+ && typeof view.diagnostics.rev === 'number'
2271
+ ? view.diagnostics.rev
2272
+ : (data.rev || 0)
2273
+ // F7: the pause decision itself belongs to the host (injection.muted);
2274
+ // the countdown below is display-only. At zero the bar yields back to
2275
+ // the normal control (the host's own timer is delivering a fresh
2276
+ // snapshot at that moment in any case).
2277
+ const muted = injection.muted === true
2278
+ const disabledUntil = typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil)
2279
+ ? data.disabledUntil
2280
+ : 0
2281
+ const muteRemainingMs = muted ? Math.max(0, disabledUntil - nowTick) : 0
2282
+ const muteRemainingText = (ms) => {
2283
+ const totalSec = Math.max(0, Math.round(ms / 1000))
2284
+ const hh = Math.floor(totalSec / 3600)
2285
+ const mm = Math.floor((totalSec % 3600) / 60)
2286
+ const ss = totalSec % 60
2287
+ return hh > 0 ? t('mute.remainingH', { h: hh, m: mm }) : t('mute.remainingMs', { m: mm, s: ss })
2288
+ }
2289
+ const endOfToday = () => {
2290
+ const d = new Date()
2291
+ d.setHours(24, 0, 0, 0)
2292
+ return d.getTime()
2293
+ }
2294
+ function pauseFor(ms) {
2295
+ if (busy || typeof ms !== 'number' || !(ms > 0)) return
2296
+ setPauseMenuOpen(false)
2297
+ void send('set-options', { disabledUntil: Date.now() + ms, baseRev: revOf() })
2298
+ }
2299
+ function resumePause() {
2300
+ if (busy) return
2301
+ setPauseMenuOpen(false)
2302
+ void send('set-options', { disabledUntil: 0, baseRev: revOf() })
2303
+ }
1975
2304
 
1976
2305
  const header = h('div', { className: 'im-card', key: 'head' },
2306
+ // F12: the non-loopback warning sits at the TOP of the panel — not
2307
+ // inside the collapsible diagnostics card — so it cannot be missed.
2308
+ security.hostGuarded === false
2309
+ ? h('div', { className: 'im-hostwarn', key: 'hostwarn' },
2310
+ t('diag.hostWarn', { host: security.boundHost !== '' ? security.boundHost : '?' }))
2311
+ : null,
1977
2312
  h('div', { className: 'im-row' },
1978
2313
  h('div', null,
1979
2314
  h('h3', { className: 'im-h' }, t('panel.title')),
1980
2315
  h('p', { className: 'im-sub' }, t('panel.subtitle'))),
1981
- h('label', { className: 'im-switch' },
1982
- h('input', {
1983
- type: 'checkbox',
1984
- checked: data.enabled === true,
1985
- disabled: busy,
1986
- onChange: () => send('set-options', { enabled: !data.enabled, baseRev: revOf() }),
1987
- }),
1988
- t('panel.masterSwitch'))),
2316
+ h('div', { className: 'im-master-col' },
2317
+ h('label', { className: 'im-switch' },
2318
+ h('input', {
2319
+ type: 'checkbox',
2320
+ checked: data.enabled === true,
2321
+ disabled: busy,
2322
+ onChange: () => send('set-options', { enabled: !data.enabled, baseRev: revOf() }),
2323
+ }),
2324
+ t('panel.masterSwitch')),
2325
+ // F7 临时静音:暂停期间提示词与「未安装插件」逐字节一致;到期由宿主定时器
2326
+ // 自动恢复,无需用户操作、不写盘。倒计时只是展示,权威在快照。
2327
+ muted && muteRemainingMs > 0
2328
+ ? h('div', { className: 'im-pause-bar' },
2329
+ h('span', null, t('mute.active', { remaining: muteRemainingText(muteRemainingMs) })),
2330
+ h('button', { className: 'im-btn', disabled: busy, onClick: resumePause }, t('mute.resume')))
2331
+ : h('div', { className: 'im-pause-row' },
2332
+ h('button', {
2333
+ className: 'im-btn',
2334
+ disabled: busy,
2335
+ onClick: () => setPauseMenuOpen(!pauseMenuOpen),
2336
+ }, t('mute.menu')),
2337
+ pauseMenuOpen
2338
+ ? h('div', { className: 'im-pause-menu' },
2339
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => pauseFor(30 * 60000) }, t('mute.30m')),
2340
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => pauseFor(60 * 60000) }, t('mute.1h')),
2341
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => pauseFor(endOfToday() - Date.now()) }, t('mute.today')),
2342
+ h('div', { className: 'im-meta' }, t('mute.hint')))
2343
+ : null))),
1989
2344
  h('div', { className: 'im-status' },
1990
2345
  h('span', { className: dotClass }),
1991
2346
  h('span', { className: injection.error ? 'im-err' : 'im-meta' }, dotText)),
@@ -2410,6 +2765,58 @@ window.__ModuleLoader__.load({
2410
2765
  history !== null && history.dir !== ''
2411
2766
  ? h('div', { className: 'im-meta' }, t('history.dir', { dir: history.dir }))
2412
2767
  : null),
2768
+ // ---- F12 诊断与安全状态(默认收起;纯展示,无写路由) ----
2769
+ h('div', { key: 'diag', className: 'im-diag' },
2770
+ h('div', { className: 'im-profiles-head' },
2771
+ h('span', { className: 'im-section-title' }, t('diag.section')),
2772
+ h('span', { className: 'im-spacer' }),
2773
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => setDiagOpen(!diagOpen) },
2774
+ diagOpen ? t('diag.hide') : t('diag.show')),
2775
+ h('button', { className: 'im-btn', disabled: busy, onClick: copyDiagnostics }, t('diag.copy'))),
2776
+ diagOpen
2777
+ ? h('div', null,
2778
+ h('div', { className: 'im-diag-group' },
2779
+ h('span', { className: 'im-section-title im-diag-sub' }, t('diag.groupStorage')),
2780
+ diagRow(t('diag.storagePath'), diagValue(storage.path)),
2781
+ diagRow(t('diag.onDisk'), diagYesNo(storage.onDisk === true)),
2782
+ diagRow(t('diag.loadedFrom'), diagValue(diagLoadedFrom)),
2783
+ diagRow(t('diag.savedAt'), typeof storage.savedAt === 'number' && storage.savedAt > 0
2784
+ ? new Date(storage.savedAt).toLocaleString()
2785
+ : t('diag.none')),
2786
+ diagRow(t('diag.rev'), String(diagRev))),
2787
+ h('div', { className: 'im-diag-group' },
2788
+ h('span', { className: 'im-section-title im-diag-sub' }, t('diag.groupInjection')),
2789
+ diagRow(t('diag.registered'), diagYesNo(injection.registered === true)),
2790
+ diagRow(t('diag.pulls'), String(typeof injection.pulls === 'number' ? injection.pulls : 0)),
2791
+ diagRow(t('diag.chars'),
2792
+ String(typeof injection.chars === 'number' ? injection.chars : 0) + ' / '
2793
+ + String(typeof injection.budget === 'number' ? injection.budget : data.budgetChars))),
2794
+ h('div', { className: 'im-diag-group' },
2795
+ h('span', { className: 'im-section-title im-diag-sub' }, t('diag.groupBackup')),
2796
+ diagRow(t('diag.backupDir'), typeof storage.backupDir === 'string' && storage.backupDir !== ''
2797
+ ? storage.backupDir
2798
+ : t('diag.backupDefault')),
2799
+ diagRow(t('diag.backupPath'), diagValue(storage.backupPath)),
2800
+ diagRow(t('diag.historyDir'), diagValue(storage.historyDir)),
2801
+ diagRow(t('diag.historyCount'), String(typeof storage.historyCount === 'number' ? storage.historyCount : 0)),
2802
+ diagRow(t('diag.schemaFile'), diagValue(storage.schemaFile))),
2803
+ h('div', { className: 'im-diag-group' },
2804
+ h('span', { className: 'im-section-title im-diag-sub' }, t('diag.groupIssues')),
2805
+ diagRow(t('diag.issueStorage'), diagValue(storage.error)),
2806
+ diagRow(t('diag.issueWarning'), diagValue(storage.warning)),
2807
+ diagRow(t('diag.issueHistory'), diagValue(storage.historyError)),
2808
+ diagRow(t('diag.issueSection'), diagValue(injection.error))),
2809
+ diagFallback !== null
2810
+ ? h('div', { className: 'im-diag-fallback' },
2811
+ h('div', { className: 'im-meta' }, t('diag.copyManual')),
2812
+ h('textarea', {
2813
+ className: 'im-textarea',
2814
+ readOnly: true,
2815
+ value: diagFallback,
2816
+ onFocus: (ev) => { if (typeof ev.target.select === 'function') ev.target.select() },
2817
+ }))
2818
+ : null)
2819
+ : null),
2413
2820
  error !== '' ? h('div', { className: 'im-err' }, error) : null,
2414
2821
  error === '' && notice !== '' ? h('div', { className: 'im-ok' }, notice) : null,
2415
2822
  error === '' && notice === '' && storage.error ? h('div', { className: 'im-err' }, storage.error) : null,
@@ -2717,6 +3124,7 @@ window.__ModuleLoader__.load({
2717
3124
  exports.__relevanceTerms = relevanceTerms
2718
3125
  exports.__relevancePreview = relevancePreview
2719
3126
  exports.__sourceParseVerdict = sourceParseVerdict
3127
+ exports.__diagnosticsText = diagnosticsText
2720
3128
  exports.__strings = STRINGS
2721
3129
  exports.__ns = NS
2722
3130
  exports.__dictionaryT = dictionaryT