@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/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# @weibaohui/context-razor
|
|
2
|
+
|
|
3
|
+
[](https://github.com/topics/dsh-plugin)
|
|
4
|
+
[](https://www.npmjs.com/package/@weibaohui/context-razor)
|
|
5
|
+
|
|
6
|
+
**上下文剃刀**:把当前会话上下文逐条列出来——角色、预览、≈token(cl100k_base 估算,与技能市场同词表)——超阈值标红,勾选后精确裁剪。压缩不知道裁了什么,剃刀让你自己挑。
|
|
7
|
+
|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
## 核心功能
|
|
11
|
+
|
|
12
|
+
- **逐条列出**:当前会话模型可见的每条上下文(用户/助手/工具结果)按序排列,带预览、字符数与 ≈token 估算,点 ⋯ 查看全文与真实 usage
|
|
13
|
+
- **超阈值标红**:可调阈值(默认 500 token,本地记忆),超限条目红色高亮;「只看超阈值」过滤 + 「token 高→低」排序,大头一眼可见
|
|
14
|
+
- **精确裁剪**:勾选任意条目一键删除——连续段自动合并处理。删除走宿主 surface replace 协议(与官方 compaction 同款机制),**不经 LLM 总结**,删了什么、删了多少完全由你决定
|
|
15
|
+
- **安全护栏**:会话运行中禁止裁剪(等当前回合结束);每次替换附带一条 notice 标记消息,模型知道这段历史被你移除了,需要时会主动向你确认
|
|
16
|
+
- **非破坏**:append-only 日志保留全部痕迹(宿主机制如此)。「删除」= 从模型视野与界面投影中移除
|
|
17
|
+
|
|
18
|
+
## 安装
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
dsh plugin --profile web add @weibaohui/context-razor -w
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
装完重启 `dsh web` 即生效。入口:设置窗口 → 上下文剃刀。
|
|
25
|
+
|
|
26
|
+
## 删除的实现方式
|
|
27
|
+
|
|
28
|
+
dsh 会话是 append-only 事件日志(深冻结 + zstd 校验),物理删除不存在也不必要。本插件的裁剪 = 仿官方 `dsh-compaction-tool-result-pruner` 协议:对每个连续段追加一条 `compaction/prune` 计价事件 + 一条带 `surfaceOp: {op:'replace'}` 的 notice 标记消息(`sourceEventSeqs` 覆盖全部被影子化节点)。被删节点从此不进 `deriveMessages()`——模型看不到、UI 不再渲染、token 不再计入上下文,而日志留痕可审计。
|
|
29
|
+
|
|
30
|
+
## 联系我 :飞书群
|
|
31
|
+
|
|
32
|
+

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