@weibaohui/context-razor 0.1.0 → 0.3.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 CHANGED
@@ -12,6 +12,7 @@
12
12
  - **逐条列出**:当前会话模型可见的每条上下文(用户/助手/工具结果)按序排列,带预览、字符数与 ≈token 估算,点 ⋯ 查看全文与真实 usage
13
13
  - **超阈值标红**:可调阈值(默认 500 token,本地记忆),超限条目红色高亮;「只看超阈值」过滤 + 「token 高→低」排序,大头一眼可见
14
14
  - **精确裁剪**:勾选任意条目一键删除——连续段自动合并处理。删除走宿主 surface replace 协议(与官方 compaction 同款机制),**不经 LLM 总结**,删了什么、删了多少完全由你决定
15
+ - **不重排**:本插件不会改变上下文条目顺序,只会剔除选中条目;排序下拉仅影响页面显示(上下文顺序 / 原始序号 / token 高→低),模型可见顺序始终由会话 surface 决定
15
16
  - **安全护栏**:会话运行中禁止裁剪(等当前回合结束);每次替换附带一条 notice 标记消息,模型知道这段历史被你移除了,需要时会主动向你确认
16
17
  - **非破坏**:append-only 日志保留全部痕迹(宿主机制如此)。「删除」= 从模型视野与界面投影中移除
17
18
 
@@ -21,7 +22,7 @@
21
22
  dsh plugin --profile web add @weibaohui/context-razor -w
