dsh-memento 0.4.2 → 0.4.4
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/ARCHITECTURE.md +177 -177
- package/CHANGELOG.md +118 -104
- package/README.es.md +2 -2
- package/README.hi.md +2 -2
- package/README.md +3 -2
- package/README.pt.md +2 -2
- package/README.zh.md +2 -2
- package/client/client.js +241 -241
- package/index.mjs +1743 -1739
- package/lib/constants.mjs +68 -68
- package/lib/store.mjs +717 -717
- package/package.json +153 -153
- package/types.d.ts +238 -237
package/client/client.js
CHANGED
|
@@ -1,241 +1,241 @@
|
|
|
1
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// client/client.js — dsh-memento 浏览器观察面板(F9,零构建 vanilla)。
|
|
3
|
-
//
|
|
4
|
-
// host 端 dsh.client 扫描把本文件作为 classic script 注入 __DSH_BOOT__ 图,
|
|
5
|
-
// 执行时经 window.__ModuleLoader__.load 注册工厂;apply 挂载浮层抽屉面板。
|
|
6
|
-
// 面板只读:条目浏览/搜索/预算条/审计尾,全部走本插件自注册的
|
|
7
|
-
// /api/memento/* JSON 路由(只走公开 API)。写与审批在 DSH 内置审批 UI 完成,
|
|
8
|
-
// 面板不产生任何模型可见内容、不做任何审批决策。
|
|
9
|
-
// 面板文案随 Config.language(en/zh)切换,语言来自 entries 路由响应。
|
|
10
|
-
|
|
11
|
-
(function () {
|
|
12
|
-
'use strict'
|
|
13
|
-
if (typeof window === 'undefined' || !window.__ModuleLoader__ || !window.__ModuleLoader__.load) return
|
|
14
|
-
window.__ModuleLoader__.load({
|
|
15
|
-
id: 'dsh-memento',
|
|
16
|
-
factory: function () {
|
|
17
|
-
return {
|
|
18
|
-
name: 'memento-panel',
|
|
19
|
-
inject: [],
|
|
20
|
-
apply: function () { installPanel() },
|
|
21
|
-
}
|
|
22
|
-
},
|
|
23
|
-
})
|
|
24
|
-
})()
|
|
25
|
-
|
|
26
|
-
const PANEL_ID = 'dsh-memento-panel'
|
|
27
|
-
|
|
28
|
-
/** 面板文案(en 源文 / zh 译文;语言来自 /api/memento/entries 响应的 language 字段,缺省 en)。 */
|
|
29
|
-
const STRINGS = {
|
|
30
|
-
en: {
|
|
31
|
-
open: '🧠 Memory',
|
|
32
|
-
title: 'dsh-memento memory',
|
|
33
|
-
refresh: 'Refresh',
|
|
34
|
-
close: 'Close',
|
|
35
|
-
filter: 'Filter entries by text…',
|
|
36
|
-
empty: 'Memory is empty (or nothing matches the filter). Use the memory tool to write (approval happens in the built-in approval UI).',
|
|
37
|
-
truncated: (shown, total) => `Showing the first ${shown} of ${total} entries — narrow the filter to see more.`,
|
|
38
|
-
groupCount: (n) => `(${n})`,
|
|
39
|
-
budgets: 'Budget usage',
|
|
40
|
-
audit: 'Recent audit',
|
|
41
|
-
loading: 'Loading…',
|
|
42
|
-
auditEmpty: 'Audit is empty',
|
|
43
|
-
proposals: 'Pending proposals',
|
|
44
|
-
proposalsEmpty: 'No pending proposals (generated after session compaction; decide via /memory proposals approve|dismiss)',
|
|
45
|
-
loadFailed: (message) => `Load failed: ${message} (panel is read-only; make sure the Web profile has dsh-memento loaded)`,
|
|
46
|
-
},
|
|
47
|
-
zh: {
|
|
48
|
-
open: '🧠 记忆',
|
|
49
|
-
title: 'dsh-memento 记忆',
|
|
50
|
-
refresh: '刷新',
|
|
51
|
-
close: '关闭',
|
|
52
|
-
filter: '按文本过滤条目…',
|
|
53
|
-
empty: '记忆为空(或没有匹配当前过滤)。写操作请用 memory 工具(审批在 DS 内置审批 UI 完成)。',
|
|
54
|
-
truncated: (shown, total) => `仅显示前 ${shown} 条,共 ${total} 条——用过滤框缩小范围。`,
|
|
55
|
-
groupCount: (n) => `(${n} 条)`,
|
|
56
|
-
budgets: '预算用量',
|
|
57
|
-
audit: '最近审计',
|
|
58
|
-
loading: '加载中…',
|
|
59
|
-
auditEmpty: '审计为空',
|
|
60
|
-
proposals: '待审批提案',
|
|
61
|
-
proposalsEmpty: '暂无待审批提案(会话压缩后自动生成;用 /memory proposals approve|dismiss 处理)',
|
|
62
|
-
loadFailed: (message) => `加载失败:${message}(面板只读;请确认 Web profile 已装载 dsh-memento)`,
|
|
63
|
-
},
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function installPanel() {
|
|
67
|
-
if (document.getElementById(PANEL_ID)) return
|
|
68
|
-
let S = STRINGS.en
|
|
69
|
-
|
|
70
|
-
const style = document.createElement('style')
|
|
71
|
-
style.textContent = `
|
|
72
|
-
#dsh-memento-panel { position: fixed; z-index: 2147483000; font: 13px/1.5 system-ui, "Segoe UI", sans-serif; }
|
|
73
|
-
#mem-open { position: fixed; right: 16px; bottom: 56px; z-index: 2147483000; padding: 8px 14px; border: 1px solid #3f6fae;
|
|
74
|
-
border-radius: 999px; background: #0d1526; color: #7db4ff; cursor: pointer; box-shadow: 0 4px 14px rgba(0,0,0,.35); }
|
|
75
|
-
#mem-open:hover { background: #16233c; }
|
|
76
|
-
#mem-drawer { position: fixed; right: 0; top: 0; bottom: 0; width: 460px; max-width: 92vw; display: flex; flex-direction: column;
|
|
77
|
-
background: #101827; color: #d7e2f2; border-left: 1px solid #24344d; box-shadow: -8px 0 24px rgba(0,0,0,.4); }
|
|
78
|
-
#mem-head { padding: 10px 12px; border-bottom: 1px solid #24344d; display: flex; gap: 8px; align-items: center; }
|
|
79
|
-
#mem-head b { flex: 1; }
|
|
80
|
-
#mem-head button { background: #1c2a42; color: #cfe1f7; border: 1px solid #2f4466; border-radius: 6px; padding: 4px 10px; cursor: pointer; }
|
|
81
|
-
#mem-head button:hover { background: #27395a; }
|
|
82
|
-
#mem-filter { margin: 8px 12px; padding: 6px 8px; background: #0b1120; color: #d7e2f2; border: 1px solid #24344d; border-radius: 6px; }
|
|
83
|
-
#mem-body { flex: 1; overflow: auto; padding: 0 12px 12px; }
|
|
84
|
-
.mem-group { margin: 10px 0 4px; color: #8fb4e8; }
|
|
85
|
-
.mem-entry { padding: 6px 8px; margin: 3px 0; border: 1px solid #24344d; border-radius: 8px; background: #131e33; }
|
|
86
|
-
.mem-entry .t { display: block; color: #e6eefb; }
|
|
87
|
-
.mem-entry .m { display: block; color: #9fb4d4; font-size: 11px; }
|
|
88
|
-
.mem-bar { height: 6px; margin: 6px 0 2px; background: #0b1120; border-radius: 3px; overflow: hidden; border: 1px solid #24344d; }
|
|
89
|
-
.mem-bar i { display: block; height: 100%; background: #3f6fae; }
|
|
90
|
-
.mem-row { margin: 6px 0; color: #9fb4d4; }
|
|
91
|
-
.mem-audit { margin: 6px 0; padding: 4px 8px; border-left: 2px solid #3f6fae; color: #9fb4d4; font-size: 12px; }
|
|
92
|
-
.mem-empty { color: #8fa8cc; margin: 10px 0; }
|
|
93
|
-
`
|
|
94
|
-
|
|
95
|
-
const openBtn = document.createElement('button')
|
|
96
|
-
openBtn.id = 'mem-open'
|
|
97
|
-
openBtn.textContent = S.open
|
|
98
|
-
|
|
99
|
-
const root = document.createElement('div')
|
|
100
|
-
root.id = PANEL_ID
|
|
101
|
-
const drawer = document.createElement('div')
|
|
102
|
-
drawer.id = 'mem-drawer'
|
|
103
|
-
drawer.style.display = 'none'
|
|
104
|
-
drawer.innerHTML = `
|
|
105
|
-
<div id="mem-head"><b id="mem-title">${S.title}</b>
|
|
106
|
-
<button id="mem-refresh" title="${S.refresh}">${S.refresh}</button>
|
|
107
|
-
<button id="mem-close" title="${S.close}">✕</button>
|
|
108
|
-
</div>
|
|
109
|
-
<input id="mem-filter" placeholder="${S.filter}" />
|
|
110
|
-
<div id="mem-body"></div>
|
|
111
|
-
`
|
|
112
|
-
root.appendChild(openBtn)
|
|
113
|
-
root.appendChild(drawer)
|
|
114
|
-
document.body.appendChild(root)
|
|
115
|
-
document.head.appendChild(style)
|
|
116
|
-
|
|
117
|
-
const body = document.getElementById('mem-body')
|
|
118
|
-
const filter = document.getElementById('mem-filter')
|
|
119
|
-
const titleLabel = document.getElementById('mem-title')
|
|
120
|
-
const refreshBtn = document.getElementById('mem-refresh')
|
|
121
|
-
const closeBtn = document.getElementById('mem-close')
|
|
122
|
-
const filterInput = document.getElementById('mem-filter')
|
|
123
|
-
|
|
124
|
-
/** 按服务端 language 切换文案并刷新静态标签(语言随配置,运行期不变)。 */
|
|
125
|
-
const applyLanguage = (language) => {
|
|
126
|
-
S = STRINGS[language] ?? STRINGS.en
|
|
127
|
-
openBtn.textContent = S.open
|
|
128
|
-
titleLabel.textContent = S.title
|
|
129
|
-
refreshBtn.title = S.refresh
|
|
130
|
-
refreshBtn.textContent = S.refresh
|
|
131
|
-
closeBtn.title = S.close
|
|
132
|
-
filterInput.placeholder = S.filter
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
let entries = []
|
|
136
|
-
let lastFilter = ''
|
|
137
|
-
let lastTotal = 0
|
|
138
|
-
let lastTruncated = false
|
|
139
|
-
|
|
140
|
-
const render = () => {
|
|
141
|
-
const visible = entries.filter((entry) => entry.text.toLowerCase().includes(lastFilter.toLowerCase()))
|
|
142
|
-
const groups = new Map()
|
|
143
|
-
for (const entry of visible) {
|
|
144
|
-
const key = `${entry.track}/${entry.scope}`
|
|
145
|
-
const list = groups.get(key)
|
|
146
|
-
if (list === undefined) groups.set(key, [entry])
|
|
147
|
-
else list.push(entry)
|
|
148
|
-
}
|
|
149
|
-
if (visible.length === 0) {
|
|
150
|
-
body.innerHTML = `<div class="mem-empty">${S.empty}</div>`
|
|
151
|
-
return
|
|
152
|
-
}
|
|
153
|
-
let html = lastTruncated
|
|
154
|
-
? `<div class="mem-empty">${S.truncated(entries.length, lastTotal)}</div>`
|
|
155
|
-
: ''
|
|
156
|
-
for (const [key, list] of groups) {
|
|
157
|
-
html += `<div class="mem-group">${key}${S.groupCount(list.length)}</div>`
|
|
158
|
-
for (const entry of list) {
|
|
159
|
-
const agentTag = typeof entry.agentKey === 'string' && entry.agentKey.length > 0 ? ` · agent ${escapeHtml(entry.agentKey)}` : ''
|
|
160
|
-
html += `<div class="mem-entry"><span class="t">${escapeHtml(entry.text)}</span><span class="m">${escapeHtml(entry.source)}${agentTag} · ${new Date(entry.createdAt).toLocaleString()}</span></div>`
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
body.innerHTML = html
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const renderBudget = (budgets) => {
|
|
167
|
-
if (!Array.isArray(budgets) || budgets.length === 0) return
|
|
168
|
-
let html = `<div class="mem-group">${S.budgets}</div>`
|
|
169
|
-
for (const row of budgets) {
|
|
170
|
-
const pct = row.limit > 0 ? Math.min(100, Math.round((row.used / row.limit) * 100)) : 0
|
|
171
|
-
html += `<div class="mem-row">${row.track}/${row.scope}: ${row.used}/${row.limit}<div class="mem-bar"><i style="width:${pct}%"></i></div></div>`
|
|
172
|
-
}
|
|
173
|
-
html += `<div class="mem-group">${S.audit}</div>`
|
|
174
|
-
html += `<div id="mem-audit-slot"><div class="mem-empty">${S.loading}</div></div>`
|
|
175
|
-
html += `<div class="mem-group">${S.proposals}</div>`
|
|
176
|
-
html += `<div id="mem-proposal-slot"><div class="mem-empty">${S.loading}</div></div>`
|
|
177
|
-
body.insertAdjacentHTML('beforeend', html)
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const renderAudit = (rows) => {
|
|
181
|
-
const slot = document.getElementById('mem-audit-slot')
|
|
182
|
-
if (slot === null) return
|
|
183
|
-
if (!Array.isArray(rows) || rows.length === 0) {
|
|
184
|
-
slot.innerHTML = `<div class="mem-empty">${S.auditEmpty}</div>`
|
|
185
|
-
return
|
|
186
|
-
}
|
|
187
|
-
slot.innerHTML = rows.map((row) =>
|
|
188
|
-
`<div class="mem-audit">${new Date(row.ts).toLocaleString()} ${escapeHtml(row.action)}${row.track ? ` ${escapeHtml(row.track)}/${escapeHtml(row.scope)}` : ''} · ${escapeHtml(row.outcome ?? '')} · ${escapeHtml(row.source ?? '')}</div>`).join('')
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const renderProposals = (proposals) => {
|
|
192
|
-
const slot = document.getElementById('mem-proposal-slot')
|
|
193
|
-
if (slot === null) return
|
|
194
|
-
if (!Array.isArray(proposals) || proposals.length === 0) {
|
|
195
|
-
slot.innerHTML = `<div class="mem-empty">${S.proposalsEmpty}</div>`
|
|
196
|
-
return
|
|
197
|
-
}
|
|
198
|
-
slot.innerHTML = proposals.map((proposal) =>
|
|
199
|
-
`<div class="mem-audit">[${escapeHtml(proposal.id)}] ${escapeHtml(proposal.track)}/${escapeHtml(proposal.scope)} · ${escapeHtml(proposal.text.length > 160 ? `${proposal.text.slice(0, 160)}…` : proposal.text)}</div>`).join('')
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const refresh = async () => {
|
|
203
|
-
try {
|
|
204
|
-
const response = await fetch('/api/memento/entries?limit=200')
|
|
205
|
-
if (!response.ok) throw new Error(`entries ${response.status}`)
|
|
206
|
-
const data = await response.json()
|
|
207
|
-
if (data.error !== undefined) throw new Error(data.error)
|
|
208
|
-
applyLanguage(data.language)
|
|
209
|
-
entries = Array.isArray(data.entries) ? data.entries : []
|
|
210
|
-
lastTotal = Number.isInteger(data.total) ? data.total : entries.length
|
|
211
|
-
lastTruncated = data.truncated === true
|
|
212
|
-
render()
|
|
213
|
-
renderBudget(data.budgets)
|
|
214
|
-
void fetch('/api/memento/audit?limit=20')
|
|
215
|
-
.then((res) => res.json())
|
|
216
|
-
.then((audit) => renderAudit(audit.rows))
|
|
217
|
-
.catch(() => renderAudit([]))
|
|
218
|
-
void fetch('/api/memento/proposals')
|
|
219
|
-
.then((res) => res.json())
|
|
220
|
-
.then((data) => renderProposals(data.proposals))
|
|
221
|
-
.catch(() => renderProposals([]))
|
|
222
|
-
} catch (error) {
|
|
223
|
-
body.innerHTML = `<div class="mem-empty">${S.loadFailed(String(error && error.message ? error.message : error))}</div>`
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
openBtn.addEventListener('click', () => {
|
|
228
|
-
drawer.style.display = drawer.style.display === 'none' ? 'flex' : 'none'
|
|
229
|
-
if (drawer.style.display !== 'none') void refresh()
|
|
230
|
-
})
|
|
231
|
-
closeBtn.addEventListener('click', () => { drawer.style.display = 'none' })
|
|
232
|
-
refreshBtn.addEventListener('click', () => void refresh())
|
|
233
|
-
filter.addEventListener('input', () => {
|
|
234
|
-
lastFilter = filter.value
|
|
235
|
-
render()
|
|
236
|
-
})
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function escapeHtml(value) {
|
|
240
|
-
return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
|
241
|
-
}
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// client/client.js — dsh-memento 浏览器观察面板(F9,零构建 vanilla)。
|
|
3
|
+
//
|
|
4
|
+
// host 端 dsh.client 扫描把本文件作为 classic script 注入 __DSH_BOOT__ 图,
|
|
5
|
+
// 执行时经 window.__ModuleLoader__.load 注册工厂;apply 挂载浮层抽屉面板。
|
|
6
|
+
// 面板只读:条目浏览/搜索/预算条/审计尾,全部走本插件自注册的
|
|
7
|
+
// /api/memento/* JSON 路由(只走公开 API)。写与审批在 DSH 内置审批 UI 完成,
|
|
8
|
+
// 面板不产生任何模型可见内容、不做任何审批决策。
|
|
9
|
+
// 面板文案随 Config.language(en/zh)切换,语言来自 entries 路由响应。
|
|
10
|
+
|
|
11
|
+
(function () {
|
|
12
|
+
'use strict'
|
|
13
|
+
if (typeof window === 'undefined' || !window.__ModuleLoader__ || !window.__ModuleLoader__.load) return
|
|
14
|
+
window.__ModuleLoader__.load({
|
|
15
|
+
id: 'dsh-memento',
|
|
16
|
+
factory: function () {
|
|
17
|
+
return {
|
|
18
|
+
name: 'memento-panel',
|
|
19
|
+
inject: [],
|
|
20
|
+
apply: function () { installPanel() },
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
})()
|
|
25
|
+
|
|
26
|
+
const PANEL_ID = 'dsh-memento-panel'
|
|
27
|
+
|
|
28
|
+
/** 面板文案(en 源文 / zh 译文;语言来自 /api/memento/entries 响应的 language 字段,缺省 en)。 */
|
|
29
|
+
const STRINGS = {
|
|
30
|
+
en: {
|
|
31
|
+
open: '🧠 Memory',
|
|
32
|
+
title: 'dsh-memento memory',
|
|
33
|
+
refresh: 'Refresh',
|
|
34
|
+
close: 'Close',
|
|
35
|
+
filter: 'Filter entries by text…',
|
|
36
|
+
empty: 'Memory is empty (or nothing matches the filter). Use the memory tool to write (approval happens in the built-in approval UI).',
|
|
37
|
+
truncated: (shown, total) => `Showing the first ${shown} of ${total} entries — narrow the filter to see more.`,
|
|
38
|
+
groupCount: (n) => `(${n})`,
|
|
39
|
+
budgets: 'Budget usage',
|
|
40
|
+
audit: 'Recent audit',
|
|
41
|
+
loading: 'Loading…',
|
|
42
|
+
auditEmpty: 'Audit is empty',
|
|
43
|
+
proposals: 'Pending proposals',
|
|
44
|
+
proposalsEmpty: 'No pending proposals (generated after session compaction; decide via /memory proposals approve|dismiss)',
|
|
45
|
+
loadFailed: (message) => `Load failed: ${message} (panel is read-only; make sure the Web profile has dsh-memento loaded)`,
|
|
46
|
+
},
|
|
47
|
+
zh: {
|
|
48
|
+
open: '🧠 记忆',
|
|
49
|
+
title: 'dsh-memento 记忆',
|
|
50
|
+
refresh: '刷新',
|
|
51
|
+
close: '关闭',
|
|
52
|
+
filter: '按文本过滤条目…',
|
|
53
|
+
empty: '记忆为空(或没有匹配当前过滤)。写操作请用 memory 工具(审批在 DS 内置审批 UI 完成)。',
|
|
54
|
+
truncated: (shown, total) => `仅显示前 ${shown} 条,共 ${total} 条——用过滤框缩小范围。`,
|
|
55
|
+
groupCount: (n) => `(${n} 条)`,
|
|
56
|
+
budgets: '预算用量',
|
|
57
|
+
audit: '最近审计',
|
|
58
|
+
loading: '加载中…',
|
|
59
|
+
auditEmpty: '审计为空',
|
|
60
|
+
proposals: '待审批提案',
|
|
61
|
+
proposalsEmpty: '暂无待审批提案(会话压缩后自动生成;用 /memory proposals approve|dismiss 处理)',
|
|
62
|
+
loadFailed: (message) => `加载失败:${message}(面板只读;请确认 Web profile 已装载 dsh-memento)`,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function installPanel() {
|
|
67
|
+
if (document.getElementById(PANEL_ID)) return
|
|
68
|
+
let S = STRINGS.en
|
|
69
|
+
|
|
70
|
+
const style = document.createElement('style')
|
|
71
|
+
style.textContent = `
|
|
72
|
+
#dsh-memento-panel { position: fixed; z-index: 2147483000; font: 13px/1.5 system-ui, "Segoe UI", sans-serif; }
|
|
73
|
+
#mem-open { position: fixed; right: 16px; bottom: 56px; z-index: 2147483000; padding: 8px 14px; border: 1px solid #3f6fae;
|
|
74
|
+
border-radius: 999px; background: #0d1526; color: #7db4ff; cursor: pointer; box-shadow: 0 4px 14px rgba(0,0,0,.35); }
|
|
75
|
+
#mem-open:hover { background: #16233c; }
|
|
76
|
+
#mem-drawer { position: fixed; right: 0; top: 0; bottom: 0; width: 460px; max-width: 92vw; display: flex; flex-direction: column;
|
|
77
|
+
background: #101827; color: #d7e2f2; border-left: 1px solid #24344d; box-shadow: -8px 0 24px rgba(0,0,0,.4); }
|
|
78
|
+
#mem-head { padding: 10px 12px; border-bottom: 1px solid #24344d; display: flex; gap: 8px; align-items: center; }
|
|
79
|
+
#mem-head b { flex: 1; }
|
|
80
|
+
#mem-head button { background: #1c2a42; color: #cfe1f7; border: 1px solid #2f4466; border-radius: 6px; padding: 4px 10px; cursor: pointer; }
|
|
81
|
+
#mem-head button:hover { background: #27395a; }
|
|
82
|
+
#mem-filter { margin: 8px 12px; padding: 6px 8px; background: #0b1120; color: #d7e2f2; border: 1px solid #24344d; border-radius: 6px; }
|
|
83
|
+
#mem-body { flex: 1; overflow: auto; padding: 0 12px 12px; }
|
|
84
|
+
.mem-group { margin: 10px 0 4px; color: #8fb4e8; }
|
|
85
|
+
.mem-entry { padding: 6px 8px; margin: 3px 0; border: 1px solid #24344d; border-radius: 8px; background: #131e33; }
|
|
86
|
+
.mem-entry .t { display: block; color: #e6eefb; }
|
|
87
|
+
.mem-entry .m { display: block; color: #9fb4d4; font-size: 11px; }
|
|
88
|
+
.mem-bar { height: 6px; margin: 6px 0 2px; background: #0b1120; border-radius: 3px; overflow: hidden; border: 1px solid #24344d; }
|
|
89
|
+
.mem-bar i { display: block; height: 100%; background: #3f6fae; }
|
|
90
|
+
.mem-row { margin: 6px 0; color: #9fb4d4; }
|
|
91
|
+
.mem-audit { margin: 6px 0; padding: 4px 8px; border-left: 2px solid #3f6fae; color: #9fb4d4; font-size: 12px; }
|
|
92
|
+
.mem-empty { color: #8fa8cc; margin: 10px 0; }
|
|
93
|
+
`
|
|
94
|
+
|
|
95
|
+
const openBtn = document.createElement('button')
|
|
96
|
+
openBtn.id = 'mem-open'
|
|
97
|
+
openBtn.textContent = S.open
|
|
98
|
+
|
|
99
|
+
const root = document.createElement('div')
|
|
100
|
+
root.id = PANEL_ID
|
|
101
|
+
const drawer = document.createElement('div')
|
|
102
|
+
drawer.id = 'mem-drawer'
|
|
103
|
+
drawer.style.display = 'none'
|
|
104
|
+
drawer.innerHTML = `
|
|
105
|
+
<div id="mem-head"><b id="mem-title">${S.title}</b>
|
|
106
|
+
<button id="mem-refresh" title="${S.refresh}">${S.refresh}</button>
|
|
107
|
+
<button id="mem-close" title="${S.close}">✕</button>
|
|
108
|
+
</div>
|
|
109
|
+
<input id="mem-filter" placeholder="${S.filter}" />
|
|
110
|
+
<div id="mem-body"></div>
|
|
111
|
+
`
|
|
112
|
+
root.appendChild(openBtn)
|
|
113
|
+
root.appendChild(drawer)
|
|
114
|
+
document.body.appendChild(root)
|
|
115
|
+
document.head.appendChild(style)
|
|
116
|
+
|
|
117
|
+
const body = document.getElementById('mem-body')
|
|
118
|
+
const filter = document.getElementById('mem-filter')
|
|
119
|
+
const titleLabel = document.getElementById('mem-title')
|
|
120
|
+
const refreshBtn = document.getElementById('mem-refresh')
|
|
121
|
+
const closeBtn = document.getElementById('mem-close')
|
|
122
|
+
const filterInput = document.getElementById('mem-filter')
|
|
123
|
+
|
|
124
|
+
/** 按服务端 language 切换文案并刷新静态标签(语言随配置,运行期不变)。 */
|
|
125
|
+
const applyLanguage = (language) => {
|
|
126
|
+
S = STRINGS[language] ?? STRINGS.en
|
|
127
|
+
openBtn.textContent = S.open
|
|
128
|
+
titleLabel.textContent = S.title
|
|
129
|
+
refreshBtn.title = S.refresh
|
|
130
|
+
refreshBtn.textContent = S.refresh
|
|
131
|
+
closeBtn.title = S.close
|
|
132
|
+
filterInput.placeholder = S.filter
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let entries = []
|
|
136
|
+
let lastFilter = ''
|
|
137
|
+
let lastTotal = 0
|
|
138
|
+
let lastTruncated = false
|
|
139
|
+
|
|
140
|
+
const render = () => {
|
|
141
|
+
const visible = entries.filter((entry) => entry.text.toLowerCase().includes(lastFilter.toLowerCase()))
|
|
142
|
+
const groups = new Map()
|
|
143
|
+
for (const entry of visible) {
|
|
144
|
+
const key = `${entry.track}/${entry.scope}`
|
|
145
|
+
const list = groups.get(key)
|
|
146
|
+
if (list === undefined) groups.set(key, [entry])
|
|
147
|
+
else list.push(entry)
|
|
148
|
+
}
|
|
149
|
+
if (visible.length === 0) {
|
|
150
|
+
body.innerHTML = `<div class="mem-empty">${S.empty}</div>`
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
let html = lastTruncated
|
|
154
|
+
? `<div class="mem-empty">${S.truncated(entries.length, lastTotal)}</div>`
|
|
155
|
+
: ''
|
|
156
|
+
for (const [key, list] of groups) {
|
|
157
|
+
html += `<div class="mem-group">${key}${S.groupCount(list.length)}</div>`
|
|
158
|
+
for (const entry of list) {
|
|
159
|
+
const agentTag = typeof entry.agentKey === 'string' && entry.agentKey.length > 0 ? ` · agent ${escapeHtml(entry.agentKey)}` : ''
|
|
160
|
+
html += `<div class="mem-entry"><span class="t">${escapeHtml(entry.text)}</span><span class="m">${escapeHtml(entry.source)}${agentTag} · ${new Date(entry.createdAt).toLocaleString()}</span></div>`
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
body.innerHTML = html
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const renderBudget = (budgets) => {
|
|
167
|
+
if (!Array.isArray(budgets) || budgets.length === 0) return
|
|
168
|
+
let html = `<div class="mem-group">${S.budgets}</div>`
|
|
169
|
+
for (const row of budgets) {
|
|
170
|
+
const pct = row.limit > 0 ? Math.min(100, Math.round((row.used / row.limit) * 100)) : 0
|
|
171
|
+
html += `<div class="mem-row">${row.track}/${row.scope}: ${row.used}/${row.limit}<div class="mem-bar"><i style="width:${pct}%"></i></div></div>`
|
|
172
|
+
}
|
|
173
|
+
html += `<div class="mem-group">${S.audit}</div>`
|
|
174
|
+
html += `<div id="mem-audit-slot"><div class="mem-empty">${S.loading}</div></div>`
|
|
175
|
+
html += `<div class="mem-group">${S.proposals}</div>`
|
|
176
|
+
html += `<div id="mem-proposal-slot"><div class="mem-empty">${S.loading}</div></div>`
|
|
177
|
+
body.insertAdjacentHTML('beforeend', html)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const renderAudit = (rows) => {
|
|
181
|
+
const slot = document.getElementById('mem-audit-slot')
|
|
182
|
+
if (slot === null) return
|
|
183
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
184
|
+
slot.innerHTML = `<div class="mem-empty">${S.auditEmpty}</div>`
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
slot.innerHTML = rows.map((row) =>
|
|
188
|
+
`<div class="mem-audit">${new Date(row.ts).toLocaleString()} ${escapeHtml(row.action)}${row.track ? ` ${escapeHtml(row.track)}/${escapeHtml(row.scope)}` : ''} · ${escapeHtml(row.outcome ?? '')} · ${escapeHtml(row.source ?? '')}</div>`).join('')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const renderProposals = (proposals) => {
|
|
192
|
+
const slot = document.getElementById('mem-proposal-slot')
|
|
193
|
+
if (slot === null) return
|
|
194
|
+
if (!Array.isArray(proposals) || proposals.length === 0) {
|
|
195
|
+
slot.innerHTML = `<div class="mem-empty">${S.proposalsEmpty}</div>`
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
slot.innerHTML = proposals.map((proposal) =>
|
|
199
|
+
`<div class="mem-audit">[${escapeHtml(proposal.id)}] ${escapeHtml(proposal.track)}/${escapeHtml(proposal.scope)} · ${escapeHtml(proposal.text.length > 160 ? `${proposal.text.slice(0, 160)}…` : proposal.text)}</div>`).join('')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const refresh = async () => {
|
|
203
|
+
try {
|
|
204
|
+
const response = await fetch('/api/memento/entries?limit=200')
|
|
205
|
+
if (!response.ok) throw new Error(`entries ${response.status}`)
|
|
206
|
+
const data = await response.json()
|
|
207
|
+
if (data.error !== undefined) throw new Error(data.error)
|
|
208
|
+
applyLanguage(data.language)
|
|
209
|
+
entries = Array.isArray(data.entries) ? data.entries : []
|
|
210
|
+
lastTotal = Number.isInteger(data.total) ? data.total : entries.length
|
|
211
|
+
lastTruncated = data.truncated === true
|
|
212
|
+
render()
|
|
213
|
+
renderBudget(data.budgets)
|
|
214
|
+
void fetch('/api/memento/audit?limit=20')
|
|
215
|
+
.then((res) => res.json())
|
|
216
|
+
.then((audit) => renderAudit(audit.rows))
|
|
217
|
+
.catch(() => renderAudit([]))
|
|
218
|
+
void fetch('/api/memento/proposals')
|
|
219
|
+
.then((res) => res.json())
|
|
220
|
+
.then((data) => renderProposals(data.proposals))
|
|
221
|
+
.catch(() => renderProposals([]))
|
|
222
|
+
} catch (error) {
|
|
223
|
+
body.innerHTML = `<div class="mem-empty">${S.loadFailed(String(error && error.message ? error.message : error))}</div>`
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
openBtn.addEventListener('click', () => {
|
|
228
|
+
drawer.style.display = drawer.style.display === 'none' ? 'flex' : 'none'
|
|
229
|
+
if (drawer.style.display !== 'none') void refresh()
|
|
230
|
+
})
|
|
231
|
+
closeBtn.addEventListener('click', () => { drawer.style.display = 'none' })
|
|
232
|
+
refreshBtn.addEventListener('click', () => void refresh())
|
|
233
|
+
filter.addEventListener('input', () => {
|
|
234
|
+
lastFilter = filter.value
|
|
235
|
+
render()
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function escapeHtml(value) {
|
|
240
|
+
return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
|
241
|
+
}
|