dsh-instruction-memory 1.4.7 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +154 -0
- package/README.md +117 -19
- package/install.mjs +79 -10
- package/lib/client.js +269 -26
- package/lib/index.js +856 -86
- package/lib/mailer.js +50 -9
- package/package.json +1 -1
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
|
-
|
|
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
|
|
@@ -227,6 +260,7 @@ window.__ModuleLoader__.load({
|
|
|
227
260
|
'panel.title': '指令记忆',
|
|
228
261
|
'panel.subtitle': '保存后自动写入系统提示词,对此后所有对话、问答与任务执行生效。',
|
|
229
262
|
'panel.loading': '正在读取指令记忆…',
|
|
263
|
+
'panel.retry': '重新载入',
|
|
230
264
|
'panel.masterSwitch': '总开关',
|
|
231
265
|
'status.mounted': '已挂载到系统提示词——后续每轮对话自动生效({chars} 字符)',
|
|
232
266
|
'status.notMountedEmpty': '未挂载:当前没有启用中的指令',
|
|
@@ -258,6 +292,7 @@ window.__ModuleLoader__.load({
|
|
|
258
292
|
'editor.cancel': '取消',
|
|
259
293
|
'editor.hint': '保存后对下一轮对话立即生效',
|
|
260
294
|
'editor.needTitleOrContent': '请至少填写标题或指令内容',
|
|
295
|
+
'editor.conflictOverwrite': '这条记忆刚被其他窗口或进程修改过。继续保存会用你当前的编辑内容覆盖对方的修改,确定要继续吗?',
|
|
261
296
|
'editor.confirmDiscard': '当前编辑内容尚未保存,确定放弃这些修改吗?',
|
|
262
297
|
'confirm.unavailable': '当前环境无法弹出确认对话框,操作已取消,未做任何改动。',
|
|
263
298
|
'body.expand': '展开全部',
|
|
@@ -383,6 +418,7 @@ window.__ModuleLoader__.load({
|
|
|
383
418
|
'diag.copied': '诊断信息已复制到剪贴板',
|
|
384
419
|
'diag.copyManual': '自动复制不可用;请手动全选复制下方文本。',
|
|
385
420
|
'diag.hostWarn': '安全提示:设置路由当前绑定在非回环地址({host}),同一网络内的设备可以无鉴权访问此页面,请勿在不可信网络暴露;如需对外发布,请阅读 README 的「经反向代理对外发布」。',
|
|
421
|
+
'diag.hostWarnFence': '安全提示:设置路由当前绑定在非回环地址({host}),但宿主鉴权门已挂载——访问仍需登录 DSH 后的浏览器会话,未登录请求会被 401/403 拒绝;仍建议只在可信网络暴露。',
|
|
386
422
|
'diag.groupStorage': '存储',
|
|
387
423
|
'diag.groupInjection': '注入',
|
|
388
424
|
'diag.groupBackup': '备份与历史',
|
|
@@ -459,7 +495,7 @@ window.__ModuleLoader__.load({
|
|
|
459
495
|
'history.itemMetaLite': '{size} KB(文件较大,未统计条目数)',
|
|
460
496
|
'history.foreign': '来自其他设备({device})',
|
|
461
497
|
'history.restore': '回滚到此版本',
|
|
462
|
-
'history.restoreConfirm': '确定把当前记忆整体回滚到 {time} 的快照(rev {rev})?当前内容仍保留在 memory.json.bak 中,且回滚本身会生成一份新快照,可以再回滚回来。',
|
|
498
|
+
'history.restoreConfirm': '确定把当前记忆整体回滚到 {time} 的快照(rev {rev},文件 {name})?当前内容仍保留在 memory.json.bak 中,且回滚本身会生成一份新快照,可以再回滚回来。',
|
|
463
499
|
'history.delete': '删除',
|
|
464
500
|
'history.deleteConfirm': '确定删除这份历史快照?删除后无法恢复。',
|
|
465
501
|
'history.dir': '快照目录:{dir}',
|
|
@@ -511,6 +547,7 @@ window.__ModuleLoader__.load({
|
|
|
511
547
|
'panel.title': 'Instruction Memory',
|
|
512
548
|
'panel.subtitle': 'Saved instructions are written into the system prompt and apply to every conversation, answer, and task that follows.',
|
|
513
549
|
'panel.loading': 'Loading instruction memory…',
|
|
550
|
+
'panel.retry': 'Reload',
|
|
514
551
|
'panel.masterSwitch': 'Master switch',
|
|
515
552
|
'status.mounted': 'Mounted into the system prompt — effective for every turn that follows ({chars} chars)',
|
|
516
553
|
'status.notMountedEmpty': 'Not mounted: no enabled instructions',
|
|
@@ -542,6 +579,7 @@ window.__ModuleLoader__.load({
|
|
|
542
579
|
'editor.cancel': 'Cancel',
|
|
543
580
|
'editor.hint': 'Takes effect for the next conversation turn once saved',
|
|
544
581
|
'editor.needTitleOrContent': 'Fill in at least a title or the instruction content',
|
|
582
|
+
'editor.conflictOverwrite': 'Another window or process just changed this entry. Saving now replaces their edit with yours. Continue?',
|
|
545
583
|
'editor.confirmDiscard': 'The draft has unsaved changes. Discard them?',
|
|
546
584
|
'confirm.unavailable': 'This shell cannot show a confirmation dialog, so the action was cancelled and nothing changed.',
|
|
547
585
|
'body.expand': 'Show all',
|
|
@@ -667,6 +705,7 @@ window.__ModuleLoader__.load({
|
|
|
667
705
|
'diag.copied': 'Diagnostics copied to the clipboard',
|
|
668
706
|
'diag.copyManual': 'Automatic copy is unavailable; select and copy the text below manually.',
|
|
669
707
|
'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.',
|
|
708
|
+
'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
709
|
'diag.groupStorage': 'Storage',
|
|
671
710
|
'diag.groupInjection': 'Injection',
|
|
672
711
|
'diag.groupBackup': 'Backup & history',
|
|
@@ -743,7 +782,7 @@ window.__ModuleLoader__.load({
|
|
|
743
782
|
'history.itemMetaLite': '{size} KB (file too large to count items)',
|
|
744
783
|
'history.foreign': 'From another device ({device})',
|
|
745
784
|
'history.restore': 'Roll back to this',
|
|
746
|
-
'history.restoreConfirm': 'Roll the whole memory back to the snapshot from {time} (rev {rev})? The current content stays in memory.json.bak, and the rollback itself creates a new snapshot, so you can roll forward again.',
|
|
785
|
+
'history.restoreConfirm': 'Roll the whole memory back to the snapshot from {time} (rev {rev}, file {name})? The current content stays in memory.json.bak, and the rollback itself creates a new snapshot, so you can roll forward again.',
|
|
747
786
|
'history.delete': 'Delete',
|
|
748
787
|
'history.deleteConfirm': 'Delete this snapshot? This cannot be undone.',
|
|
749
788
|
'history.dir': 'Snapshot folder: {dir}',
|
|
@@ -1130,10 +1169,16 @@ window.__ModuleLoader__.load({
|
|
|
1130
1169
|
*/
|
|
1131
1170
|
function normalizeResult(result) {
|
|
1132
1171
|
if (result === null || typeof result !== 'object') return null
|
|
1133
|
-
const
|
|
1172
|
+
const hasEnvelope = result.snapshot !== undefined && result.snapshot !== null
|
|
1173
|
+
const snapshot = hasEnvelope ? result.snapshot : result
|
|
1134
1174
|
// data === null passes a typeof check but poisons every consumer that
|
|
1135
1175
|
// reads data.budgetChars; it is exactly as unusable as a missing data.
|
|
1136
|
-
if (snapshot === null || typeof snapshot !== 'object' ||
|
|
1176
|
+
if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) return null
|
|
1177
|
+
const data = snapshot.data
|
|
1178
|
+
if (data === undefined || data === null || typeof data !== 'object' || Array.isArray(data)) return null
|
|
1179
|
+
// Every consumer reads data.entries and sortEntries() calls .slice() on
|
|
1180
|
+
// it; a non-array threw mid-render instead of surfacing a readable error.
|
|
1181
|
+
if (!Array.isArray(data.entries)) return null
|
|
1137
1182
|
return {
|
|
1138
1183
|
snapshot,
|
|
1139
1184
|
ok: result.ok !== false,
|
|
@@ -1469,7 +1514,7 @@ window.__ModuleLoader__.load({
|
|
|
1469
1514
|
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
1470
1515
|
const timer = controller !== null ? setTimeout(() => controller.abort(), timeoutMs) : null
|
|
1471
1516
|
try {
|
|
1472
|
-
const response = await fetch(
|
|
1517
|
+
const response = await fetch(apiEndpoint(), {
|
|
1473
1518
|
method: 'POST',
|
|
1474
1519
|
headers: { 'content-type': 'application/json' },
|
|
1475
1520
|
body: JSON.stringify({ method, args: args === undefined ? null : args }),
|
|
@@ -1951,6 +1996,14 @@ window.__ModuleLoader__.load({
|
|
|
1951
1996
|
setError(t('err.unrecognized'))
|
|
1952
1997
|
return
|
|
1953
1998
|
}
|
|
1999
|
+
// Out-of-order responses: an older request can resolve after a newer one
|
|
2000
|
+
// (an SSE-triggered refresh racing a POST reply) and would restore a
|
|
2001
|
+
// stale snapshot over fresher data. rev only ever increases, so a
|
|
2002
|
+
// snapshot older than the one already rendered is dropped. Equal revs
|
|
2003
|
+
// are still absorbed — they are the same state.
|
|
2004
|
+
const nextRev = revOfFrom(normalized.snapshot)
|
|
2005
|
+
const currentRev = revOf()
|
|
2006
|
+
if (typeof nextRev === 'number' && typeof currentRev === 'number' && nextRev < currentRev) return
|
|
1954
2007
|
setView(normalized.snapshot)
|
|
1955
2008
|
viewRef.current = normalized.snapshot
|
|
1956
2009
|
// Keep the user's in-progress budget edit when a push arrives while
|
|
@@ -1972,17 +2025,29 @@ window.__ModuleLoader__.load({
|
|
|
1972
2025
|
}
|
|
1973
2026
|
}
|
|
1974
2027
|
|
|
2028
|
+
// A single boolean was cleared by whichever request finished first, which
|
|
2029
|
+
// re-enabled every button while other requests were still in flight.
|
|
2030
|
+
const pendingRef = React.useRef(0)
|
|
2031
|
+
// The panel can be unmounted while a request is in flight; setState after
|
|
2032
|
+
// unmount is a no-op in React 18 but it also loses the error the user
|
|
2033
|
+
// needed to see. Track disposal so late responses are dropped.
|
|
2034
|
+
const disposedRef = React.useRef(false)
|
|
2035
|
+
React.useEffect(() => () => { disposedRef.current = true }, [])
|
|
2036
|
+
|
|
1975
2037
|
async function send(method, args) {
|
|
2038
|
+
pendingRef.current += 1
|
|
1976
2039
|
setBusy(true)
|
|
1977
2040
|
try {
|
|
1978
2041
|
const result = await callHost(method, args)
|
|
2042
|
+
if (disposedRef.current) return null
|
|
1979
2043
|
absorb(result)
|
|
1980
2044
|
return result
|
|
1981
2045
|
} catch (err) {
|
|
1982
|
-
setError(String(err && err.message ? err.message : err))
|
|
2046
|
+
if (!disposedRef.current) setError(String(err && err.message ? err.message : err))
|
|
1983
2047
|
return null
|
|
1984
2048
|
} finally {
|
|
1985
|
-
|
|
2049
|
+
pendingRef.current = Math.max(0, pendingRef.current - 1)
|
|
2050
|
+
if (pendingRef.current === 0 && !disposedRef.current) setBusy(false)
|
|
1986
2051
|
}
|
|
1987
2052
|
}
|
|
1988
2053
|
|
|
@@ -2105,6 +2170,13 @@ window.__ModuleLoader__.load({
|
|
|
2105
2170
|
/** Switch the active set. The one click that changes what is injected. */
|
|
2106
2171
|
function switchSet(id) {
|
|
2107
2172
|
if (busy || id === '') return
|
|
2173
|
+
// A switch closes every open form. The entry editor has always asked
|
|
2174
|
+
// before discarding; the persona editor did not, so a typed persona was
|
|
2175
|
+
// silently discarded while the entry form next to it would have asked.
|
|
2176
|
+
const openProfile = profileDraftRef.current
|
|
2177
|
+
if (openProfile !== null
|
|
2178
|
+
&& profileIsDirty(openProfile, view !== null && Array.isArray(view.data.profiles) ? view.data.profiles : [])
|
|
2179
|
+
&& !confirmDiscard()) return
|
|
2108
2180
|
void send('set-active-set', { id, baseRev: revOf() }).then((result) => {
|
|
2109
2181
|
// A switch invalidates any list selection: the ids belong to the set
|
|
2110
2182
|
// that is no longer displayed.
|
|
@@ -2191,8 +2263,17 @@ window.__ModuleLoader__.load({
|
|
|
2191
2263
|
|
|
2192
2264
|
function rollbackTo(item) {
|
|
2193
2265
|
if (busy) return
|
|
2266
|
+
// Restoring makes the Host read a whole file; a snapshot far larger
|
|
2267
|
+
// than any real store is refused BEFORE the user confirms an operation
|
|
2268
|
+
// that cannot succeed.
|
|
2269
|
+
const sizeVerdict = importSizeVerdict(Number(item.bytes),
|
|
2270
|
+
view !== null && view.limits !== null && typeof view.limits === 'object' ? view.limits.maxBodyChars : undefined)
|
|
2271
|
+
if (!sizeVerdict.ok) {
|
|
2272
|
+
setError(sizeVerdict.message)
|
|
2273
|
+
return
|
|
2274
|
+
}
|
|
2194
2275
|
const when = new Date(item.epoch).toLocaleString()
|
|
2195
|
-
const ask = askConfirm(t('history.restoreConfirm', { time: when, rev: item.rev }))
|
|
2276
|
+
const ask = askConfirm(t('history.restoreConfirm', { time: when, rev: item.rev, name: item.name }))
|
|
2196
2277
|
if (ask !== CONFIRM_YES) {
|
|
2197
2278
|
if (ask === CONFIRM_UNAVAILABLE) setError(t('confirm.unavailable'))
|
|
2198
2279
|
return
|
|
@@ -2342,11 +2423,16 @@ window.__ModuleLoader__.load({
|
|
|
2342
2423
|
function saveSource() {
|
|
2343
2424
|
if (busy) return
|
|
2344
2425
|
void send('write-source', { text: sourceText, baseRev: sourceBaseRev }).then((result) => {
|
|
2345
|
-
if (result === null
|
|
2426
|
+
if (result === null) return
|
|
2427
|
+
// A conflict response carries the FRESH snapshot. Keeping the old
|
|
2428
|
+
// baseRev made every retry conflict again against a rev the Host had
|
|
2429
|
+
// already told us about — an unwinnable loop with no way forward.
|
|
2430
|
+
const freshRev = revOfFrom(result.snapshot)
|
|
2431
|
+
if (typeof freshRev === 'number') setSourceBaseRev(freshRev)
|
|
2432
|
+
if (result.ok !== true) return
|
|
2346
2433
|
// The draft now matches the committed store, so the rev it is based
|
|
2347
2434
|
// on must move with it — otherwise the very next save would be
|
|
2348
2435
|
// refused as a conflict against our own write.
|
|
2349
|
-
setSourceBaseRev(revOfFrom(result.snapshot))
|
|
2350
2436
|
sourceBaselineRef.current = sourceText
|
|
2351
2437
|
void loadHistory()
|
|
2352
2438
|
})
|
|
@@ -2522,7 +2608,7 @@ window.__ModuleLoader__.load({
|
|
|
2522
2608
|
// flag ignores the query string and keeps sending full frames — the
|
|
2523
2609
|
// branch below still accepts those, so the negotiation is additive and
|
|
2524
2610
|
// one-way: neither side has to know the other's version.
|
|
2525
|
-
const source = new EventSource(
|
|
2611
|
+
const source = new EventSource(apiEndpoint('?lite=1'))
|
|
2526
2612
|
let alive = true
|
|
2527
2613
|
let refreshing = false
|
|
2528
2614
|
let missed = false
|
|
@@ -2549,9 +2635,13 @@ window.__ModuleLoader__.load({
|
|
|
2549
2635
|
const pushed = JSON.parse(ev.data)
|
|
2550
2636
|
if (pushed !== null && typeof pushed === 'object' && pushed.lite === true) {
|
|
2551
2637
|
const current = viewRef.current
|
|
2552
|
-
// A null view
|
|
2553
|
-
//
|
|
2554
|
-
|
|
2638
|
+
// A null view used to be ignored here on the assumption that the
|
|
2639
|
+
// initial POST `state` is always in flight. When that request
|
|
2640
|
+
// failed, nothing ever refetched and the panel stayed on its
|
|
2641
|
+
// loading text for the rest of the session. A lite frame proves
|
|
2642
|
+
// the Host is alive, so it is a safe trigger to retry.
|
|
2643
|
+
if (current === null) refresh()
|
|
2644
|
+
else if (current.data !== null && current.data.rev !== pushed.rev) refresh()
|
|
2555
2645
|
return
|
|
2556
2646
|
}
|
|
2557
2647
|
if (sameSnapshot(pushed, viewRef.current)) return
|
|
@@ -2565,7 +2655,11 @@ window.__ModuleLoader__.load({
|
|
|
2565
2655
|
|
|
2566
2656
|
if (view === null) {
|
|
2567
2657
|
return h('div', { className: 'im-root' },
|
|
2568
|
-
h('div', { className: 'im-empty' }, error === '' ? t('panel.loading') : error)
|
|
2658
|
+
h('div', { className: 'im-empty' }, error === '' ? t('panel.loading') : error),
|
|
2659
|
+
error === ''
|
|
2660
|
+
? null
|
|
2661
|
+
: h('div', { className: 'im-row im-row-end' },
|
|
2662
|
+
h('button', { type: 'button', className: 'im-btn', onClick: () => { setError(''); send('state', null) } }, t('panel.retry'))))
|
|
2569
2663
|
}
|
|
2570
2664
|
|
|
2571
2665
|
if (draft !== null) {
|
|
@@ -2583,13 +2677,26 @@ window.__ModuleLoader__.load({
|
|
|
2583
2677
|
setDraft(null); setError('')
|
|
2584
2678
|
},
|
|
2585
2679
|
onSave: () => {
|
|
2680
|
+
if (busy) return
|
|
2586
2681
|
if (draft.title.trim() === '' && draft.content.trim() === '') {
|
|
2587
2682
|
setError(t('editor.needTitleOrContent'))
|
|
2588
2683
|
return
|
|
2589
2684
|
}
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2685
|
+
const commit = (baseRev, allowOverwrite) => {
|
|
2686
|
+
void send('save-entry', { entry: draft, baseRev }).then((result) => {
|
|
2687
|
+
if (result === null) return
|
|
2688
|
+
if (result.ok === true) { setDraft(null); setError(''); return }
|
|
2689
|
+
// The Host refuses a write built on a stale rev and answers
|
|
2690
|
+
// with the fresh snapshot (already absorbed into the view).
|
|
2691
|
+
// Retrying on our own would replace the other window's edit
|
|
2692
|
+
// with this draft — a silent overwrite. Ask first, and only
|
|
2693
|
+
// then retry against the rev we just learned.
|
|
2694
|
+
if (result.conflict === true && !allowOverwrite) {
|
|
2695
|
+
if (askConfirm(t('editor.conflictOverwrite'), 'allow') === CONFIRM_YES) commit(revOf(), true)
|
|
2696
|
+
}
|
|
2697
|
+
})
|
|
2698
|
+
}
|
|
2699
|
+
commit(revOf(), false)
|
|
2593
2700
|
},
|
|
2594
2701
|
}),
|
|
2595
2702
|
// The editor is an early return, so it must render its OWN error
|
|
@@ -2875,9 +2982,15 @@ window.__ModuleLoader__.load({
|
|
|
2875
2982
|
feedbackCard,
|
|
2876
2983
|
// F12: the non-loopback warning stays high in the panel — directly
|
|
2877
2984
|
// under the feedback entry — so it cannot be missed.
|
|
2985
|
+
// Two different situations, two different texts: with the host fence
|
|
2986
|
+
// attached the route is NOT open to the LAN (it needs a signed-in
|
|
2987
|
+
// browser session), so claiming "no authentication" would be a false
|
|
2988
|
+
// alarm. `authFence` is what the route itself checked on this request.
|
|
2878
2989
|
security.hostGuarded === false
|
|
2879
2990
|
? h('div', { className: 'im-hostwarn', key: 'hostwarn' },
|
|
2880
|
-
|
|
2991
|
+
security.authFence === true
|
|
2992
|
+
? t('diag.hostWarnFence', { host: security.boundHost !== '' ? security.boundHost : '?' })
|
|
2993
|
+
: t('diag.hostWarn', { host: security.boundHost !== '' ? security.boundHost : '?' }))
|
|
2881
2994
|
: null,
|
|
2882
2995
|
h('div', { className: 'im-row' },
|
|
2883
2996
|
h('div', null,
|
|
@@ -3666,7 +3779,18 @@ window.__ModuleLoader__.load({
|
|
|
3666
3779
|
// published before or after this plugin activates.
|
|
3667
3780
|
runtimeCtx = ctx
|
|
3668
3781
|
|
|
3669
|
-
const resolveSlots = () =>
|
|
3782
|
+
const resolveSlots = () => {
|
|
3783
|
+
// ctx.get() can throw on a disposed context, and this call sits outside
|
|
3784
|
+
// safeMount's try — an escaping throw here killed the retry timer with
|
|
3785
|
+
// no message anywhere.
|
|
3786
|
+
try {
|
|
3787
|
+
return ctx.slots !== undefined ? ctx.slots : ctx.get('slots')
|
|
3788
|
+
} catch (err) {
|
|
3789
|
+
console.error('[instruction-memory] 读取 slots 服务失败:'
|
|
3790
|
+
+ (err !== null && err !== undefined && typeof err.message === 'string' ? err.message : String(err)))
|
|
3791
|
+
return undefined
|
|
3792
|
+
}
|
|
3793
|
+
}
|
|
3670
3794
|
|
|
3671
3795
|
// A retry can fire after the fiber was disposed (plugin disabled, host
|
|
3672
3796
|
// shutting down); registering into a dead context throws, and an uncaught
|
|
@@ -3676,7 +3800,12 @@ window.__ModuleLoader__.load({
|
|
|
3676
3800
|
try {
|
|
3677
3801
|
mountPanel(ctx, target)
|
|
3678
3802
|
return true
|
|
3679
|
-
} catch {
|
|
3803
|
+
} catch (err) {
|
|
3804
|
+
// F19: this used to swallow the throw outright — the panel simply
|
|
3805
|
+
// never appeared and nothing anywhere said so.
|
|
3806
|
+
const detail = '注册设置页时抛出异常:' + (err !== null && err !== undefined && typeof err.message === 'string' ? err.message : String(err))
|
|
3807
|
+
console.error('[instruction-memory] ' + detail)
|
|
3808
|
+
showMountFailureNotice(detail)
|
|
3680
3809
|
return false
|
|
3681
3810
|
}
|
|
3682
3811
|
}
|
|
@@ -3696,10 +3825,10 @@ window.__ModuleLoader__.load({
|
|
|
3696
3825
|
return
|
|
3697
3826
|
}
|
|
3698
3827
|
if (attempt >= SLOT_RETRY_LIMIT) {
|
|
3699
|
-
|
|
3700
|
-
'
|
|
3701
|
-
|
|
3702
|
-
)
|
|
3828
|
+
const detail = '宿主没有发布 slots 服务(重试 '
|
|
3829
|
+
+ SLOT_RETRY_LIMIT + ' 次,约 ' + (SLOT_RETRY_LIMIT * SLOT_RETRY_DELAY_MS) + 'ms)'
|
|
3830
|
+
console.error('[instruction-memory] ' + detail + ';settings page not registered')
|
|
3831
|
+
showMountFailureNotice(detail)
|
|
3703
3832
|
return
|
|
3704
3833
|
}
|
|
3705
3834
|
schedule(retry)
|
|
@@ -3707,6 +3836,120 @@ window.__ModuleLoader__.load({
|
|
|
3707
3836
|
schedule(retry)
|
|
3708
3837
|
}
|
|
3709
3838
|
|
|
3839
|
+
/**
|
|
3840
|
+
* R8: read the running plugin version for the mount-failure notice.
|
|
3841
|
+
*
|
|
3842
|
+
* The settings route does not depend on `slots`, so `state` normally
|
|
3843
|
+
* answers even when the panel could not mount — which is exactly the
|
|
3844
|
+
* moment someone copies a version number into a bug report. Best effort in
|
|
3845
|
+
* every direction: no `fetch`, no route, a refusal by the host's auth
|
|
3846
|
+
* gate, a timeout or a malformed body all resolve to null, and the notice
|
|
3847
|
+
* simply omits the line.
|
|
3848
|
+
*/
|
|
3849
|
+
function probePluginVersion() {
|
|
3850
|
+
try {
|
|
3851
|
+
if (typeof fetch !== 'function') return Promise.resolve(null)
|
|
3852
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
3853
|
+
const options = {
|
|
3854
|
+
method: 'POST',
|
|
3855
|
+
headers: { 'content-type': 'application/json' },
|
|
3856
|
+
body: JSON.stringify({ method: 'state', args: null }),
|
|
3857
|
+
}
|
|
3858
|
+
if (controller !== null) {
|
|
3859
|
+
options.signal = controller.signal
|
|
3860
|
+
const timer = setTimeout(() => controller.abort(), 2000)
|
|
3861
|
+
if (timer !== null && typeof timer === 'object' && typeof timer.unref === 'function') timer.unref()
|
|
3862
|
+
}
|
|
3863
|
+
return fetch(apiEndpoint(), options)
|
|
3864
|
+
.then((response) => (response.ok === true ? response.json() : null))
|
|
3865
|
+
.then((payload) => (payload !== null && payload !== undefined
|
|
3866
|
+
&& payload.ok === true
|
|
3867
|
+
&& payload.snapshot !== null && typeof payload.snapshot === 'object'
|
|
3868
|
+
&& typeof payload.snapshot.pluginVersion === 'string'
|
|
3869
|
+
&& payload.snapshot.pluginVersion !== ''
|
|
3870
|
+
? payload.snapshot.pluginVersion
|
|
3871
|
+
: null))
|
|
3872
|
+
.catch(() => null)
|
|
3873
|
+
} catch {
|
|
3874
|
+
return Promise.resolve(null)
|
|
3875
|
+
}
|
|
3876
|
+
}
|
|
3877
|
+
|
|
3878
|
+
/**
|
|
3879
|
+
* F19: make a failed mount VISIBLE instead of console-only.
|
|
3880
|
+
*
|
|
3881
|
+
* The panel is how the user sees and edits their memory. When `slots` never
|
|
3882
|
+
* resolves, the previous code wrote one console.error: the plugin looked
|
|
3883
|
+
* installed, the settings page had no section, and nothing told the user
|
|
3884
|
+
* why. A console line is not a user interface.
|
|
3885
|
+
*
|
|
3886
|
+
* The notice is deliberately dependency-free — plain DOM, inline styles,
|
|
3887
|
+
* `textContent` only (never innerHTML) — because the very thing that failed
|
|
3888
|
+
* is the service that would supply a styled component. It is non-modal and
|
|
3889
|
+
* self-removing so it cannot become a permanent artefact of a transient
|
|
3890
|
+
* race (a late `slots` publish still mounts the panel; the banner then
|
|
3891
|
+
* only says the panel appeared late).
|
|
3892
|
+
*/
|
|
3893
|
+
function showMountFailureNotice(detail) {
|
|
3894
|
+
try {
|
|
3895
|
+
if (typeof document === 'undefined' || document.body === null) return
|
|
3896
|
+
const id = 'dsh-instruction-memory-mount-failure'
|
|
3897
|
+
const existing = document.getElementById(id)
|
|
3898
|
+
if (existing !== null) existing.remove()
|
|
3899
|
+
|
|
3900
|
+
const box = document.createElement('div')
|
|
3901
|
+
box.id = id
|
|
3902
|
+
box.setAttribute('role', 'status')
|
|
3903
|
+
box.style.cssText = [
|
|
3904
|
+
'position:fixed', 'right:16px', 'bottom:16px', 'z-index:2147483000',
|
|
3905
|
+
'max-width:min(380px,calc(100vw - 32px))',
|
|
3906
|
+
'background:#3a1d1d', 'color:#ffe8e8', 'border:1px solid #7a3a3a',
|
|
3907
|
+
'border-radius:10px', 'padding:12px 14px',
|
|
3908
|
+
'font:13px/1.5 system-ui,-apple-system,Segoe UI,sans-serif',
|
|
3909
|
+
'box-shadow:0 8px 24px rgba(0,0,0,.45)',
|
|
3910
|
+
].join(';')
|
|
3911
|
+
|
|
3912
|
+
const title = document.createElement('div')
|
|
3913
|
+
title.style.cssText = 'font-weight:600;margin-bottom:4px'
|
|
3914
|
+
title.textContent = '指令记忆:设置页挂载失败'
|
|
3915
|
+
box.appendChild(title)
|
|
3916
|
+
|
|
3917
|
+
const body = document.createElement('div')
|
|
3918
|
+
body.textContent = detail
|
|
3919
|
+
box.appendChild(body)
|
|
3920
|
+
|
|
3921
|
+
const hint = document.createElement('div')
|
|
3922
|
+
hint.style.cssText = 'margin-top:6px;opacity:.75'
|
|
3923
|
+
hint.textContent = '记忆注入本身仍在工作;请检查宿主版本与插件日志,或重装插件后刷新页面。'
|
|
3924
|
+
box.appendChild(hint)
|
|
3925
|
+
|
|
3926
|
+
const close = document.createElement('button')
|
|
3927
|
+
close.type = 'button'
|
|
3928
|
+
close.textContent = '关闭'
|
|
3929
|
+
close.style.cssText = 'margin-top:8px;background:transparent;color:#ffe8e8;border:1px solid #7a3a3a;border-radius:6px;padding:2px 10px;cursor:pointer;font:inherit'
|
|
3930
|
+
close.addEventListener('click', () => box.remove())
|
|
3931
|
+
box.appendChild(close)
|
|
3932
|
+
|
|
3933
|
+
document.body.appendChild(box)
|
|
3934
|
+
// R8: name the running version. Appended after the banner exists so a
|
|
3935
|
+
// slow or refused probe can never delay the warning itself.
|
|
3936
|
+
probePluginVersion().then((version) => {
|
|
3937
|
+
if (version === null) return
|
|
3938
|
+
const meta = document.createElement('div')
|
|
3939
|
+
meta.style.cssText = 'margin-top:6px;opacity:.75'
|
|
3940
|
+
meta.textContent = '插件版本 ' + version
|
|
3941
|
+
box.appendChild(meta)
|
|
3942
|
+
}).catch(() => {})
|
|
3943
|
+
// Non-modal and self-removing: a failure that persists past 30 s has
|
|
3944
|
+
// already been reported once; leaving a banner on screen forever is
|
|
3945
|
+
// worse than a dismissible one the user can re-trigger by reloading.
|
|
3946
|
+
const timer = setTimeout(() => box.remove(), 30000)
|
|
3947
|
+
if (timer !== null && typeof timer === 'object' && typeof timer.unref === 'function') timer.unref()
|
|
3948
|
+
} catch {
|
|
3949
|
+
// A banner is a courtesy: never let it be the reason the plugin throws.
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
|
|
3710
3953
|
function schedule(fn) {
|
|
3711
3954
|
const timer = setTimeout(fn, SLOT_RETRY_DELAY_MS)
|
|
3712
3955
|
// Never hold a process open just to finish retrying.
|