22
23
  ```
23
24
 
24
- 装完重启 `dsh web` 即生效。入口:设置窗口上下文剃刀。
25
+ 装完重启 `dsh web` 即生效。入口:打开会话顶部「上下文剃刀」标签(Hermes Loop 右侧),自动锁定当前会话。
25
26
 
26
27
  ## 删除的实现方式
27
28
 
package/client/bundle.js CHANGED
@@ -11,12 +11,12 @@ window.__ModuleLoader__.load({
11
11
  /**
12
12
  * dsh-plugin-razor — Browser half.
13
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.
14
+ * Conversation-view tab surface: the model-visible context entries of the
15
+ * CURRENT session, each with a rainbow tier (≈token, log-scale buckets —
16
+ * warmer = heavier) doubling as filter buttons; select and razor away.
17
+ * All copy comes from the locale registry (`zh`/`en`); tier hues are data
18
+ * (hsl computed per bucket). No class components render errors land in
19
+ * globalThis.__rzErrors.
20
20
  */
21
21
 
22
22
  let __React = null
@@ -58,32 +58,30 @@ window.__ModuleLoader__.load({
58
58
 
59
59
  const ZH = {
60
60
  title: '上下文剃刀',
61
- hint: '逐条列出会话上下文(≈token 为 cl100k_base 估算,与技能市场同词表)。删除 = 从模型视野移除(append-only 日志保留痕迹,不可恢复),不经 LLM 总结。',
62
- sessionLabel: '会话',
63
- pickSession: '选择会话…',
61
+ pickSession: '在会话顶部标签打开以查看上下文',
64
62
  loading: '正在加载…',
65
63
  refresh: '刷新',
66
64
  busyTag: '运行中',
67
- idleTag: '空闲',
68
- thresholdLabel: '标红阈值',
69
- thresholdUnit: 'token',
70
65
  sortLabel: '排序',
71
66
  sortOrder: '上下文顺序',
67
+ sortSeq: '原始序号',
72
68
  sortTokens: 'token 高→低',
73
- overOnly: '只看超阈值',
74
- selectOver: '选中超阈值',
69
+ orderNote: '本插件不会改变上下文条目顺序,只会剔除选中条目。',
70
+ selectVisible: '选中可见',
71
+ selectVisibleHint: '选中当前显示的全部条目(配合色块筛选)',
75
72
  clearSelect: '清空选择',
76
73
  deleteSelected: '删除选中',
77
74
  selectedStats: '已选 {n} 条 / ≈{tokens} token',
78
75
  stats: '{nodes} 条 · 合计 ≈{tokens} token{mode}',
79
76
  modeHeuristic: '(启发式估算:词表未加载)',
80
77
  kindUser: '用户',
78
+ kindInjected: '注入',
81
79
  kindAssistant: '助手',
82
80
  kindTool: '工具',
83
81
  emptyContext: '该会话上下文为空',
84
82
  showMore: '显示更多(剩余 {n})',
85
83
  detailTitle: '条目详情',
86
- detailTokens: '{tokens} token · {chars} 字符',
84
+ detailChars: '{chars} 字符',
87
85
  detailUsage: '真实用量:输入 {input} / 输出 {output}{cache}',
88
86
  detailUsageCache: ' / 缓存读 {cache}',
89
87
  close: '关闭',
@@ -99,32 +97,30 @@ window.__ModuleLoader__.load({
99
97
 
100
98
  const EN = {
101
99
  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…',
100
+ pickSession: 'Open a conversation tab to inspect its context',
105
101
  loading: 'Loading…',
106
102
  refresh: 'Refresh',
107
103
  busyTag: 'running',
108
- idleTag: 'idle',
109
- thresholdLabel: 'Red threshold',
110
- thresholdUnit: 'tokens',
111
104
  sortLabel: 'Sort',
112
105
  sortOrder: 'Context order',
106
+ sortSeq: 'Original seq',
113
107
  sortTokens: 'tokens high→low',
114
- overOnly: 'Over threshold only',
115
- selectOver: 'Select over threshold',
108
+ orderNote: 'This plugin never reorders context entries; it only removes the selected ones.',
109
+ selectVisible: 'Select shown',
110
+ selectVisibleHint: 'Select every currently shown entry (pairs with tier filters)',
116
111
  clearSelect: 'Clear selection',
117
112
  deleteSelected: 'Delete selected',
118
113
  selectedStats: '{n} selected / ≈{tokens} tokens',
119
114
  stats: '{nodes} entries · total ≈{tokens} tokens{mode}',
120
115
  modeHeuristic: ' (heuristic: ranks unavailable)',
121
116
  kindUser: 'User',
117
+ kindInjected: 'injected',
122
118
  kindAssistant: 'Assistant',
123
119
  kindTool: 'Tool',
124
120
  emptyContext: 'This session has no context entries',
125
121
  showMore: 'Show more ({n} left)',
126
122
  detailTitle: 'Entry detail',
127
- detailTokens: '{tokens} tokens · {chars} chars',
123
+ detailChars: '{chars} chars',
128
124
  detailUsage: 'Actual usage: in {input} / out {output}{cache}',
129
125
  detailUsageCache: ' / cache-read {cache}',
130
126
  close: 'Close',
@@ -142,18 +138,12 @@ window.__ModuleLoader__.load({
142
138
 
143
139
  const API = '/context-razor/api'
144
140
  const PAGE_SIZE = 150
145
- const DEFAULT_THRESHOLD = 500
146
- const THRESHOLD_KEY = 'context-razor-threshold'
147
141
 
148
142
  const kindI18n = (key) => 'kind' + key[0].toUpperCase() + key.slice(1)
149
143
 
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 沉底)。 */
144
+ /** 排序:order = 投影原序(模型可见序);seq = 原始序号升序(裁剪过的会话里与 order 不同:标记消息 seq 大但在中间);tokens = 降序(并列按 seq,缺 token 沉底)。 */
156
145
  function sortEntries(entries, sortBy) {
146
+ if (sortBy === 'seq') return [...entries].sort((a, b) => a.seq - b.seq)
157
147
  if (sortBy !== 'tokens') return entries
158
148
  return [...entries].sort((a, b) => {
159
149
  const va = a.tokens, vb = b.tokens
@@ -166,6 +156,41 @@ window.__ModuleLoader__.load({
166
156
 
167
157
  const formatNum = (n) => Number.isFinite(n) ? n.toLocaleString('en-US') : '-'
168
158
 
159
+ /** 完整日期时间(短格式:2026/9/2 11:03;ts 兼容 epoch 毫秒与 ISO 串)。 */
160
+ function formatDateTime(ts) {
161
+ if (!ts) return ''
162
+ const t = typeof ts === 'number' ? ts : Date.parse(ts)
163
+ if (!Number.isFinite(t)) return ''
164
+ try { return new Date(t).toLocaleString([], { dateStyle: 'short', timeStyle: 'short' }) } catch { return new Date(t).toLocaleString() }
165
+ }
166
+
167
+ /** 行首 chip 的文案与提示:工具结果显示工具名,注入类 user 消息标「注入」。 */
168
+ function entryChip(entry, t) {
169
+ if (entry.kind === 'tool') {
170
+ return { kind: 'tool', label: entry.tool || t('kindTool'), title: entry.tool }
171
+ }
172
+ if (entry.kind === 'user' && entry.sourceKind && entry.sourceKind !== 'user') {
173
+ return { kind: 'user', label: t('kindInjected'), title: [entry.sourceKind, entry.sourceForm, entry.sourcePlugin].filter(Boolean).join(' · ') }
174
+ }
175
+ return { kind: entry.kind, label: t(kindI18n(entry.kind)) }
176
+ }
177
+
178
+ // ── 彩虹分级:颜色越暖 = 占用越多 ────────────────────────────────────────
179
+ // 固定对数档位(相邻约 ×2.5),跨会话语义稳定:绿→黄绿→黄→橙→红→品红。
180
+ // 档位是「这条消息吃掉多少典型上下文预算」的粗标尺,不随会话内最大值缩放。
181
+ const RAZOR_TIERS = [
182
+ { max: 100, hue: 120, label: '0-100' },
183
+ { max: 300, hue: 90, label: '100-300' },
184
+ { max: 800, hue: 60, label: '300-800' },
185
+ { max: 2000, hue: 32, label: '800-2k' },
186
+ { max: 5000, hue: 8, label: '2k-5k' },
187
+ { max: Infinity, hue: 320, label: '>5k' },
188
+ ]
189
+ function tierOf(entry) {
190
+ const tokens = typeof entry.tokens === 'number' ? entry.tokens : 0
191
+ return RAZOR_TIERS.findIndex(t => tokens <= t.max)
192
+ }
193
+
169
194
  async function getJson(url) {
170
195
  const r = await fetch(url)
171
196
  if (!r.ok) {
@@ -178,28 +203,47 @@ window.__ModuleLoader__.load({
178
203
  // ── Token-based stylesheet (light/dark adaptive by construction) ────────
179
204
 
180
205
  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)}
206
+ .rz-page,.rz-page *{box-sizing:border-box}
207
+ .rz-page{position:relative;display:flex;flex-direction:column;gap:12px;padding:16px 20px;min-width:0;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);font-size:var(--dsw-font-sm-14,14px)}
182
208
  .rz-hint{color:var(--dsw-alias-label-secondary);font-size:12.5px;line-height:1.5}
183
209
  .rz-toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
184
210
  .rz-spacer{flex:1}
185
211
  .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
212
  .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
213
  .rz-stats{display:flex;gap:12px;align-items:center;flex-wrap:wrap;color:var(--dsw-alias-label-secondary);font-size:12.5px}
190
214
  .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
215
  .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
216
  .rz-chip.user{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}
194
217
  .rz-chip.assistant{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}
218
+ .rz-legend{display:inline-flex;gap:4px;align-items:center}
219
+ .rz-swatch{border:none;cursor:pointer;padding:2px 8px;border-radius:999px;font-size:10.5px;color:var(--dsw-alias-label-primary-inverted,#fff);white-space:nowrap;font-family:var(--dsw-font-family)}
220
+ .rz-legend.filtering .rz-swatch:not(.on){opacity:.4}
221
+ .rz-swatch.on{box-shadow:0 0 0 2px var(--dsw-alias-border-l3,rgba(128,128,128,.65))}
222
+ .rz-swatch.tier-0{background:hsl(120,55%,40%)}
223
+ .rz-swatch.tier-1{background:hsl(90,60%,38%)}
224
+ .rz-swatch.tier-2{background:hsl(60,70%,36%)}
225
+ .rz-swatch.tier-3{background:hsl(32,80%,45%)}
226
+ .rz-swatch.tier-4{background:hsl(8,75%,48%)}
227
+ .rz-swatch.tier-5{background:hsl(320,65%,50%)}
195
228
  .rz-list{display:flex;flex-direction:column;gap:6px}
196
229
  .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
230
  .rz-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
198
- .rz-row.over{border-left-color:var(--dsw-alias-state-error-primary)}
199
231
  .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)}
232
+ .rz-row-preview{flex:0 1 340px;min-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary);font-size:13px;text-decoration:none}
233
+ .rz-row-preview:hover{text-decoration:underline;text-underline-offset:3px}
202
234
  .rz-row-meta{color:var(--dsw-alias-label-tertiary);font-size:11px;flex:none}
235
+ .rz-badge.tier-0{color:hsl(120,55%,40%);border-color:hsla(120,55%,40%,.45);background:hsla(120,55%,40%,.10)}
236
+ .rz-badge.tier-1{color:hsl(90,60%,38%);border-color:hsla(90,60%,38%,.45);background:hsla(90,60%,38%,.10)}
237
+ .rz-badge.tier-2{color:hsl(60,70%,36%);border-color:hsla(60,70%,36%,.45);background:hsla(60,70%,36%,.12)}
238
+ .rz-badge.tier-3{color:hsl(32,80%,45%);border-color:hsla(32,80%,45%,.5);background:hsla(32,80%,45%,.12)}
239
+ .rz-badge.tier-4{color:hsl(8,75%,48%);border-color:hsla(8,75%,48%,.5);background:hsla(8,75%,48%,.12);font-weight:600}
240
+ .rz-badge.tier-5{color:hsl(320,65%,50%);border-color:hsla(320,65%,50%,.5);background:hsla(320,65%,50%,.12);font-weight:600}
241
+ .rz-row.tier-0{border-left-color:hsl(120,55%,40%)}
242
+ .rz-row.tier-1{border-left-color:hsl(90,60%,38%)}
243
+ .rz-row.tier-2{border-left-color:hsl(60,70%,36%)}
244
+ .rz-row.tier-3{border-left-color:hsl(32,80%,45%)}
245
+ .rz-row.tier-4{border-left-color:hsl(8,75%,48%)}
246
+ .rz-row.tier-5{border-left-color:hsl(320,65%,50%)}
203
247
  .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
248
  .rz-loading{padding:36px;text-align:center;color:var(--dsw-alias-label-secondary)}
205
249
  .rz-footbtns{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
@@ -218,11 +262,11 @@ window.__ModuleLoader__.load({
218
262
 
219
263
  // ── Small building blocks ────────────────────────────────────────────────
220
264
 
221
- const Chip = ({ kind, label }) => h('span', { className: 'rz-chip ' + kind }, label)
265
+ const Chip = ({ kind, label, title }) => h('span', { className: 'rz-chip ' + kind, title }, label)
222
266
 
223
- const TokenBadge = ({ entry, threshold }) => {
224
- const over = overThreshold(entry, threshold)
225
- return h('span', { className: 'rz-badge' + (over ? ' over' : ''), title: over ? ' 阈值 ' + threshold : undefined },
267
+ const TokenBadge = ({ entry }) => {
268
+ const tier = tierOf(entry)
269
+ return h('span', { className: 'rz-badge tier-' + tier, title: RAZOR_TIERS[tier].label + ' token' },
226
270
  '≈' + formatNum(entry.tokens))
227
271
  }
228
272
 
@@ -238,15 +282,16 @@ window.__ModuleLoader__.load({
238
282
  }
239
283
 
240
284
  /** 单条全文弹窗(正文经 /entry 异步补全)。 */
241
- function DetailModal({ detail, threshold, t, onClose }) {
285
+ function DetailModal({ detail, t, onClose }) {
242
286
  if (!detail) return null
243
287
  return h('div', { className: 'rz-dlg-backdrop', onClick: onClose },
244
288
  h('div', { className: 'rz-dlg', onClick: e => e.stopPropagation() },
245
289
  h('h3', null, t('detailTitle') + ' · ' + t('seqLabel', { seq: detail.seq })),
246
290
  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) })),
291
+ h(Chip, { ...entryChip(detail, t) }),
292
+ h(TokenBadge, { entry: detail }),
293
+ h('span', { className: 'rz-label', title: 'seq ' + detail.seq }, formatDateTime(detail.time)),
294
+ h('span', { className: 'rz-label' }, t('detailChars', { chars: formatNum(detail.chars) })),
250
295
  detail.usage && h('span', { className: 'rz-label' }, t('detailUsage', {
251
296
  input: formatNum(detail.usage.input), output: formatNum(detail.usage.output),
252
297
  cache: detail.usage.cacheRead ? t('detailUsageCache', { cache: formatNum(detail.usage.cacheRead) }) : '' }))),
@@ -268,17 +313,14 @@ window.__ModuleLoader__.load({
268
313
 
269
314
  // ── Page ────────────────────────────────────────────────────────────────
270
315
 
271
- function RazorPage({ t }) {
272
- const [sessions, setSessions] = useState(null)
316
+ function RazorPage({ t, fixedSessionId }) {
317
+ useEffect(ensureStyles, [])
273
318
  const [sessionId, setSessionId] = useState('')
274
319
  const [context, setContext] = useState(null)
275
320
  const [ctxLoading, setCtxLoading] = useState(false)
276
321
  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
- })
322
+ const [tierFilter, setTierFilter] = useState(() => new Set())
280
323
  const [sortBy, setSortBy] = useState('order')
281
- const [overOnly, setOverOnly] = useState(false)
282
324
  const [selected, setSelected] = useState(() => new Set())
283
325
  const [detail, setDetail] = useState(null)
284
326
  const [confirming, setConfirming] = useState(false)
@@ -288,8 +330,10 @@ window.__ModuleLoader__.load({
288
330
 
289
331
  const showToast = (text) => { setToast(text); setTimeout(() => setToast(null), 3000) }
290
332
 
291
- const loadSessions = () => getJson(API + '/sessions').then(d => setSessions(d.sessions || [])).catch(e => setError(e.message))
292
- useEffect(() => { loadSessions() }, [])
333
+ // 会话视图挂载:只管当前会话(fixedSessionId 来自 slot props)
334
+ useEffect(() => {
335
+ if (fixedSessionId) { sessionRef.current = fixedSessionId; setSessionId(fixedSessionId) }
336
+ }, [fixedSessionId])
293
337
 
294
338
  const loadContext = (id) => {
295
339
  if (!id) { setContext(null); return }
@@ -303,25 +347,16 @@ window.__ModuleLoader__.load({
303
347
  useEffect(() => { if (sessionId) loadContext(sessionId) }, [sessionId])
304
348
 
305
349
  const refreshAll = () => {
306
- loadSessions()
307
350
  if (sessionRef.current) loadContext(sessionRef.current)
308
351
  }
309
352
 
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
353
  const visible = useMemo(() => {
318
354
  if (!context) return []
319
355
  let rows = context.entries
320
- if (overOnly && threshold > 0) rows = rows.filter(e => overThreshold(e, threshold))
356
+ if (tierFilter.size > 0) rows = rows.filter(e => tierFilter.has(tierOf(e)))
321
357
  return sortEntries(rows, sortBy)
322
- }, [context, overOnly, threshold, sortBy])
358
+ }, [context, tierFilter, sortBy])
323
359
 
324
- const overList = useMemo(() => (context ? context.entries.filter(e => overThreshold(e, threshold)) : []), [context, threshold])
325
360
  const selectedTokens = useMemo(() => {
326
361
  if (!context) return 0
327
362
  const bySeq = new Map(context.entries.map(e => [e.seq, e]))
@@ -336,12 +371,18 @@ window.__ModuleLoader__.load({
336
371
  else next.add(seq)
337
372
  return next
338
373
  })
339
- const selectOver = () => setSelected(new Set(overList.map(e => e.seq)))
374
+ const toggleTier = (i) => setTierFilter(prev => {
375
+ const next = new Set(prev)
376
+ if (next.has(i)) next.delete(i)
377
+ else next.add(i)
378
+ return next
379
+ })
380
+ const selectVisible = () => setSelected(new Set(visible.map(e => e.seq)))
340
381
 
341
382
  const openDetail = (entry) => {
342
383
  setDetail(entry)
343
384
  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))
385
+ .then(d => setDetail(cur => (cur && cur.seq === entry.seq) ? { ...cur, text: d.text, tokens: d.tokens, tool: d.tool, sourceKind: d.sourceKind, sourceForm: d.sourceForm, sourcePlugin: d.sourcePlugin } : cur))
345
386
  .catch(() => {})
346
387
  }
347
388
 
@@ -365,60 +406,49 @@ window.__ModuleLoader__.load({
365
406
  const canDelete = selected.size > 0 && !busy && !deleting
366
407
 
367
408
  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
409
  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'))),
410
+ !sessionId && h('div', { className: 'rz-empty' }, t('pickSession')),
381
411
  sessionId && ctxLoading && h('div', { className: 'rz-loading' }, t('loading')),
382
412
  sessionId && !ctxLoading && context && [
383
- h('div', { key: 'stats', className: 'rz-stats' },
413
+ h('div', { key: 'note', className: 'rz-hint' }, t('orderNote')),
414
+ h('div', { key: 'bar', className: 'rz-stats' },
384
415
  h('span', null, t('stats', { nodes: context.nodes, tokens: formatNum(context.totalTokens), mode: context.encoder === 'heuristic' ? t('modeHeuristic') : '' })),
416
+ h('span', { className: 'rz-legend' + (tierFilter.size > 0 ? ' filtering' : ''), title: t('legendTitle') },
417
+ RAZOR_TIERS.map((tr, i) => h('button', { key: i, className: 'rz-swatch tier-' + i + (tierFilter.has(i) ? ' on' : ''), title: tr.label + ' token', onClick: () => toggleTier(i) }, tr.label))),
385
418
  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')),
419
+ busy && h('span', { className: 'rz-badge' }, t('busyTag')),
420
+ h('button', { className: 'rz-btn', onClick: selectVisible, disabled: visible.length === 0 || busy, title: t('selectVisibleHint') },
421
+ t('selectVisible') + (visible.length ? ` (${visible.length})` : '')),
390
422
  h('select', { className: 'rz-select', value: sortBy, onChange: e => setSortBy(e.target.value), title: t('sortLabel'), 'aria-label': t('sortLabel') },
391
423
  h('option', { value: 'order' }, t('sortOrder')),
424
+ h('option', { value: 'seq' }, t('sortSeq')),
392
425
  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) }) : ' '),
426
+ h('button', { className: 'rz-btn', onClick: refreshAll, title: t('refresh') }, t('refresh'))),
427
+ selected.size > 0 && h('div', { key: 'sel', className: 'rz-stats' },
428
+ h('span', null, t('selectedStats', { n: selected.size, tokens: formatNum(selectedTokens) })),
397
429
  h('span', { className: 'rz-spacer' }),
398
430
  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')),
431
+ h('button', { className: 'rz-btn', onClick: () => setSelected(new Set()) }, t('clearSelect')),
401
432
  h('button', { className: 'rz-btn rz-btn-danger', disabled: !canDelete,
402
433
  title: busy ? t('deleteBusyHint') : undefined,
403
434
  onClick: () => setConfirming(true) },
404
- t('deleteSelected') + (selected.size ? ` (${selected.size})` : '')))),
435
+ t('deleteSelected') + ` (${selected.size})`))),
405
436
  visible.length === 0
406
- ? h('div', { key: 'empty', className: 'rz-empty' }, overOnly ? t('overOnly') + ' · ' + t('emptyContext') : t('emptyContext'))
437
+ ? h('div', { key: 'empty', className: 'rz-empty' }, t('emptyContext'))
407
438
  : h('div', { key: 'list', className: 'rz-list' },
408
439
  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' : ''),
440
+ const tier = tierOf(entry)
441
+ return h('div', { key: entry.seq, className: 'rz-row tier-' + tier + (selected.has(entry.seq) ? ' checked' : ''),
411
442
  role: 'button', tabIndex: 0, onClick: () => toggleRow(entry.seq),
412
443
  onKeyDown: e => e.key === 'Enter' && toggleRow(entry.seq) },
413
444
  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))
445
+ h(TokenBadge, { entry }),
446
+ h(Chip, { ...entryChip(entry, t) }),
447
+ h('span', { className: 'rz-row-preview', title: entry.preview,
448
+ onClick: e => { e.stopPropagation(); openDetail(entry) } }, entry.preview || ' '),
449
+ h('span', { className: 'rz-row-meta', title: 'seq ' + entry.seq }, formatDateTime(entry.time)))
420
450
  } })),
421
- detail && h(DetailModal, { detail, threshold, t, onClose: () => setDetail(null) }),
451
+ detail && h(DetailModal, { detail, t, onClose: () => setDetail(null) }),
422
452
  confirming && h(ConfirmDialog, { n: selected.size, tokens: formatNum(selectedTokens), deleting, t,
423
453
  onCancel: () => setConfirming(false), onOk: doDelete }),
424
454
  ],
@@ -432,7 +462,7 @@ window.__ModuleLoader__.load({
432
462
  module.exports = {
433
463
  name: CLIENT_NAME,
434
464
  inject: ['slots', 'locale'],
435
- __internals: { NS, ZH, EN, overThreshold, sortEntries },
465
+ __internals: { NS, ZH, EN, sortEntries, tierOf, RAZOR_TIERS, formatDateTime, entryChip },
436
466
  __boot(container, opts = {}) {
437
467
  ensureStyles()
438
468
  const t = opts.t || ((key, vars) => {
@@ -462,21 +492,20 @@ window.__ModuleLoader__.load({
462
492
  }
463
493
  }
464
494
  } catch (e) { try { console.error('[context-razor] locale init:', e) } catch {} }
495
+ // 会话视图标签:order 30 排在 Chat(0)/Trajectory(10)/Context(20)/Hermes Loop(25) 之后
465
496
  ctx.effect(() => {
466
497
  try {
467
- ctx.slots.inject('settings.section', () => ctx.slots.register({
468
- name: 'settings.section',
498
+ ctx.slots.inject('conversation.view', () => ctx.slots.register({
499
+ name: 'conversation.view',
469
500
  id: CLIENT_NAME,
470
- order: 91,
501
+ order: 30,
471
502
  locale: NS,
472
503
  label: () => t('title'),
473
- inject: () => ({}),
474
- }, function RazorSectionSlot() {
475
- useEffect(ensureStyles, [])
476
- return h(RazorPage, { t })
504
+ }, function RazorViewSlot(props) {
505
+ return h(RazorPage, { t, fixedSessionId: props && props.sessionId })
477
506
  }))
478
- } catch (e) { (globalThis.__rzErrors = globalThis.__rzErrors || []).push('settings:' + (e && e.message)); throw e }
479
- }, 'context-razor: settings section')
507
+ } catch (e) { (globalThis.__rzErrors = globalThis.__rzErrors || []).push('conversation.view:' + (e && e.message)); throw e }
508
+ }, 'context-razor: conversation view tab')
480
509
  },
481
510
  }
482
511
 
package/client/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * dsh-plugin-razor — Browser half.
3
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.
4
+ * Conversation-view tab surface: the model-visible context entries of the
5
+ * CURRENT session, each with a rainbow tier (≈token, log-scale buckets —
6
+ * warmer = heavier) doubling as filter buttons; select and razor away.
7
+ * All copy comes from the locale registry (`zh`/`en`); tier hues are data
8
+ * (hsl computed per bucket). No class components render errors land in
9
+ * globalThis.__rzErrors.
10
10
  */
11
11
 
12
12
  let __React = null
@@ -48,32 +48,30 @@ const NS = 'contextRazor'
48
48
 
49
49
  const ZH = {
50
50
  title: '上下文剃刀',
51
- hint: '逐条列出会话上下文(≈token 为 cl100k_base 估算,与技能市场同词表)。删除 = 从模型视野移除(append-only 日志保留痕迹,不可恢复),不经 LLM 总结。',
52
- sessionLabel: '会话',
53
- pickSession: '选择会话…',
51
+ pickSession: '在会话顶部标签打开以查看上下文',
54
52
  loading: '正在加载…',
55
53
  refresh: '刷新',
56
54
  busyTag: '运行中',
57
- idleTag: '空闲',
58
- thresholdLabel: '标红阈值',
59
- thresholdUnit: 'token',
60
55
  sortLabel: '排序',
61
56
  sortOrder: '上下文顺序',
57
+ sortSeq: '原始序号',
62
58
  sortTokens: 'token 高→低',
63
- overOnly: '只看超阈值',
64
- selectOver: '选中超阈值',
59
+ orderNote: '本插件不会改变上下文条目顺序,只会剔除选中条目。',
60
+ selectVisible: '选中可见',
61
+ selectVisibleHint: '选中当前显示的全部条目(配合色块筛选)',
65
62
  clearSelect: '清空选择',
66
63
  deleteSelected: '删除选中',
67
64
  selectedStats: '已选 {n} 条 / ≈{tokens} token',
68
65
  stats: '{nodes} 条 · 合计 ≈{tokens} token{mode}',
69
66
  modeHeuristic: '(启发式估算:词表未加载)',
70
67
  kindUser: '用户',
68
+ kindInjected: '注入',
71
69
  kindAssistant: '助手',
72
70
  kindTool: '工具',
73
71
  emptyContext: '该会话上下文为空',
74
72
  showMore: '显示更多(剩余 {n})',
75
73
  detailTitle: '条目详情',
76
- detailTokens: '{tokens} token · {chars} 字符',
74
+ detailChars: '{chars} 字符',
77
75
  detailUsage: '真实用量:输入 {input} / 输出 {output}{cache}',
78
76
  detailUsageCache: ' / 缓存读 {cache}',
79
77
  close: '关闭',
@@ -89,32 +87,30 @@ const ZH = {
89
87
 
90
88
  const EN = {
91
89
  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…',
90
+ pickSession: 'Open a conversation tab to inspect its context',
95
91
  loading: 'Loading…',
96
92
  refresh: 'Refresh',
97
93
  busyTag: 'running',
98
- idleTag: 'idle',
99
- thresholdLabel: 'Red threshold',
100
- thresholdUnit: 'tokens',
101
94
  sortLabel: 'Sort',
102
95
  sortOrder: 'Context order',
96
+ sortSeq: 'Original seq',
103
97
  sortTokens: 'tokens high→low',
104
- overOnly: 'Over threshold only',
105
- selectOver: 'Select over threshold',
98
+ orderNote: 'This plugin never reorders context entries; it only removes the selected ones.',
99
+ selectVisible: 'Select shown',
100
+ selectVisibleHint: 'Select every currently shown entry (pairs with tier filters)',
106
101
  clearSelect: 'Clear selection',
107
102
  deleteSelected: 'Delete selected',
108
103
  selectedStats: '{n} selected / ≈{tokens} tokens',
109
104
  stats: '{nodes} entries · total ≈{tokens} tokens{mode}',
110
105
  modeHeuristic: ' (heuristic: ranks unavailable)',
111
106
  kindUser: 'User',
107
+ kindInjected: 'injected',
112
108
  kindAssistant: 'Assistant',
113
109
  kindTool: 'Tool',
114
110
  emptyContext: 'This session has no context entries',
115
111
  showMore: 'Show more ({n} left)',
116
112
  detailTitle: 'Entry detail',
117
- detailTokens: '{tokens} tokens · {chars} chars',
113
+ detailChars: '{chars} chars',
118
114
  detailUsage: 'Actual usage: in {input} / out {output}{cache}',
119
115
  detailUsageCache: ' / cache-read {cache}',
120
116
  close: 'Close',
@@ -132,18 +128,12 @@ const EN = {
132
128
 
133
129
  const API = '/context-razor/api'
134
130
  const PAGE_SIZE = 150
135
- const DEFAULT_THRESHOLD = 500
136
- const THRESHOLD_KEY = 'context-razor-threshold'
137
131
 
138
132
  const kindI18n = (key) => 'kind' + key[0].toUpperCase() + key.slice(1)
139
133
 
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 沉底)。 */
134
+ /** 排序:order = 投影原序(模型可见序);seq = 原始序号升序(裁剪过的会话里与 order 不同:标记消息 seq 大但在中间);tokens = 降序(并列按 seq,缺 token 沉底)。 */
146
135
  function sortEntries(entries, sortBy) {
136
+ if (sortBy === 'seq') return [...entries].sort((a, b) => a.seq - b.seq)
147
137
  if (sortBy !== 'tokens') return entries
148
138
  return [...entries].sort((a, b) => {
149
139
  const va = a.tokens, vb = b.tokens
@@ -156,6 +146,41 @@ function sortEntries(entries, sortBy) {
156
146
 
157
147
  const formatNum = (n) => Number.isFinite(n) ? n.toLocaleString('en-US') : '-'
158
148
 
149
+ /** 完整日期时间(短格式:2026/9/2 11:03;ts 兼容 epoch 毫秒与 ISO 串)。 */
150
+ function formatDateTime(ts) {
151
+ if (!ts) return ''
152
+ const t = typeof ts === 'number' ? ts : Date.parse(ts)
153
+ if (!Number.isFinite(t)) return ''
154
+ try { return new Date(t).toLocaleString([], { dateStyle: 'short', timeStyle: 'short' }) } catch { return new Date(t).toLocaleString() }
155
+ }
156
+
157
+ /** 行首 chip 的文案与提示:工具结果显示工具名,注入类 user 消息标「注入」。 */
158
+ function entryChip(entry, t) {
159
+ if (entry.kind === 'tool') {
160
+ return { kind: 'tool', label: entry.tool || t('kindTool'), title: entry.tool }
161
+ }
162
+ if (entry.kind === 'user' && entry.sourceKind && entry.sourceKind !== 'user') {
163
+ return { kind: 'user', label: t('kindInjected'), title: [entry.sourceKind, entry.sourceForm, entry.sourcePlugin].filter(Boolean).join(' · ') }
164
+ }
165
+ return { kind: entry.kind, label: t(kindI18n(entry.kind)) }
166
+ }
167
+
168
+ // ── 彩虹分级:颜色越暖 = 占用越多 ────────────────────────────────────────
169
+ // 固定对数档位(相邻约 ×2.5),跨会话语义稳定:绿→黄绿→黄→橙→红→品红。
170
+ // 档位是「这条消息吃掉多少典型上下文预算」的粗标尺,不随会话内最大值缩放。
171
+ const RAZOR_TIERS = [
172
+ { max: 100, hue: 120, label: '0-100' },
173
+ { max: 300, hue: 90, label: '100-300' },
174
+ { max: 800, hue: 60, label: '300-800' },
175
+ { max: 2000, hue: 32, label: '800-2k' },
176
+ { max: 5000, hue: 8, label: '2k-5k' },
177
+ { max: Infinity, hue: 320, label: '>5k' },
178
+ ]
179
+ function tierOf(entry) {
180
+ const tokens = typeof entry.tokens === 'number' ? entry.tokens : 0
181
+ return RAZOR_TIERS.findIndex(t => tokens <= t.max)
182
+ }
183
+
159
184
  async function getJson(url) {
160
185
  const r = await fetch(url)
161
186
  if (!r.ok) {
@@ -168,28 +193,47 @@ async function getJson(url) {
168
193
  // ── Token-based stylesheet (light/dark adaptive by construction) ────────
169
194
 
170
195
  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)}
196
+ .rz-page,.rz-page *{box-sizing:border-box}
197
+ .rz-page{position:relative;display:flex;flex-direction:column;gap:12px;padding:16px 20px;min-width:0;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);font-size:var(--dsw-font-sm-14,14px)}
172
198
  .rz-hint{color:var(--dsw-alias-label-secondary);font-size:12.5px;line-height:1.5}
173
199
  .rz-toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
174
200
  .rz-spacer{flex:1}
175
201
  .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
202
  .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
203
  .rz-stats{display:flex;gap:12px;align-items:center;flex-wrap:wrap;color:var(--dsw-alias-label-secondary);font-size:12.5px}
180
204
  .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
205
  .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
206
  .rz-chip.user{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}
184
207
  .rz-chip.assistant{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}
208
+ .rz-legend{display:inline-flex;gap:4px;align-items:center}
209
+ .rz-swatch{border:none;cursor:pointer;padding:2px 8px;border-radius:999px;font-size:10.5px;color:var(--dsw-alias-label-primary-inverted,#fff);white-space:nowrap;font-family:var(--dsw-font-family)}
210
+ .rz-legend.filtering .rz-swatch:not(.on){opacity:.4}
211
+ .rz-swatch.on{box-shadow:0 0 0 2px var(--dsw-alias-border-l3,rgba(128,128,128,.65))}
212
+ .rz-swatch.tier-0{background:hsl(120,55%,40%)}
213
+ .rz-swatch.tier-1{background:hsl(90,60%,38%)}
214
+ .rz-swatch.tier-2{background:hsl(60,70%,36%)}
215
+ .rz-swatch.tier-3{background:hsl(32,80%,45%)}
216
+ .rz-swatch.tier-4{background:hsl(8,75%,48%)}
217
+ .rz-swatch.tier-5{background:hsl(320,65%,50%)}
185
218
  .rz-list{display:flex;flex-direction:column;gap:6px}
186
219
  .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
220
  .rz-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
188
- .rz-row.over{border-left-color:var(--dsw-alias-state-error-primary)}
189
221
  .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)}
222
+ .rz-row-preview{flex:0 1 340px;min-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary);font-size:13px;text-decoration:none}
223
+ .rz-row-preview:hover{text-decoration:underline;text-underline-offset:3px}
192
224
  .rz-row-meta{color:var(--dsw-alias-label-tertiary);font-size:11px;flex:none}
225
+ .rz-badge.tier-0{color:hsl(120,55%,40%);border-color:hsla(120,55%,40%,.45);background:hsla(120,55%,40%,.10)}
226
+ .rz-badge.tier-1{color:hsl(90,60%,38%);border-color:hsla(90,60%,38%,.45);background:hsla(90,60%,38%,.10)}
227
+ .rz-badge.tier-2{color:hsl(60,70%,36%);border-color:hsla(60,70%,36%,.45);background:hsla(60,70%,36%,.12)}
228
+ .rz-badge.tier-3{color:hsl(32,80%,45%);border-color:hsla(32,80%,45%,.5);background:hsla(32,80%,45%,.12)}
229
+ .rz-badge.tier-4{color:hsl(8,75%,48%);border-color:hsla(8,75%,48%,.5);background:hsla(8,75%,48%,.12);font-weight:600}
230
+ .rz-badge.tier-5{color:hsl(320,65%,50%);border-color:hsla(320,65%,50%,.5);background:hsla(320,65%,50%,.12);font-weight:600}
231
+ .rz-row.tier-0{border-left-color:hsl(120,55%,40%)}
232
+ .rz-row.tier-1{border-left-color:hsl(90,60%,38%)}
233
+ .rz-row.tier-2{border-left-color:hsl(60,70%,36%)}
234
+ .rz-row.tier-3{border-left-color:hsl(32,80%,45%)}
235
+ .rz-row.tier-4{border-left-color:hsl(8,75%,48%)}
236
+ .rz-row.tier-5{border-left-color:hsl(320,65%,50%)}
193
237
  .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
238
  .rz-loading{padding:36px;text-align:center;color:var(--dsw-alias-label-secondary)}
195
239
  .rz-footbtns{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
@@ -208,11 +252,11 @@ const STYLE = `<style>
208
252
 
