dsh-instruction-memory 1.4.7 → 1.4.8

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
@@ -20,7 +20,40 @@ window.__ModuleLoader__.load({
20
20
  const React = require('react')
21
21
  const h = React.createElement
22
22
 
23
- const API = '/instruction-memory/api'
23
+ /**
24
+ * F19: the route path RESOLVED against the page, not assumed to be at root.
25
+ *
26
+ * `/instruction-memory/api` is correct only when the GUI is served from `/`.
27
+ * Behind a reverse proxy that mounts the app under a prefix (`/ui/`) the
28
+ * browser posts to the proxy root instead, and every request 404s even
29
+ * though the panel rendered. The harness itself resolves its own client
30
+ * paths the same way (`new URL(path.slice(1), document.baseURI)`), so this
31
+ * follows the page wherever it is served from.
32
+ *
33
+ * Computed per call, not once at load: a test harness (or a page that
34
+ * rewrites `<base>`) can establish the base after this module evaluates.
35
+ *
36
+ * @param {string} [suffix] query string to append, e.g. `?lite=1`.
37
+ * @returns {string} absolute or root-relative URL for the route.
38
+ */
39
+ const apiEndpoint = (suffix) => {
40
+ const tail = typeof suffix === 'string' ? suffix : ''
41
+ const path = 'instruction-memory/api'
42
+ if (typeof document === 'undefined' || typeof document.baseURI !== 'string' || document.baseURI === '') {
43
+ return '/' + path + tail
44
+ }
45
+ try {
46
+ const base = new URL(document.baseURI)
47
+ // A base of `https://host/ui` (no trailing slash) would otherwise
48
+ // resolve against `/` — `ui` is the mount point itself, so treat it as
49
+ // the directory the app lives in.
50
+ if (!base.pathname.endsWith('/')) base.pathname += '/'
51
+ const url = new URL(path, base)
52
+ return url.pathname + url.search + tail
53
+ } catch {
54
+ return '/' + path + tail
55
+ }
56
+ }
24
57
 
25
58
  // The Host bounds one delivery at 60 s (lib/mailer.js SMTP_TOTAL_TIMEOUT_MS).
26
59
  // Waiting longer than the generic 15 s keeps the panel honest: aborting while
@@ -383,6 +416,7 @@ window.__ModuleLoader__.load({
383
416
  'diag.copied': '诊断信息已复制到剪贴板',
384
417
  'diag.copyManual': '自动复制不可用;请手动全选复制下方文本。',
385
418
  'diag.hostWarn': '安全提示:设置路由当前绑定在非回环地址({host}),同一网络内的设备可以无鉴权访问此页面,请勿在不可信网络暴露;如需对外发布,请阅读 README 的「经反向代理对外发布」。',
419
+ 'diag.hostWarnFence': '安全提示:设置路由当前绑定在非回环地址({host}),但宿主鉴权门已挂载——访问仍需登录 DSH 后的浏览器会话,未登录请求会被 401/403 拒绝;仍建议只在可信网络暴露。',
386
420
  'diag.groupStorage': '存储',
387
421
  'diag.groupInjection': '注入',
388
422
  'diag.groupBackup': '备份与历史',
@@ -667,6 +701,7 @@ window.__ModuleLoader__.load({
667
701
  'diag.copied': 'Diagnostics copied to the clipboard',
668
702
  'diag.copyManual': 'Automatic copy is unavailable; select and copy the text below manually.',
669
703
  '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.',
704
+ 'diag.hostWarnFence': 'Security notice: the settings route is bound to a non-loopback address ({host}), but the host authentication fence is attached — access still requires a signed-in DSH browser session, and unauthenticated requests are refused with 401/403. Keep it on a trusted network anyway.',
670
705
  'diag.groupStorage': 'Storage',
671
706
  'diag.groupInjection': 'Injection',
672
707
  'diag.groupBackup': 'Backup & history',
@@ -1469,7 +1504,7 @@ window.__ModuleLoader__.load({
1469
1504
  const controller = typeof AbortController === 'function' ? new AbortController() : null
1470
1505
  const timer = controller !== null ? setTimeout(() => controller.abort(), timeoutMs) : null
1471
1506
  try {
1472
- const response = await fetch(API, {
1507
+ const response = await fetch(apiEndpoint(), {
1473
1508
  method: 'POST',
1474
1509
  headers: { 'content-type': 'application/json' },
1475
1510
  body: JSON.stringify({ method, args: args === undefined ? null : args }),
@@ -2522,7 +2557,7 @@ window.__ModuleLoader__.load({
2522
2557
  // flag ignores the query string and keeps sending full frames — the
2523
2558
  // branch below still accepts those, so the negotiation is additive and
2524
2559
  // one-way: neither side has to know the other's version.
2525
- const source = new EventSource(API + '?lite=1')
2560
+ const source = new EventSource(apiEndpoint('?lite=1'))
2526
2561
  let alive = true
2527
2562
  let refreshing = false
2528
2563
  let missed = false
@@ -2875,9 +2910,15 @@ window.__ModuleLoader__.load({
2875
2910
  feedbackCard,
2876
2911
  // F12: the non-loopback warning stays high in the panel — directly
2877
2912
  // under the feedback entry — so it cannot be missed.
2913
+ // Two different situations, two different texts: with the host fence
2914
+ // attached the route is NOT open to the LAN (it needs a signed-in
2915
+ // browser session), so claiming "no authentication" would be a false
2916
+ // alarm. `authFence` is what the route itself checked on this request.
2878
2917
  security.hostGuarded === false
2879
2918
  ? h('div', { className: 'im-hostwarn', key: 'hostwarn' },
2880
- t('diag.hostWarn', { host: security.boundHost !== '' ? security.boundHost : '?' }))
2919
+ security.authFence === true
2920
+ ? t('diag.hostWarnFence', { host: security.boundHost !== '' ? security.boundHost : '?' })
2921
+ : t('diag.hostWarn', { host: security.boundHost !== '' ? security.boundHost : '?' }))
2881
2922
  : null,
2882
2923
  h('div', { className: 'im-row' },
2883
2924
  h('div', null,
@@ -3676,7 +3717,12 @@ window.__ModuleLoader__.load({
3676
3717
  try {
3677
3718
  mountPanel(ctx, target)
3678
3719
  return true
3679
- } catch {
3720
+ } catch (err) {
3721
+ // F19: this used to swallow the throw outright — the panel simply
3722
+ // never appeared and nothing anywhere said so.
3723
+ const detail = '注册设置页时抛出异常:' + (err !== null && err !== undefined && typeof err.message === 'string' ? err.message : String(err))
3724
+ console.error('[instruction-memory] ' + detail)
3725
+ showMountFailureNotice(detail)
3680
3726
  return false
3681
3727
  }
3682
3728
  }
@@ -3696,10 +3742,10 @@ window.__ModuleLoader__.load({
3696
3742
  return
3697
3743
  }
3698
3744
  if (attempt >= SLOT_RETRY_LIMIT) {
3699
- console.error(
3700
- '[instruction-memory] slots service unavailable after '
3701
- + (SLOT_RETRY_LIMIT * SLOT_RETRY_DELAY_MS) + 'ms of retries; settings page not registered',
3702
- )
3745
+ const detail = '宿主没有发布 slots 服务(重试 '
3746
+ + SLOT_RETRY_LIMIT + ' 次,约 ' + (SLOT_RETRY_LIMIT * SLOT_RETRY_DELAY_MS) + 'ms)'
3747
+ console.error('[instruction-memory] ' + detail + ';settings page not registered')
3748
+ showMountFailureNotice(detail)
3703
3749
  return
3704
3750
  }
3705
3751
  schedule(retry)
@@ -3707,6 +3753,120 @@ window.__ModuleLoader__.load({
3707
3753
  schedule(retry)
3708
3754
  }
3709
3755
 
3756
+ /**
3757
+ * R8: read the running plugin version for the mount-failure notice.
3758
+ *
3759
+ * The settings route does not depend on `slots`, so `state` normally
3760
+ * answers even when the panel could not mount — which is exactly the
3761
+ * moment someone copies a version number into a bug report. Best effort in
3762
+ * every direction: no `fetch`, no route, a refusal by the host's auth
3763
+ * gate, a timeout or a malformed body all resolve to null, and the notice
3764
+ * simply omits the line.
3765
+ */
3766
+ function probePluginVersion() {
3767
+ try {
3768
+ if (typeof fetch !== 'function') return Promise.resolve(null)
3769
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
3770
+ const options = {
3771
+ method: 'POST',
3772
+ headers: { 'content-type': 'application/json' },
3773
+ body: JSON.stringify({ method: 'state', args: null }),
3774
+ }
3775
+ if (controller !== null) {
3776
+ options.signal = controller.signal
3777
+ const timer = setTimeout(() => controller.abort(), 2000)
3778
+ if (timer !== null && typeof timer === 'object' && typeof timer.unref === 'function') timer.unref()
3779
+ }
3780
+ return fetch(apiEndpoint(), options)
3781
+ .then((response) => (response.ok === true ? response.json() : null))
3782
+ .then((payload) => (payload !== null && payload !== undefined
3783
+ && payload.ok === true
3784
+ && payload.snapshot !== null && typeof payload.snapshot === 'object'
3785
+ && typeof payload.snapshot.pluginVersion === 'string'
3786
+ && payload.snapshot.pluginVersion !== ''
3787
+ ? payload.snapshot.pluginVersion
3788
+ : null))
3789
+ .catch(() => null)
3790
+ } catch {
3791
+ return Promise.resolve(null)
3792
+ }
3793
+ }
3794
+
3795
+ /**
3796
+ * F19: make a failed mount VISIBLE instead of console-only.
3797
+ *
3798
+ * The panel is how the user sees and edits their memory. When `slots` never
3799
+ * resolves, the previous code wrote one console.error: the plugin looked
3800
+ * installed, the settings page had no section, and nothing told the user
3801
+ * why. A console line is not a user interface.
3802
+ *
3803
+ * The notice is deliberately dependency-free — plain DOM, inline styles,
3804
+ * `textContent` only (never innerHTML) — because the very thing that failed
3805
+ * is the service that would supply a styled component. It is non-modal and
3806
+ * self-removing so it cannot become a permanent artefact of a transient
3807
+ * race (a late `slots` publish still mounts the panel; the banner then
3808
+ * only says the panel appeared late).
3809
+ */
3810
+ function showMountFailureNotice(detail) {
3811
+ try {
3812
+ if (typeof document === 'undefined' || document.body === null) return
3813
+ const id = 'dsh-instruction-memory-mount-failure'
3814
+ const existing = document.getElementById(id)
3815
+ if (existing !== null) existing.remove()
3816
+
3817
+ const box = document.createElement('div')
3818
+ box.id = id
3819
+ box.setAttribute('role', 'status')
3820
+ box.style.cssText = [
3821
+ 'position:fixed', 'right:16px', 'bottom:16px', 'z-index:2147483000',
3822
+ 'max-width:min(380px,calc(100vw - 32px))',
3823
+ 'background:#3a1d1d', 'color:#ffe8e8', 'border:1px solid #7a3a3a',
3824
+ 'border-radius:10px', 'padding:12px 14px',
3825
+ 'font:13px/1.5 system-ui,-apple-system,Segoe UI,sans-serif',
3826
+ 'box-shadow:0 8px 24px rgba(0,0,0,.45)',
3827
+ ].join(';')
3828
+
3829
+ const title = document.createElement('div')
3830
+ title.style.cssText = 'font-weight:600;margin-bottom:4px'
3831
+ title.textContent = '指令记忆:设置页挂载失败'
3832
+ box.appendChild(title)
3833
+
3834
+ const body = document.createElement('div')
3835
+ body.textContent = detail
3836
+ box.appendChild(body)
3837
+
3838
+ const hint = document.createElement('div')
3839
+ hint.style.cssText = 'margin-top:6px;opacity:.75'
3840
+ hint.textContent = '记忆注入本身仍在工作;请检查宿主版本与插件日志,或重装插件后刷新页面。'
3841
+ box.appendChild(hint)
3842
+
3843
+ const close = document.createElement('button')
3844
+ close.type = 'button'
3845
+ close.textContent = '关闭'
3846
+ close.style.cssText = 'margin-top:8px;background:transparent;color:#ffe8e8;border:1px solid #7a3a3a;border-radius:6px;padding:2px 10px;cursor:pointer;font:inherit'
3847
+ close.addEventListener('click', () => box.remove())
3848
+ box.appendChild(close)
3849
+
3850
+ document.body.appendChild(box)
3851
+ // R8: name the running version. Appended after the banner exists so a
3852
+ // slow or refused probe can never delay the warning itself.
3853
+ probePluginVersion().then((version) => {
3854
+ if (version === null) return
3855
+ const meta = document.createElement('div')
3856
+ meta.style.cssText = 'margin-top:6px;opacity:.75'
3857
+ meta.textContent = '插件版本 ' + version
3858
+ box.appendChild(meta)
3859
+ }).catch(() => {})
3860
+ // Non-modal and self-removing: a failure that persists past 30 s has
3861
+ // already been reported once; leaving a banner on screen forever is
3862
+ // worse than a dismissible one the user can re-trigger by reloading.
3863
+ const timer = setTimeout(() => box.remove(), 30000)
3864
+ if (timer !== null && typeof timer === 'object' && typeof timer.unref === 'function') timer.unref()
3865
+ } catch {
3866
+ // A banner is a courtesy: never let it be the reason the plugin throws.
3867
+ }
3868
+ }
3869
+
3710
3870
  function schedule(fn) {
3711
3871
  const timer = setTimeout(fn, SLOT_RETRY_DELAY_MS)
3712
3872
  // Never hold a process open just to finish retrying.