@weibaohui/context-razor 0.1.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/README.md +32 -0
- package/client/bundle.js +485 -0
- package/client/index.js +471 -0
- package/cordis.patch.yml +11 -0
- package/docs/demo.gif +0 -0
- package/package.json +60 -0
- package/src/index.js +303 -0
package/client/index.js
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-razor — Browser half.
|
|
3
|
+
*
|
|
4
|
+
* Settings-section surface (same embedding as skills-management): pick a live
|
|
5
|
+
* session, see every model-visible context entry with its ≈token estimate,
|
|
6
|
+
* mark oversized ones in red over a threshold, multi-select and razor them
|
|
7
|
+
* away. All colors come from the ui-theme `--dsw-*` token layers; all copy
|
|
8
|
+
* comes from the locale registry (`zh`/`en`). No hardcoded colors, no ad-hoc
|
|
9
|
+
* copy. No class components — render errors land in globalThis.__rzErrors.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
let __React = null
|
|
13
|
+
try { __React = require('react') } catch {}
|
|
14
|
+
if (!__React || typeof __React.createElement !== 'function') {
|
|
15
|
+
__React = {
|
|
16
|
+
createElement(type, props, ...kids) {
|
|
17
|
+
return { type, props: props || {}, kids: kids.flat(9).filter(k => k !== null && k !== undefined && k !== false && k !== true && typeof k !== 'string' || true) }
|
|
18
|
+
},
|
|
19
|
+
useState(init) { const v = [typeof init === 'function' ? init() : init]; return [v[0], x => { v[0] = typeof x === 'function' ? x(v[0]) : x }] },
|
|
20
|
+
useEffect() {}, useMemo(fn) { return fn() }, useRef(v = null) { return { current: v } },
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const { createElement: h, useState, useEffect, useMemo, useRef } = __React
|
|
24
|
+
|
|
25
|
+
let P = null
|
|
26
|
+
try { P = require('@deepseek-ai/dsh-client-ui-primitives') } catch {}
|
|
27
|
+
|
|
28
|
+
/** Idempotent stylesheet injection (position-critical classes included). */
|
|
29
|
+
function ensureStyles() {
|
|
30
|
+
if (typeof document === 'undefined' || document.getElementById('rz-styles')) return
|
|
31
|
+
const holder = document.createElement('div')
|
|
32
|
+
holder.id = 'rz-styles'
|
|
33
|
+
holder.style.display = 'none'
|
|
34
|
+
holder.innerHTML = STYLE
|
|
35
|
+
document.head.appendChild(holder)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const prim = (name) => P && P[name]
|
|
39
|
+
? P[name]
|
|
40
|
+
: function Shim(props) {
|
|
41
|
+
const { children, ...rest } = props
|
|
42
|
+
return h('button', { ...rest, 'data-p-shim': name }, children)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Locale ───────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
const NS = 'contextRazor'
|
|
48
|
+
|
|
49
|
+
const ZH = {
|
|
50
|
+
title: '上下文剃刀',
|
|
51
|
+
hint: '逐条列出会话上下文(≈token 为 cl100k_base 估算,与技能市场同词表)。删除 = 从模型视野移除(append-only 日志保留痕迹,不可恢复),不经 LLM 总结。',
|
|
52
|
+
sessionLabel: '会话',
|
|
53
|
+
pickSession: '选择会话…',
|
|
54
|
+
loading: '正在加载…',
|
|
55
|
+
refresh: '刷新',
|
|
56
|
+
busyTag: '运行中',
|
|
57
|
+
idleTag: '空闲',
|
|
58
|
+
thresholdLabel: '标红阈值',
|
|
59
|
+
thresholdUnit: 'token',
|
|
60
|
+
sortLabel: '排序',
|
|
61
|
+
sortOrder: '上下文顺序',
|
|
62
|
+
sortTokens: 'token 高→低',
|
|
63
|
+
overOnly: '只看超阈值',
|
|
64
|
+
selectOver: '选中超阈值',
|
|
65
|
+
clearSelect: '清空选择',
|
|
66
|
+
deleteSelected: '删除选中',
|
|
67
|
+
selectedStats: '已选 {n} 条 / ≈{tokens} token',
|
|
68
|
+
stats: '{nodes} 条 · 合计 ≈{tokens} token{mode}',
|
|
69
|
+
modeHeuristic: '(启发式估算:词表未加载)',
|
|
70
|
+
kindUser: '用户',
|
|
71
|
+
kindAssistant: '助手',
|
|
72
|
+
kindTool: '工具',
|
|
73
|
+
emptyContext: '该会话上下文为空',
|
|
74
|
+
showMore: '显示更多(剩余 {n})',
|
|
75
|
+
detailTitle: '条目详情',
|
|
76
|
+
detailTokens: '≈{tokens} token · {chars} 字符',
|
|
77
|
+
detailUsage: '真实用量:输入 {input} / 输出 {output}{cache}',
|
|
78
|
+
detailUsageCache: ' / 缓存读 {cache}',
|
|
79
|
+
close: '关闭',
|
|
80
|
+
deleteTitle: '确认裁剪',
|
|
81
|
+
deleteConfirm: '即将把 {n} 条消息(约 {tokens} token)从模型视野中移除,替换为一条标记消息。日志中保留痕迹,此操作不可恢复。继续?',
|
|
82
|
+
deleteBusyHint: '会话正在运行,等当前回合结束后再裁剪',
|
|
83
|
+
deleteOk: '执行',
|
|
84
|
+
cancel: '取消',
|
|
85
|
+
deletedToast: '已删除 {n} 条(约 {tokens} token)',
|
|
86
|
+
operationFailed: '操作失败',
|
|
87
|
+
seqLabel: 'seq {seq}',
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const EN = {
|
|
91
|
+
title: 'Context Razor',
|
|
92
|
+
hint: 'Every model-visible context entry with a ≈token estimate (cl100k_base, same ranks as Skills Market). Delete removes entries from the model view (append-only log keeps traces, irreversible) — no LLM summarization involved.',
|
|
93
|
+
sessionLabel: 'Session',
|
|
94
|
+
pickSession: 'Pick a session…',
|
|
95
|
+
loading: 'Loading…',
|
|
96
|
+
refresh: 'Refresh',
|
|
97
|
+
busyTag: 'running',
|
|
98
|
+
idleTag: 'idle',
|
|
99
|
+
thresholdLabel: 'Red threshold',
|
|
100
|
+
thresholdUnit: 'tokens',
|
|
101
|
+
sortLabel: 'Sort',
|
|
102
|
+
sortOrder: 'Context order',
|
|
103
|
+
sortTokens: 'tokens high→low',
|
|
104
|
+
overOnly: 'Over threshold only',
|
|
105
|
+
selectOver: 'Select over threshold',
|
|
106
|
+
clearSelect: 'Clear selection',
|
|
107
|
+
deleteSelected: 'Delete selected',
|
|
108
|
+
selectedStats: '{n} selected / ≈{tokens} tokens',
|
|
109
|
+
stats: '{nodes} entries · total ≈{tokens} tokens{mode}',
|
|
110
|
+
modeHeuristic: ' (heuristic: ranks unavailable)',
|
|
111
|
+
kindUser: 'User',
|
|
112
|
+
kindAssistant: 'Assistant',
|
|
113
|
+
kindTool: 'Tool',
|
|
114
|
+
emptyContext: 'This session has no context entries',
|
|
115
|
+
showMore: 'Show more ({n} left)',
|
|
116
|
+
detailTitle: 'Entry detail',
|
|
117
|
+
detailTokens: '≈{tokens} tokens · {chars} chars',
|
|
118
|
+
detailUsage: 'Actual usage: in {input} / out {output}{cache}',
|
|
119
|
+
detailUsageCache: ' / cache-read {cache}',
|
|
120
|
+
close: 'Close',
|
|
121
|
+
deleteTitle: 'Confirm trim',
|
|
122
|
+
deleteConfirm: 'About to remove {n} messages (≈{tokens} tokens) from the model view, replaced by one marker message. Traces stay in the log; this cannot be undone. Continue?',
|
|
123
|
+
deleteBusyHint: 'Session is running — trim after the current turn finishes',
|
|
124
|
+
deleteOk: 'Delete',
|
|
125
|
+
cancel: 'Cancel',
|
|
126
|
+
deletedToast: 'Deleted {n} entries (≈{tokens} tokens)',
|
|
127
|
+
operationFailed: 'Operation failed',
|
|
128
|
+
seqLabel: 'seq {seq}',
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Pure helpers ────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
const API = '/context-razor/api'
|
|
134
|
+
const PAGE_SIZE = 150
|
|
135
|
+
const DEFAULT_THRESHOLD = 500
|
|
136
|
+
const THRESHOLD_KEY = 'context-razor-threshold'
|
|
137
|
+
|
|
138
|
+
const kindI18n = (key) => 'kind' + key[0].toUpperCase() + key.slice(1)
|
|
139
|
+
|
|
140
|
+
/** 行是否标红(仅 user/assistant/tool 有意义,阈值 <= 0 视为关闭)。 */
|
|
141
|
+
function overThreshold(entry, threshold) {
|
|
142
|
+
return threshold > 0 && typeof entry.tokens === 'number' && entry.tokens > threshold
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 排序:order = 投影原序;tokens = 降序(并列按 seq,缺 token 沉底)。 */
|
|
146
|
+
function sortEntries(entries, sortBy) {
|
|
147
|
+
if (sortBy !== 'tokens') return entries
|
|
148
|
+
return [...entries].sort((a, b) => {
|
|
149
|
+
const va = a.tokens, vb = b.tokens
|
|
150
|
+
if (va === vb) return a.seq - b.seq
|
|
151
|
+
if (va === undefined || va === null) return 1
|
|
152
|
+
if (vb === undefined || vb === null) return -1
|
|
153
|
+
return vb - va
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const formatNum = (n) => Number.isFinite(n) ? n.toLocaleString('en-US') : '-'
|
|
158
|
+
|
|
159
|
+
async function getJson(url) {
|
|
160
|
+
const r = await fetch(url)
|
|
161
|
+
if (!r.ok) {
|
|
162
|
+
const body = await r.json().catch(() => ({}))
|
|
163
|
+
throw new Error(body.error || 'HTTP ' + r.status)
|
|
164
|
+
}
|
|
165
|
+
return r.json()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Token-based stylesheet (light/dark adaptive by construction) ────────
|
|
169
|
+
|
|
170
|
+
const STYLE = `<style>
|
|
171
|
+
.rz-page{position:relative;display:flex;flex-direction:column;gap:12px;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);font-size:var(--dsw-font-sm-14,14px)}
|
|
172
|
+
.rz-hint{color:var(--dsw-alias-label-secondary);font-size:12.5px;line-height:1.5}
|
|
173
|
+
.rz-toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
|
174
|
+
.rz-spacer{flex:1}
|
|
175
|
+
.rz-label{color:var(--dsw-alias-label-secondary);font-size:12.5px;white-space:nowrap}
|
|
176
|
+
.rz-input{min-height:30px;padding:4px 10px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-specific-input-major,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary);font-size:13px;font-family:var(--dsw-font-family);outline:none}
|
|
177
|
+
.rz-input:focus{border-color:var(--dsw-alias-state-business-primary)}
|
|
178
|
+
.rz-select{min-height:30px;padding:4px 10px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-specific-input-major,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary);font-size:13px;font-family:var(--dsw-font-family);outline:none}
|
|
179
|
+
.rz-stats{display:flex;gap:12px;align-items:center;flex-wrap:wrap;color:var(--dsw-alias-label-secondary);font-size:12.5px}
|
|
180
|
+
.rz-badge{display:inline-flex;align-items:center;padding:1px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-size:11.5px;white-space:nowrap}
|
|
181
|
+
.rz-badge.over{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);font-weight:600}
|
|
182
|
+
.rz-chip{display:inline-flex;align-items:center;padding:1px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);font-size:11.5px;white-space:nowrap;flex:none}
|
|
183
|
+
.rz-chip.user{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}
|
|
184
|
+
.rz-chip.assistant{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}
|
|
185
|
+
.rz-list{display:flex;flex-direction:column;gap:6px}
|
|
186
|
+
.rz-row{display:flex;gap:10px;align-items:center;padding:8px 12px;border-radius:10px;border:1px solid var(--dsw-alias-border-l1);border-left:3px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);cursor:pointer;text-align:left;width:100%}
|
|
187
|
+
.rz-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
188
|
+
.rz-row.over{border-left-color:var(--dsw-alias-state-error-primary)}
|
|
189
|
+
.rz-row.checked{border-color:var(--dsw-alias-state-business-primary)}
|
|
190
|
+
.rz-row-preview{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary);font-size:13px}
|
|
191
|
+
.rz-row.over .rz-row-preview{color:var(--dsw-alias-label-primary)}
|
|
192
|
+
.rz-row-meta{color:var(--dsw-alias-label-tertiary);font-size:11px;flex:none}
|
|
193
|
+
.rz-empty{border:1px dashed var(--dsw-alias-border-l2);border-radius:12px;padding:36px 20px;text-align:center;color:var(--dsw-alias-label-secondary)}
|
|
194
|
+
.rz-loading{padding:36px;text-align:center;color:var(--dsw-alias-label-secondary)}
|
|
195
|
+
.rz-footbtns{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
|
196
|
+
.rz-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:30px;padding:5px 14px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12.5px;font-weight:500;cursor:pointer;font-family:var(--dsw-font-family);white-space:nowrap}
|
|
197
|
+
.rz-btn:hover{border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}
|
|
198
|
+
.rz-btn:disabled{opacity:.45;cursor:not-allowed}
|
|
199
|
+
.rz-btn-danger{background:var(--dsw-alias-state-error-primary);border-color:transparent;color:var(--dsw-alias-label-primary-inverted,#fff)}
|
|
200
|
+
.rz-btn-danger:hover{filter:brightness(1.08);background:var(--dsw-alias-state-error-primary)}
|
|
201
|
+
.rz-dlg-backdrop{position:fixed;inset:0;z-index:30;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;padding:24px}
|
|
202
|
+
.rz-dlg{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:14px;min-width:360px;max-width:720px;max-width:min(720px,92vw);max-height:82vh;overflow:auto;padding:18px;box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family)}
|
|
203
|
+
.rz-dlg h3{margin:0 0 12px;font-size:15px}
|
|
204
|
+
.rz-dlg-foot{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
|
|
205
|
+
.rz-dlg-text{white-space:pre-wrap;font-size:12.5px;line-height:1.55;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);padding:12px;max-height:46vh;overflow:auto}
|
|
206
|
+
.rz-toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);z-index:40;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:8px 18px;font-size:13px;box-shadow:var(--dsw-shadow-lv2)}
|
|
207
|
+
</style>`
|
|
208
|
+
|
|
209
|
+
// ── Small building blocks ────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
const Chip = ({ kind, label }) => h('span', { className: 'rz-chip ' + kind }, label)
|
|
212
|
+
|
|
213
|
+
const TokenBadge = ({ entry, threshold }) => {
|
|
214
|
+
const over = overThreshold(entry, threshold)
|
|
215
|
+
return h('span', { className: 'rz-badge' + (over ? ' over' : ''), title: over ? '≥ 阈值 ' + threshold : undefined },
|
|
216
|
+
'≈' + formatNum(entry.tokens))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 分页列表:先渲染 pageSize 行,按需增长(大会话一次挂几千行会卡)。 */
|
|
220
|
+
function PagedList({ items, render, t }) {
|
|
221
|
+
const [shown, setShown] = useState(PAGE_SIZE)
|
|
222
|
+
useEffect(() => { setShown(PAGE_SIZE) }, [items])
|
|
223
|
+
return [
|
|
224
|
+
...items.slice(0, shown).map(render),
|
|
225
|
+
items.length > shown && h('div', { style: { textAlign: 'center', margin: '10px 0' } },
|
|
226
|
+
h('button', { className: 'rz-btn', onClick: () => setShown(n => n + PAGE_SIZE) }, t('showMore', { n: items.length - shown }))),
|
|
227
|
+
]
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 单条全文弹窗(正文经 /entry 异步补全)。 */
|
|
231
|
+
function DetailModal({ detail, threshold, t, onClose }) {
|
|
232
|
+
if (!detail) return null
|
|
233
|
+
return h('div', { className: 'rz-dlg-backdrop', onClick: onClose },
|
|
234
|
+
h('div', { className: 'rz-dlg', onClick: e => e.stopPropagation() },
|
|
235
|
+
h('h3', null, t('detailTitle') + ' · ' + t('seqLabel', { seq: detail.seq })),
|
|
236
|
+
h('div', { className: 'rz-stats', style: { marginBottom: 10 } },
|
|
237
|
+
h(Chip, { kind: detail.kind, label: t(kindI18n(detail.kind)) }),
|
|
238
|
+
h(TokenBadge, { entry: detail, threshold }),
|
|
239
|
+
h('span', { className: 'rz-label' }, t('detailTokens', { tokens: formatNum(detail.tokens), chars: formatNum(detail.chars) })),
|
|
240
|
+
detail.usage && h('span', { className: 'rz-label' }, t('detailUsage', {
|
|
241
|
+
input: formatNum(detail.usage.input), output: formatNum(detail.usage.output),
|
|
242
|
+
cache: detail.usage.cacheRead ? t('detailUsageCache', { cache: formatNum(detail.usage.cacheRead) }) : '' }))),
|
|
243
|
+
h('div', { className: 'rz-dlg-text' }, detail.text || ' '),
|
|
244
|
+
h('div', { className: 'rz-dlg-foot' },
|
|
245
|
+
h('button', { className: 'rz-btn', onClick: onClose }, t('close')))))
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** 裁剪确认弹窗。 */
|
|
249
|
+
function ConfirmDialog({ n, tokens, deleting, t, onCancel, onOk }) {
|
|
250
|
+
return h('div', { className: 'rz-dlg-backdrop', onClick: onCancel },
|
|
251
|
+
h('div', { className: 'rz-dlg', onClick: e => e.stopPropagation() },
|
|
252
|
+
h('h3', null, t('deleteTitle')),
|
|
253
|
+
h('div', { className: 'rz-hint' }, t('deleteConfirm', { n, tokens })),
|
|
254
|
+
h('div', { className: 'rz-dlg-foot' },
|
|
255
|
+
h('button', { className: 'rz-btn', onClick: onCancel }, t('cancel')),
|
|
256
|
+
h('button', { className: 'rz-btn rz-btn-danger', disabled: deleting, onClick: onOk }, t('deleteOk')))))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Page ────────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
function RazorPage({ t }) {
|
|
262
|
+
const [sessions, setSessions] = useState(null)
|
|
263
|
+
const [sessionId, setSessionId] = useState('')
|
|
264
|
+
const [context, setContext] = useState(null)
|
|
265
|
+
const [ctxLoading, setCtxLoading] = useState(false)
|
|
266
|
+
const [error, setError] = useState(null)
|
|
267
|
+
const [threshold, setThreshold] = useState(() => {
|
|
268
|
+
try { const v = Number(localStorage.getItem(THRESHOLD_KEY)); return Number.isFinite(v) && v > 0 ? v : DEFAULT_THRESHOLD } catch { return DEFAULT_THRESHOLD }
|
|
269
|
+
})
|
|
270
|
+
const [sortBy, setSortBy] = useState('order')
|
|
271
|
+
const [overOnly, setOverOnly] = useState(false)
|
|
272
|
+
const [selected, setSelected] = useState(() => new Set())
|
|
273
|
+
const [detail, setDetail] = useState(null)
|
|
274
|
+
const [confirming, setConfirming] = useState(false)
|
|
275
|
+
const [deleting, setDeleting] = useState(false)
|
|
276
|
+
const [toast, setToast] = useState(null)
|
|
277
|
+
const sessionRef = useRef('')
|
|
278
|
+
|
|
279
|
+
const showToast = (text) => { setToast(text); setTimeout(() => setToast(null), 3000) }
|
|
280
|
+
|
|
281
|
+
const loadSessions = () => getJson(API + '/sessions').then(d => setSessions(d.sessions || [])).catch(e => setError(e.message))
|
|
282
|
+
useEffect(() => { loadSessions() }, [])
|
|
283
|
+
|
|
284
|
+
const loadContext = (id) => {
|
|
285
|
+
if (!id) { setContext(null); return }
|
|
286
|
+
setCtxLoading(true)
|
|
287
|
+
setError(null)
|
|
288
|
+
getJson(API + '/context?session=' + encodeURIComponent(id))
|
|
289
|
+
.then(d => { setContext(d); setSelected(new Set()) })
|
|
290
|
+
.catch(e => setError(e.message))
|
|
291
|
+
.finally(() => setCtxLoading(false))
|
|
292
|
+
}
|
|
293
|
+
useEffect(() => { if (sessionId) loadContext(sessionId) }, [sessionId])
|
|
294
|
+
|
|
295
|
+
const refreshAll = () => {
|
|
296
|
+
loadSessions()
|
|
297
|
+
if (sessionRef.current) loadContext(sessionRef.current)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const pickSession = (id) => { sessionRef.current = id; setSessionId(id) }
|
|
301
|
+
const changeThreshold = (v) => {
|
|
302
|
+
const n = Number(v)
|
|
303
|
+
setThreshold(Number.isFinite(n) && n > 0 ? n : 0)
|
|
304
|
+
try { localStorage.setItem(THRESHOLD_KEY, String(n)) } catch {}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const visible = useMemo(() => {
|
|
308
|
+
if (!context) return []
|
|
309
|
+
let rows = context.entries
|
|
310
|
+
if (overOnly && threshold > 0) rows = rows.filter(e => overThreshold(e, threshold))
|
|
311
|
+
return sortEntries(rows, sortBy)
|
|
312
|
+
}, [context, overOnly, threshold, sortBy])
|
|
313
|
+
|
|
314
|
+
const overList = useMemo(() => (context ? context.entries.filter(e => overThreshold(e, threshold)) : []), [context, threshold])
|
|
315
|
+
const selectedTokens = useMemo(() => {
|
|
316
|
+
if (!context) return 0
|
|
317
|
+
const bySeq = new Map(context.entries.map(e => [e.seq, e]))
|
|
318
|
+
let sum = 0
|
|
319
|
+
for (const seq of selected) { const e = bySeq.get(seq); if (e) sum += e.tokens }
|
|
320
|
+
return sum
|
|
321
|
+
}, [context, selected])
|
|
322
|
+
|
|
323
|
+
const toggleRow = (seq) => setSelected(prev => {
|
|
324
|
+
const next = new Set(prev)
|
|
325
|
+
if (next.has(seq)) next.delete(seq)
|
|
326
|
+
else next.add(seq)
|
|
327
|
+
return next
|
|
328
|
+
})
|
|
329
|
+
const selectOver = () => setSelected(new Set(overList.map(e => e.seq)))
|
|
330
|
+
|
|
331
|
+
const openDetail = (entry) => {
|
|
332
|
+
setDetail(entry)
|
|
333
|
+
getJson(`${API}/entry?session=${encodeURIComponent(sessionId)}&seq=${entry.seq}`)
|
|
334
|
+
.then(d => setDetail(cur => (cur && cur.seq === entry.seq) ? { ...cur, text: d.text, tokens: d.tokens } : cur))
|
|
335
|
+
.catch(() => {})
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const doDelete = async () => {
|
|
339
|
+
setDeleting(true)
|
|
340
|
+
try {
|
|
341
|
+
const r = await fetch(API + '/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session: sessionId, seqs: [...selected] }) })
|
|
342
|
+
const d = await r.json().catch(() => ({}))
|
|
343
|
+
if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status)
|
|
344
|
+
setConfirming(false)
|
|
345
|
+
setSelected(new Set())
|
|
346
|
+
showToast(t('deletedToast', { n: d.removed, tokens: formatNum(d.tokensRemoved) }))
|
|
347
|
+
refreshAll()
|
|
348
|
+
} catch (e) {
|
|
349
|
+
setConfirming(false)
|
|
350
|
+
setError(e.message)
|
|
351
|
+
} finally { setDeleting(false) }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const busy = !!(context && context.busy)
|
|
355
|
+
const canDelete = selected.size > 0 && !busy && !deleting
|
|
356
|
+
|
|
357
|
+
return h('div', { className: 'rz-page' },
|
|
358
|
+
h('div', { className: 'rz-hint' }, t('hint')),
|
|
359
|
+
h('div', { className: 'rz-toolbar' },
|
|
360
|
+
h('span', { className: 'rz-label' }, t('sessionLabel')),
|
|
361
|
+
h('select', { className: 'rz-select', value: sessionId, onChange: e => pickSession(e.target.value), style: { maxWidth: 380 } },
|
|
362
|
+
h('option', { value: '' }, t('pickSession')),
|
|
363
|
+
(sessions || []).map(s => h('option', { key: s.id, value: s.id },
|
|
364
|
+
(s.cwd ? s.cwd.split('/').pop() + ' · ' : '') + s.id.slice(0, 8) + ' · ' + s.nodes))),
|
|
365
|
+
h('button', { className: 'rz-btn', onClick: refreshAll, title: t('refresh') }, t('refresh')),
|
|
366
|
+
context && h('span', { className: 'rz-badge' }, context.busy ? t('busyTag') : t('idleTag'))),
|
|
367
|
+
error && h('div', { className: 'rz-hint', style: { color: 'var(--dsw-alias-state-error-primary)' } }, t('operationFailed') + ': ' + error),
|
|
368
|
+
!sessionId && (sessions === null
|
|
369
|
+
? h('div', { className: 'rz-loading' }, t('loading'))
|
|
370
|
+
: h('div', { className: 'rz-empty' }, t('pickSession'))),
|
|
371
|
+
sessionId && ctxLoading && h('div', { className: 'rz-loading' }, t('loading')),
|
|
372
|
+
sessionId && !ctxLoading && context && [
|
|
373
|
+
h('div', { key: 'stats', className: 'rz-stats' },
|
|
374
|
+
h('span', null, t('stats', { nodes: context.nodes, tokens: formatNum(context.totalTokens), mode: context.encoder === 'heuristic' ? t('modeHeuristic') : '' })),
|
|
375
|
+
h('span', { className: 'rz-spacer' }),
|
|
376
|
+
h('label', { className: 'rz-label', style: { display: 'inline-flex', gap: 6, alignItems: 'center' } },
|
|
377
|
+
t('thresholdLabel'),
|
|
378
|
+
h('input', { className: 'rz-input', type: 'number', min: 0, step: 50, value: threshold, onChange: e => changeThreshold(e.target.value), style: { width: 90 } }),
|
|
379
|
+
t('thresholdUnit')),
|
|
380
|
+
h('select', { className: 'rz-select', value: sortBy, onChange: e => setSortBy(e.target.value), title: t('sortLabel'), 'aria-label': t('sortLabel') },
|
|
381
|
+
h('option', { value: 'order' }, t('sortOrder')),
|
|
382
|
+
h('option', { value: 'tokens' }, t('sortTokens'))),
|
|
383
|
+
h('label', { className: 'rz-label', style: { display: 'inline-flex', gap: 6, alignItems: 'center' } },
|
|
384
|
+
h('input', { type: 'checkbox', checked: overOnly, onChange: e => setOverOnly(e.target.checked) }), t('overOnly'))),
|
|
385
|
+
h('div', { key: 'sel', className: 'rz-stats' },
|
|
386
|
+
h('span', null, selected.size > 0 ? t('selectedStats', { n: selected.size, tokens: formatNum(selectedTokens) }) : ' '),
|
|
387
|
+
h('span', { className: 'rz-spacer' }),
|
|
388
|
+
h('div', { className: 'rz-footbtns' },
|
|
389
|
+
h('button', { className: 'rz-btn', onClick: selectOver, disabled: overList.length === 0 || busy }, t('selectOver') + (overList.length ? ` (${overList.length})` : '')),
|
|
390
|
+
h('button', { className: 'rz-btn', onClick: () => setSelected(new Set()), disabled: selected.size === 0 }, t('clearSelect')),
|
|
391
|
+
h('button', { className: 'rz-btn rz-btn-danger', disabled: !canDelete,
|
|
392
|
+
title: busy ? t('deleteBusyHint') : undefined,
|
|
393
|
+
onClick: () => setConfirming(true) },
|
|
394
|
+
t('deleteSelected') + (selected.size ? ` (${selected.size})` : '')))),
|
|
395
|
+
visible.length === 0
|
|
396
|
+
? h('div', { key: 'empty', className: 'rz-empty' }, overOnly ? t('overOnly') + ' · ' + t('emptyContext') : t('emptyContext'))
|
|
397
|
+
: h('div', { key: 'list', className: 'rz-list' },
|
|
398
|
+
h(PagedList, { items: visible, t, render: entry => {
|
|
399
|
+
const over = overThreshold(entry, threshold)
|
|
400
|
+
return h('div', { key: entry.seq, className: 'rz-row' + (over ? ' over' : '') + (selected.has(entry.seq) ? ' checked' : ''),
|
|
401
|
+
role: 'button', tabIndex: 0, onClick: () => toggleRow(entry.seq),
|
|
402
|
+
onKeyDown: e => e.key === 'Enter' && toggleRow(entry.seq) },
|
|
403
|
+
h('input', { type: 'checkbox', checked: selected.has(entry.seq), onClick: e => e.stopPropagation(), onChange: () => toggleRow(entry.seq) }),
|
|
404
|
+
h(Chip, { kind: entry.kind, label: t(kindI18n(entry.kind)) }),
|
|
405
|
+
h('span', { className: 'rz-row-preview', title: entry.preview }, entry.preview || ' '),
|
|
406
|
+
h(TokenBadge, { entry, threshold }),
|
|
407
|
+
h('button', { className: 'rz-btn', style: { minHeight: 24, padding: '2px 8px' },
|
|
408
|
+
onClick: e => { e.stopPropagation(); openDetail(entry) } }, '⋯'),
|
|
409
|
+
h('span', { className: 'rz-row-meta' }, 'seq ' + entry.seq))
|
|
410
|
+
} })),
|
|
411
|
+
detail && h(DetailModal, { detail, threshold, t, onClose: () => setDetail(null) }),
|
|
412
|
+
confirming && h(ConfirmDialog, { n: selected.size, tokens: formatNum(selectedTokens), deleting, t,
|
|
413
|
+
onCancel: () => setConfirming(false), onOk: doDelete }),
|
|
414
|
+
],
|
|
415
|
+
toast && h('div', { className: 'rz-toast' }, toast))
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── Plugin plane contract ────────────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
const CLIENT_NAME = '@weibaohui/context-razor'
|
|
421
|
+
|
|
422
|
+
module.exports = {
|
|
423
|
+
name: CLIENT_NAME,
|
|
424
|
+
inject: ['slots', 'locale'],
|
|
425
|
+
__internals: { NS, ZH, EN, overThreshold, sortEntries },
|
|
426
|
+
__boot(container, opts = {}) {
|
|
427
|
+
ensureStyles()
|
|
428
|
+
const t = opts.t || ((key, vars) => {
|
|
429
|
+
let out = EN[key] ?? key
|
|
430
|
+
if (vars) for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v))
|
|
431
|
+
return out
|
|
432
|
+
})
|
|
433
|
+
const root = require('react-dom/client').createRoot(container)
|
|
434
|
+
root.render(h(RazorPage, { t }))
|
|
435
|
+
return root
|
|
436
|
+
},
|
|
437
|
+
apply(ctx) {
|
|
438
|
+
let t = (key, vars) => {
|
|
439
|
+
let out = EN[key] ?? key
|
|
440
|
+
if (vars) for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v))
|
|
441
|
+
return out
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
if (ctx.locale && typeof ctx.locale.register === 'function') {
|
|
445
|
+
ctx.locale.register(NS, 'zh', ZH)
|
|
446
|
+
ctx.locale.register(NS, 'en', EN)
|
|
447
|
+
const bound = typeof ctx.locale.bind === 'function' ? ctx.locale.bind(NS) : null
|
|
448
|
+
if (bound) t = (key, vars) => {
|
|
449
|
+
let out = bound(key) || key
|
|
450
|
+
if (vars) for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v))
|
|
451
|
+
return out
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
} catch (e) { try { console.error('[context-razor] locale init:', e) } catch {} }
|
|
455
|
+
ctx.effect(() => {
|
|
456
|
+
try {
|
|
457
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
458
|
+
name: 'settings.section',
|
|
459
|
+
id: CLIENT_NAME,
|
|
460
|
+
order: 91,
|
|
461
|
+
locale: NS,
|
|
462
|
+
label: () => t('title'),
|
|
463
|
+
inject: () => ({}),
|
|
464
|
+
}, function RazorSectionSlot() {
|
|
465
|
+
useEffect(ensureStyles, [])
|
|
466
|
+
return h(RazorPage, { t })
|
|
467
|
+
}))
|
|
468
|
+
} catch (e) { (globalThis.__rzErrors = globalThis.__rzErrors || []).push('settings:' + (e && e.message)); throw e }
|
|
469
|
+
}, 'context-razor: settings section')
|
|
470
|
+
},
|
|
471
|
+
}
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# dsh-plugin-context-razor bundle patch: contributes the plugin row into the host
|
|
2
|
+
# composition when this package is installed via `dsh plugin add`.
|
|
3
|
+
#
|
|
4
|
+
# The plugin is HOST-PLANE: it reads host session logs (ctx.sessions) and
|
|
5
|
+
# serves its HTTP API over a webServer route; its client half registers a
|
|
6
|
+
# settings section. It must therefore land in the host composition (this
|
|
7
|
+
# insert), never inside an agent preset.
|
|
8
|
+
|
|
9
|
+
- insert:
|
|
10
|
+
- id: context-razor
|
|
11
|
+
name: '@weibaohui/context-razor'
|
package/docs/demo.gif
ADDED
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@weibaohui/context-razor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "dsh 插件 · 上下文剃刀:把当前会话上下文逐条列出(角色/预览/≈token,cl100k 估算),超阈值标红,勾选后精确裁剪——不经 LLM 总结,删了什么一目了然。",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"dsh",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"cordis",
|
|
10
|
+
"plugin",
|
|
11
|
+
"context",
|
|
12
|
+
"token",
|
|
13
|
+
"trim",
|
|
14
|
+
"razor",
|
|
15
|
+
"dsh-plugin"
|
|
16
|
+
],
|
|
17
|
+
"main": "src/index.js",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.js",
|
|
20
|
+
"./client": "./client/bundle.js",
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"dsh": {
|
|
24
|
+
"bundle": {
|
|
25
|
+
"patch": "./cordis.patch.yml"
|
|
26
|
+
},
|
|
27
|
+
"client": {
|
|
28
|
+
"platform": "web"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"src",
|
|
33
|
+
"client",
|
|
34
|
+
"docs",
|
|
35
|
+
"cordis.patch.yml"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"check": "node --check src/index.js && node --check client/index.js",
|
|
39
|
+
"test": "node --test test/*.test.mjs",
|
|
40
|
+
"build:client": "node scripts/build-client.mjs",
|
|
41
|
+
"prepublishOnly": "npm run build:client && npm run check && npm test"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=22.5"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"js-tiktoken": "^1.0.21"
|
|
48
|
+
},
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "git+https://github.com/weibaohui/context-razor.git"
|
|
52
|
+
},
|
|
53
|
+
"homepage": "https://github.com/weibaohui/context-razor#readme",
|
|
54
|
+
"bugs": {
|
|
55
|
+
"url": "https://github.com/weibaohui/context-razor/issues"
|
|
56
|
+
},
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public"
|
|
59
|
+
}
|
|
60
|
+
}
|