209
253
  // ── Small building blocks ────────────────────────────────────────────────
210
254
 
211
- const Chip = ({ kind, label }) => h('span', { className: 'rz-chip ' + kind }, label)
255
+ const Chip = ({ kind, label, title }) => h('span', { className: 'rz-chip ' + kind, title }, label)
212
256
 
213
- const TokenBadge = ({ entry, threshold }) => {
214
- const over = overThreshold(entry, threshold)
215
- return h('span', { className: 'rz-badge' + (over ? ' over' : ''), title: over ? ' 阈值 ' + threshold : undefined },
257
+ const TokenBadge = ({ entry }) => {
258
+ const tier = tierOf(entry)
259
+ return h('span', { className: 'rz-badge tier-' + tier, title: RAZOR_TIERS[tier].label + ' token' },
216
260
  '≈' + formatNum(entry.tokens))
217
261
  }
218
262
 
@@ -228,15 +272,16 @@ function PagedList({ items, render, t }) {
228
272
  }
229
273
 
230
274
  /** 单条全文弹窗(正文经 /entry 异步补全)。 */
231
- function DetailModal({ detail, threshold, t, onClose }) {
275
+ function DetailModal({ detail, t, onClose }) {
232
276
  if (!detail) return null
233
277
  return h('div', { className: 'rz-dlg-backdrop', onClick: onClose },
234
278
  h('div', { className: 'rz-dlg', onClick: e => e.stopPropagation() },
235
279
  h('h3', null, t('detailTitle') + ' · ' + t('seqLabel', { seq: detail.seq })),
236
280
  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) })),
281
+ h(Chip, { ...entryChip(detail, t) }),
282
+ h(TokenBadge, { entry: detail }),
283
+ h('span', { className: 'rz-label', title: 'seq ' + detail.seq }, formatDateTime(detail.time)),
284
+ h('span', { className: 'rz-label' }, t('detailChars', { chars: formatNum(detail.chars) })),
240
285
  detail.usage && h('span', { className: 'rz-label' }, t('detailUsage', {
241
286
  input: formatNum(detail.usage.input), output: formatNum(detail.usage.output),
242
287
  cache: detail.usage.cacheRead ? t('detailUsageCache', { cache: formatNum(detail.usage.cacheRead) }) : '' }))),
@@ -258,17 +303,14 @@ function ConfirmDialog({ n, tokens, deleting, t, onCancel, onOk }) {
258
303
 
259
304
  // ── Page ────────────────────────────────────────────────────────────────
260
305
 
261
- function RazorPage({ t }) {
262
- const [sessions, setSessions] = useState(null)
306
+ function RazorPage({ t, fixedSessionId }) {
307
+ useEffect(ensureStyles, [])
263
308
  const [sessionId, setSessionId] = useState('')
264
309
  const [context, setContext] = useState(null)
265
310
  const [ctxLoading, setCtxLoading] = useState(false)
266
311
  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
- })
312
+ const [tierFilter, setTierFilter] = useState(() => new Set())
270
313
  const [sortBy, setSortBy] = useState('order')
271
- const [overOnly, setOverOnly] = useState(false)
272
314
  const [selected, setSelected] = useState(() => new Set())
273
315
  const [detail, setDetail] = useState(null)
274
316
  const [confirming, setConfirming] = useState(false)
@@ -278,8 +320,10 @@ function RazorPage({ t }) {
278
320
 
279
321
  const showToast = (text) => { setToast(text); setTimeout(() => setToast(null), 3000) }
280
322
 
281
- const loadSessions = () => getJson(API + '/sessions').then(d => setSessions(d.sessions || [])).catch(e => setError(e.message))
282
- useEffect(() => { loadSessions() }, [])
323
+ // 会话视图挂载:只管当前会话(fixedSessionId 来自 slot props)
324
+ useEffect(() => {
325
+ if (fixedSessionId) { sessionRef.current = fixedSessionId; setSessionId(fixedSessionId) }
326
+ }, [fixedSessionId])
283
327
 
284
328
  const loadContext = (id) => {
285
329
  if (!id) { setContext(null); return }
@@ -293,25 +337,16 @@ function RazorPage({ t }) {
293
337
  useEffect(() => { if (sessionId) loadContext(sessionId) }, [sessionId])
294
338
 
295
339
  const refreshAll = () => {
296
- loadSessions()
297
340
  if (sessionRef.current) loadContext(sessionRef.current)
298
341
  }
299
342
 
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
343
  const visible = useMemo(() => {
308
344
  if (!context) return []
309
345
  let rows = context.entries
310
- if (overOnly && threshold > 0) rows = rows.filter(e => overThreshold(e, threshold))
346
+ if (tierFilter.size > 0) rows = rows.filter(e => tierFilter.has(tierOf(e)))
311
347
  return sortEntries(rows, sortBy)
312
- }, [context, overOnly, threshold, sortBy])
348
+ }, [context, tierFilter, sortBy])
313
349
 
314
- const overList = useMemo(() => (context ? context.entries.filter(e => overThreshold(e, threshold)) : []), [context, threshold])
315
350
  const selectedTokens = useMemo(() => {
316
351
  if (!context) return 0
317
352
  const bySeq = new Map(context.entries.map(e => [e.seq, e]))
@@ -326,12 +361,18 @@ function RazorPage({ t }) {
326
361
  else next.add(seq)
327
362
  return next
328
363
  })
329
- const selectOver = () => setSelected(new Set(overList.map(e => e.seq)))
364
+ const toggleTier = (i) => setTierFilter(prev => {
365
+ const next = new Set(prev)
366
+ if (next.has(i)) next.delete(i)
367
+ else next.add(i)
368
+ return next
369
+ })
370
+ const selectVisible = () => setSelected(new Set(visible.map(e => e.seq)))
330
371
 
331
372
  const openDetail = (entry) => {
332
373
  setDetail(entry)
333
374
  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))
375
+ .then(d => setDetail(cur => (cur && cur.seq === entry.seq) ? { ...cur, text: d.text, tokens: d.tokens, tool: d.tool, sourceKind: d.sourceKind, sourceForm: d.sourceForm, sourcePlugin: d.sourcePlugin } : cur))
335
376
  .catch(() => {})
336
377
  }
337
378
 
@@ -355,60 +396,49 @@ function RazorPage({ t }) {
355
396
  const canDelete = selected.size > 0 && !busy && !deleting
356
397
 
357
398
  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
399
  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'))),
400
+ !sessionId && h('div', { className: 'rz-empty' }, t('pickSession')),
371
401
  sessionId && ctxLoading && h('div', { className: 'rz-loading' }, t('loading')),
372
402
  sessionId && !ctxLoading && context && [
373
- h('div', { key: 'stats', className: 'rz-stats' },
403
+ h('div', { key: 'note', className: 'rz-hint' }, t('orderNote')),
404
+ h('div', { key: 'bar', className: 'rz-stats' },
374
405
  h('span', null, t('stats', { nodes: context.nodes, tokens: formatNum(context.totalTokens), mode: context.encoder === 'heuristic' ? t('modeHeuristic') : '' })),
406
+ h('span', { className: 'rz-legend' + (tierFilter.size > 0 ? ' filtering' : ''), title: t('legendTitle') },
407
+ RAZOR_TIERS.map((tr, i) => h('button', { key: i, className: 'rz-swatch tier-' + i + (tierFilter.has(i) ? ' on' : ''), title: tr.label + ' token', onClick: () => toggleTier(i) }, tr.label))),
375
408
  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')),
409
+ busy && h('span', { className: 'rz-badge' }, t('busyTag')),
410
+ h('button', { className: 'rz-btn', onClick: selectVisible, disabled: visible.length === 0 || busy, title: t('selectVisibleHint') },
411
+ t('selectVisible') + (visible.length ? ` (${visible.length})` : '')),
380
412
  h('select', { className: 'rz-select', value: sortBy, onChange: e => setSortBy(e.target.value), title: t('sortLabel'), 'aria-label': t('sortLabel') },
381
413
  h('option', { value: 'order' }, t('sortOrder')),
414
+ h('option', { value: 'seq' }, t('sortSeq')),
382
415
  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) }) : ' '),
416
+ h('button', { className: 'rz-btn', onClick: refreshAll, title: t('refresh') }, t('refresh'))),
417
+ selected.size > 0 && h('div', { key: 'sel', className: 'rz-stats' },
418
+ h('span', null, t('selectedStats', { n: selected.size, tokens: formatNum(selectedTokens) })),
387
419
  h('span', { className: 'rz-spacer' }),
388
420
  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')),
421
+ h('button', { className: 'rz-btn', onClick: () => setSelected(new Set()) }, t('clearSelect')),
391
422
  h('button', { className: 'rz-btn rz-btn-danger', disabled: !canDelete,
392
423
  title: busy ? t('deleteBusyHint') : undefined,
393
424
  onClick: () => setConfirming(true) },
394
- t('deleteSelected') + (selected.size ? ` (${selected.size})` : '')))),
425
+ t('deleteSelected') + ` (${selected.size})`))),
395
426
  visible.length === 0
396
- ? h('div', { key: 'empty', className: 'rz-empty' }, overOnly ? t('overOnly') + ' · ' + t('emptyContext') : t('emptyContext'))
427
+ ? h('div', { key: 'empty', className: 'rz-empty' }, t('emptyContext'))
397
428
  : h('div', { key: 'list', className: 'rz-list' },
398
429
  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' : ''),
430
+ const tier = tierOf(entry)
431
+ return h('div', { key: entry.seq, className: 'rz-row tier-' + tier + (selected.has(entry.seq) ? ' checked' : ''),
401
432
  role: 'button', tabIndex: 0, onClick: () => toggleRow(entry.seq),
402
433
  onKeyDown: e => e.key === 'Enter' && toggleRow(entry.seq) },
403
434
  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))
435
+ h(TokenBadge, { entry }),
436
+ h(Chip, { ...entryChip(entry, t) }),
437
+ h('span', { className: 'rz-row-preview', title: entry.preview,
438
+ onClick: e => { e.stopPropagation(); openDetail(entry) } }, entry.preview || ' '),
439
+ h('span', { className: 'rz-row-meta', title: 'seq ' + entry.seq }, formatDateTime(entry.time)))
410
440
  } })),
411
- detail && h(DetailModal, { detail, threshold, t, onClose: () => setDetail(null) }),
441
+ detail && h(DetailModal, { detail, t, onClose: () => setDetail(null) }),
412
442
  confirming && h(ConfirmDialog, { n: selected.size, tokens: formatNum(selectedTokens), deleting, t,
413
443
  onCancel: () => setConfirming(false), onOk: doDelete }),
414
444
  ],
@@ -422,7 +452,7 @@ const CLIENT_NAME = '@weibaohui/context-razor'
422
452
  module.exports = {
423
453
  name: CLIENT_NAME,
424
454
  inject: ['slots', 'locale'],
425
- __internals: { NS, ZH, EN, overThreshold, sortEntries },
455
+ __internals: { NS, ZH, EN, sortEntries, tierOf, RAZOR_TIERS, formatDateTime, entryChip },
426
456
  __boot(container, opts = {}) {
427
457
  ensureStyles()
428
458
  const t = opts.t || ((key, vars) => {
@@ -452,20 +482,19 @@ module.exports = {
452
482
  }
453
483
  }
454
484
  } catch (e) { try { console.error('[context-razor] locale init:', e) } catch {} }
485
+ // 会话视图标签:order 30 排在 Chat(0)/Trajectory(10)/Context(20)/Hermes Loop(25) 之后
455
486
  ctx.effect(() => {
456
487
  try {
457
- ctx.slots.inject('settings.section', () => ctx.slots.register({
458
- name: 'settings.section',
488
+ ctx.slots.inject('conversation.view', () => ctx.slots.register({
489
+ name: 'conversation.view',
459
490
  id: CLIENT_NAME,
460
- order: 91,
491
+ order: 30,
461
492
  locale: NS,
462
493
  label: () => t('title'),
463
- inject: () => ({}),
464
- }, function RazorSectionSlot() {
465
- useEffect(ensureStyles, [])
466
- return h(RazorPage, { t })
494
+ }, function RazorViewSlot(props) {
495
+ return h(RazorPage, { t, fixedSessionId: props && props.sessionId })
467
496
  }))
468
- } catch (e) { (globalThis.__rzErrors = globalThis.__rzErrors || []).push('settings:' + (e && e.message)); throw e }
469
- }, 'context-razor: settings section')
497
+ } catch (e) { (globalThis.__rzErrors = globalThis.__rzErrors || []).push('conversation.view:' + (e && e.message)); throw e }
498
+ }, 'context-razor: conversation view tab')
470
499
  },
471
500
  }
package/docs/demo.gif CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weibaohui/context-razor",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "dsh 插件 · 上下文剃刀:把当前会话上下文逐条列出(角色/预览/≈token,cl100k 估算),超阈值标红,勾选后精确裁剪——不经 LLM 总结,删了什么一目了然。",
5
5
  "license": "MIT",
6
6
  "keywords": [
package/src/index.js CHANGED
@@ -87,12 +87,19 @@ function sessionBusy(session) {
87
87
  }
88
88
 
89
89
  /**
90
- * 当前 surface 投影成条目列表:seq 有序(模型可见顺序)、逐条文本、
91
- * token 估算。被此前 compaction/prune 影子化的节点本就不在 surface.nodes
92
- * 里——列表即模型此刻真实可见的上下文。
90
+ * 当前 surface 投影成条目列表:seq 有序(模型可见顺序)、逐条文本、token 估算、
91
+ * 时间、工具名(tool/result callId 关联日志里的 tool/call)与注入来源。
92
+ * 被此前 compaction/prune 影子化的节点本就不在 surface.nodes 里——列表即模型
93
+ * 此刻真实可见的上下文。
93
94
  */
94
95
  function projectContext(session) {
95
96
  const bySeq = new Map(session.events.map((event) => [event.seq, event]))
97
+ const toolNames = new Map()
98
+ for (const event of session.events) {
99
+ if (event.type === 'tool/call' && event.data && typeof event.data.callId === 'string') {
100
+ toolNames.set(event.data.callId, typeof event.data.name === 'string' ? event.data.name : undefined)
101
+ }
102
+ }
96
103
  const entries = []
97
104
  let totalTokens = 0
98
105
  let mode = 'cl100k'
@@ -103,11 +110,16 @@ function projectContext(session) {
103
110
  const { tokens, mode: m } = countTokens(text)
104
111
  mode = m
105
112
  const usage = event.type === 'assistant/message' ? event.data.usage : undefined
113
+ const source = event.type === 'user/message' && event.data.source && typeof event.data.source === 'object' ? event.data.source : undefined
106
114
  entries.push({
107
115
  seq,
108
116
  kind: entryKindOf(event.type),
109
117
  time: event.time,
110
118
  turn: typeof event.data.turn === 'number' ? event.data.turn : undefined,
119
+ tool: event.type === 'tool/result' ? toolNames.get(event.data.message && event.data.message.source && event.data.message.source.callId) : undefined,
120
+ sourceKind: source ? source.kind : undefined,
121
+ sourceForm: source ? source.form : undefined,
122
+ sourcePlugin: source ? source.plugin : undefined,
111
123
  chars: text.length,
112
124
  tokens,
113
125
  preview: text.length > 220 ? text.slice(0, 220) + '…' : text,
@@ -270,7 +282,21 @@ module.exports = {
270
282
  const event = session.events.find((candidate) => candidate.seq === seq)
271
283
  if (event === undefined) { fail(404, 'entry not found'); return }
272
284
  const text = entryText(event)
273
- sendJson(200, { seq, kind: entryKindOf(event.type), time: event.time, tokens: countTokens(text).tokens, text })
285
+ const toolName = event.type === 'tool/result' && event.data.message
286
+ ? session.events.find((candidate) => candidate.type === 'tool/call' && candidate.data.callId === event.data.message.source.callId)
287
+ : undefined
288
+ const source = event.type === 'user/message' && event.data.source && typeof event.data.source === 'object' ? event.data.source : undefined
289
+ sendJson(200, {
290
+ seq,
291
+ kind: entryKindOf(event.type),
292
+ time: event.time,
293
+ tokens: countTokens(text).tokens,
294
+ text,
295
+ tool: toolName && typeof toolName.data.name === 'string' ? toolName.data.name : undefined,
296
+ sourceKind: source ? source.kind : undefined,
297
+ sourceForm: source ? source.form : undefined,
298
+ sourcePlugin: source ? source.plugin : undefined,
299
+ })
274
300
  return
275
301
  }
276
302