@weibaohui/context-razor 0.3.0 → 0.4.1

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
@@ -10,9 +10,11 @@
10
10
  ## 核心功能
11
11
 
12
12
  - **逐条列出**:当前会话模型可见的每条上下文(用户/助手/工具结果)按序排列,带预览、字符数与 ≈token 估算,点 ⋯ 查看全文与真实 usage
13
+ - **占比标注**:每条的 token 徽章同时显示其占全部上下文的百分比(如 `≈5728 · 15%`),一眼看出谁是上下文大头
14
+ - **类型筛选**:按类型一键过滤——用户 / 注入 / 助手,以及各工具名(bash、skill、read、edit…按会话实际出现的动态生成)。多选 toggle,与 token 量级筛选正交叠加
13
15
  - **超阈值标红**:可调阈值(默认 500 token,本地记忆),超限条目红色高亮;「只看超阈值」过滤 + 「token 高→低」排序,大头一眼可见
14
- - **精确裁剪**:勾选任意条目一键删除——连续段自动合并处理。删除走宿主 surface replace 协议(与官方 compaction 同款机制),**不经 LLM 总结**,删了什么、删了多少完全由你决定
15
- - **不重排**:本插件不会改变上下文条目顺序,只会剔除选中条目;排序下拉仅影响页面显示(上下文顺序 / 原始序号 / token 高→低),模型可见顺序始终由会话 surface 决定
16
+ - **精确裁剪**:勾选任意条目一键删除——连续段自动合并处理。删除走宿主 surface replace 协议(与官方 compaction 同款机制),**不经 LLM 总结**,删了什么、删了多少完全由你决定。每行尾部带 ✕ 快捷按钮,**单条直接删**与勾选后**多选批量删**走同一确认弹窗
17
+ - **不重排**:本插件不会改变上下文条目顺序,只会剔除选中条目;「token 高→低」排序仅影响页面显示,模型可见顺序始终由会话 surface 决定
16
18
  - **安全护栏**:会话运行中禁止裁剪(等当前回合结束);每次替换附带一条 notice 标记消息,模型知道这段历史被你移除了,需要时会主动向你确认
17
19
  - **非破坏**:append-only 日志保留全部痕迹(宿主机制如此)。「删除」= 从模型视野与界面投影中移除
18
20
 
package/client/bundle.js CHANGED
@@ -64,9 +64,10 @@ window.__ModuleLoader__.load({
64
64
  busyTag: '运行中',
65
65
  sortLabel: '排序',
66
66
  sortOrder: '上下文顺序',
67
- sortSeq: '原始序号',
68
67
  sortTokens: 'token 高→低',
69
68
  orderNote: '本插件不会改变上下文条目顺序,只会剔除选中条目。',
69
+ legendTitle: '按 token 量级筛选(点选高亮某一档)',
70
+ kindFilterLabel: '类型',
70
71
  selectVisible: '选中可见',
71
72
  selectVisibleHint: '选中当前显示的全部条目(配合色块筛选)',
72
73
  clearSelect: '清空选择',
@@ -88,6 +89,8 @@ window.__ModuleLoader__.load({
88
89
  deleteTitle: '确认裁剪',
89
90
  deleteConfirm: '即将把 {n} 条消息(约 {tokens} token)从模型视野中移除,替换为一条标记消息。日志中保留痕迹,此操作不可恢复。继续?',
90
91
  deleteBusyHint: '会话正在运行,等当前回合结束后再裁剪',
92
+ deleteOne: '删除此条',
93
+ deleteOneHint: '删除此条(约 {tokens} token)',
91
94
  deleteOk: '执行',
92
95
  cancel: '取消',
93
96
  deletedToast: '已删除 {n} 条(约 {tokens} token)',
@@ -103,9 +106,10 @@ window.__ModuleLoader__.load({
103
106
  busyTag: 'running',
104
107
  sortLabel: 'Sort',
105
108
  sortOrder: 'Context order',
106
- sortSeq: 'Original seq',
107
109
  sortTokens: 'tokens high→low',
108
110
  orderNote: 'This plugin never reorders context entries; it only removes the selected ones.',
111
+ legendTitle: 'Filter by token tier (click to isolate a band)',
112
+ kindFilterLabel: 'Type',
109
113
  selectVisible: 'Select shown',
110
114
  selectVisibleHint: 'Select every currently shown entry (pairs with tier filters)',
111
115
  clearSelect: 'Clear selection',
@@ -127,6 +131,8 @@ window.__ModuleLoader__.load({
127
131
  deleteTitle: 'Confirm trim',
128
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?',
129
133
  deleteBusyHint: 'Session is running — trim after the current turn finishes',
134
+ deleteOne: 'Delete this entry',
135
+ deleteOneHint: 'Delete this entry (≈{tokens} tokens)',
130
136
  deleteOk: 'Delete',
131
137
  cancel: 'Cancel',
132
138
  deletedToast: 'Deleted {n} entries (≈{tokens} tokens)',
@@ -141,9 +147,10 @@ window.__ModuleLoader__.load({
141
147
 
142
148
  const kindI18n = (key) => 'kind' + key[0].toUpperCase() + key.slice(1)
143
149
 
144
- /** 排序:order = 投影原序(模型可见序);seq = 原始序号升序(裁剪过的会话里与 order 不同:标记消息 seq 大但在中间);tokens = 降序(并列按 seq,缺 token 沉底)。 */
150
+ /** 排序:order = 投影原序(模型可见序);tokens = 降序(并列按 seq,缺 token 沉底)。
151
+ * 只有两档且都是显示层——裁剪过的会话里 seq 升序 ≠ 模型可见序(replace 标记 seq 大但位置在中间),
152
+ * 与「本页展示模型可见上下文」相悖,故不提供 seq 档。 */
145
153
  function sortEntries(entries, sortBy) {
146
- if (sortBy === 'seq') return [...entries].sort((a, b) => a.seq - b.seq)
147
154
  if (sortBy !== 'tokens') return entries
148
155
  return [...entries].sort((a, b) => {
149
156
  const va = a.tokens, vb = b.tokens
@@ -175,6 +182,38 @@ window.__ModuleLoader__.load({
175
182
  return { kind: entry.kind, label: t(kindI18n(entry.kind)) }
176
183
  }
177
184
 
185
+ /**
186
+ * 筛选用稳定分类键,与 entryChip 的可见口径一致:
187
+ * tool → 'tool:<工具名>'(无工具名则 'tool:',按钮文案退回「工具」)
188
+ * user 注入 → 'injected'(sourceKind !== 'user' 的 user 消息,统一一档;
189
+ * 细分来源在 chip 的 title 与详情里已可见,筛选层不再切分,避免按钮爆炸)
190
+ * user / assistant → 自身
191
+ * 「按钮文案」与 chip 同源(categoryLabel),用户在两处看到的是同一个词。
192
+ */
193
+ function entryCategory(entry) {
194
+ if (entry.kind === 'tool') return 'tool:' + (typeof entry.tool === 'string' ? entry.tool : '')
195
+ if (entry.kind === 'user' && entry.sourceKind && entry.sourceKind !== 'user') return 'injected'
196
+ return entry.kind
197
+ }
198
+ function categoryLabel(cat, t) {
199
+ if (cat === 'user') return t('kindUser')
200
+ if (cat === 'assistant') return t('kindAssistant')
201
+ if (cat === 'injected') return t('kindInjected')
202
+ if (cat.lastIndexOf('tool:', 0) === 0) {
203
+ const name = cat.slice(5)
204
+ return name || t('kindTool')
205
+ }
206
+ return cat
207
+ }
208
+
209
+ /** 该条 token 占全部上下文的百分比文案;total 非正或 part 为 0 返回 null(不显示)。 */
210
+ function pctOf(part, total) {
211
+ if (!total || !part) return null
212
+ const p = part / total * 100
213
+ if (p < 0.1) return '<0.1%'
214
+ return (p >= 10 ? p.toFixed(0) : p.toFixed(1)) + '%'
215
+ }
216
+
178
217
  // ── 彩虹分级:颜色越暖 = 占用越多 ────────────────────────────────────────
179
218
  // 固定对数档位(相邻约 ×2.5),跨会话语义稳定:绿→黄绿→黄→橙→红→品红。
180
219
  // 档位是「这条消息吃掉多少典型上下文预算」的粗标尺,不随会话内最大值缩放。
@@ -225,6 +264,13 @@ window.__ModuleLoader__.load({
225
264
  .rz-swatch.tier-3{background:hsl(32,80%,45%)}
226
265
  .rz-swatch.tier-4{background:hsl(8,75%,48%)}
227
266
  .rz-swatch.tier-5{background:hsl(320,65%,50%)}
267
+ .rz-kindsbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
268
+ .rz-kinds{display:inline-flex;gap:4px;align-items:center;flex-wrap:wrap}
269
+ .rz-kinds.filtering .rz-kindbtn:not(.on){opacity:.45}
270
+ .rz-kindbtn{display:inline-flex;align-items:center;gap:5px;padding:2px 10px;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;cursor:pointer;font-family:var(--dsw-font-family);white-space:nowrap}
271
+ .rz-kindbtn:hover{border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}
272
+ .rz-kindbtn.on{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary);font-weight:600}
273
+ .rz-kindcount{font-size:10px;opacity:.7}
228
274
  .rz-list{display:flex;flex-direction:column;gap:6px}
229
275
  .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%}
230
276
  .rz-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
@@ -232,6 +278,9 @@ window.__ModuleLoader__.load({
232
278
  .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
279
  .rz-row-preview:hover{text-decoration:underline;text-underline-offset:3px}
234
280
  .rz-row-meta{color:var(--dsw-alias-label-tertiary);font-size:11px;flex:none}
281
+ .rz-row-del{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;margin-left:4px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1;cursor:pointer;flex:none}
282
+ .rz-row-del:hover:not(:disabled){color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);background:hsla(0,75%,50%,.08)}
283
+ .rz-row-del:disabled{opacity:.35;cursor:not-allowed}
235
284
  .rz-badge.tier-0{color:hsl(120,55%,40%);border-color:hsla(120,55%,40%,.45);background:hsla(120,55%,40%,.10)}
236
285
  .rz-badge.tier-1{color:hsl(90,60%,38%);border-color:hsla(90,60%,38%,.45);background:hsla(90,60%,38%,.10)}
237
286
  .rz-badge.tier-2{color:hsl(60,70%,36%);border-color:hsla(60,70%,36%,.45);background:hsla(60,70%,36%,.12)}
@@ -264,10 +313,12 @@ window.__ModuleLoader__.load({
264
313
 
265
314
  const Chip = ({ kind, label, title }) => h('span', { className: 'rz-chip ' + kind, title }, label)
266
315
 
267
- const TokenBadge = ({ entry }) => {
316
+ const TokenBadge = ({ entry, total }) => {
268
317
  const tier = tierOf(entry)
269
- return h('span', { className: 'rz-badge tier-' + tier, title: RAZOR_TIERS[tier].label + ' token' },
270
- '≈' + formatNum(entry.tokens))
318
+ const pct = pctOf(entry.tokens, total)
319
+ const title = RAZOR_TIERS[tier].label + ' token' + (pct ? ' · ' + pct + ' ' + (total ? 'of ≈' + formatNum(total) + ' token' : 'of context') : '')
320
+ return h('span', { className: 'rz-badge tier-' + tier, title },
321
+ '≈' + formatNum(entry.tokens) + (pct ? ' · ' + pct : ''))
271
322
  }
272
323
 
273
324
  /** 分页列表:先渲染 pageSize 行,按需增长(大会话一次挂几千行会卡)。 */
@@ -282,14 +333,14 @@ window.__ModuleLoader__.load({
282
333
  }
283
334
 
284
335
  /** 单条全文弹窗(正文经 /entry 异步补全)。 */
285
- function DetailModal({ detail, t, onClose }) {
336
+ function DetailModal({ detail, t, total, onClose }) {
286
337
  if (!detail) return null
287
338
  return h('div', { className: 'rz-dlg-backdrop', onClick: onClose },
288
339
  h('div', { className: 'rz-dlg', onClick: e => e.stopPropagation() },
289
340
  h('h3', null, t('detailTitle') + ' · ' + t('seqLabel', { seq: detail.seq })),
290
341
  h('div', { className: 'rz-stats', style: { marginBottom: 10 } },
291
342
  h(Chip, { ...entryChip(detail, t) }),
292
- h(TokenBadge, { entry: detail }),
343
+ h(TokenBadge, { entry: detail, total }),
293
344
  h('span', { className: 'rz-label', title: 'seq ' + detail.seq }, formatDateTime(detail.time)),
294
345
  h('span', { className: 'rz-label' }, t('detailChars', { chars: formatNum(detail.chars) })),
295
346
  detail.usage && h('span', { className: 'rz-label' }, t('detailUsage', {
@@ -320,10 +371,11 @@ window.__ModuleLoader__.load({
320
371
  const [ctxLoading, setCtxLoading] = useState(false)
321
372
  const [error, setError] = useState(null)
322
373
  const [tierFilter, setTierFilter] = useState(() => new Set())
374
+ const [kindFilter, setKindFilter] = useState(() => new Set())
323
375
  const [sortBy, setSortBy] = useState('order')
324
376
  const [selected, setSelected] = useState(() => new Set())
325
377
  const [detail, setDetail] = useState(null)
326
- const [confirming, setConfirming] = useState(false)
378
+ const [confirming, setConfirming] = useState(null) // null | number[] 待裁剪 seqs(单条/多选同源)
327
379
  const [deleting, setDeleting] = useState(false)
328
380
  const [toast, setToast] = useState(null)
329
381
  const sessionRef = useRef('')
@@ -354,16 +406,34 @@ window.__ModuleLoader__.load({
354
406
  if (!context) return []
355
407
  let rows = context.entries
356
408
  if (tierFilter.size > 0) rows = rows.filter(e => tierFilter.has(tierOf(e)))
409
+ if (kindFilter.size > 0) rows = rows.filter(e => kindFilter.has(entryCategory(e)))
357
410
  return sortEntries(rows, sortBy)
358
- }, [context, tierFilter, sortBy])
411
+ }, [context, tierFilter, kindFilter, sortBy])
359
412
 
360
- const selectedTokens = useMemo(() => {
361
- if (!context) return 0
362
- const bySeq = new Map(context.entries.map(e => [e.seq, e]))
413
+ // 当前上下文里实际出现的分类(cat/count/tokens),按条数降序——决定渲染哪些按钮。
414
+ const categories = useMemo(() => {
415
+ if (!context) return []
416
+ const seen = new Map()
417
+ for (const e of context.entries) {
418
+ const c = entryCategory(e)
419
+ const prev = seen.get(c) || { count: 0, tokens: 0 }
420
+ prev.count += 1
421
+ prev.tokens += (e.tokens || 0)
422
+ seen.set(c, prev)
423
+ }
424
+ return [...seen.entries()]
425
+ .map(([cat, v]) => ({ cat, count: v.count, tokens: v.tokens }))
426
+ .sort((a, b) => b.count - a.count || b.tokens - a.tokens)
427
+ }, [context])
428
+
429
+ const entriesBySeq = useMemo(() => context ? new Map(context.entries.map(e => [e.seq, e])) : null, [context])
430
+ const sumSeqs = (seqs) => {
363
431
  let sum = 0
364
- for (const seq of selected) { const e = bySeq.get(seq); if (e) sum += e.tokens }
432
+ if (entriesBySeq) for (const seq of seqs) { const e = entriesBySeq.get(seq); if (e) sum += e.tokens || 0 }
365
433
  return sum
366
- }, [context, selected])
434
+ }
435
+ const selectedTokens = useMemo(() => sumSeqs(selected), [selected, entriesBySeq])
436
+ const confirmTokens = useMemo(() => confirming ? sumSeqs(confirming) : 0, [confirming, entriesBySeq])
367
437
 
368
438
  const toggleRow = (seq) => setSelected(prev => {
369
439
  const next = new Set(prev)
@@ -377,6 +447,12 @@ window.__ModuleLoader__.load({
377
447
  else next.add(i)
378
448
  return next
379
449
  })
450
+ const toggleKind = (cat) => setKindFilter(prev => {
451
+ const next = new Set(prev)
452
+ if (next.has(cat)) next.delete(cat)
453
+ else next.add(cat)
454
+ return next
455
+ })
380
456
  const selectVisible = () => setSelected(new Set(visible.map(e => e.seq)))
381
457
 
382
458
  const openDetail = (entry) => {
@@ -387,17 +463,18 @@ window.__ModuleLoader__.load({
387
463
  }
388
464
 
389
465
  const doDelete = async () => {
466
+ if (!confirming || confirming.length === 0) return
390
467
  setDeleting(true)
391
468
  try {
392
- const r = await fetch(API + '/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session: sessionId, seqs: [...selected] }) })
469
+ const r = await fetch(API + '/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session: sessionId, seqs: confirming }) })
393
470
  const d = await r.json().catch(() => ({}))
394
471
  if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status)
395
- setConfirming(false)
472
+ setConfirming(null)
396
473
  setSelected(new Set())
397
474
  showToast(t('deletedToast', { n: d.removed, tokens: formatNum(d.tokensRemoved) }))
398
475
  refreshAll()
399
476
  } catch (e) {
400
- setConfirming(false)
477
+ setConfirming(null)
401
478
  setError(e.message)
402
479
  } finally { setDeleting(false) }
403
480
  }
@@ -421,9 +498,21 @@ window.__ModuleLoader__.load({
421
498
  t('selectVisible') + (visible.length ? ` (${visible.length})` : '')),
422
499
  h('select', { className: 'rz-select', value: sortBy, onChange: e => setSortBy(e.target.value), title: t('sortLabel'), 'aria-label': t('sortLabel') },
423
500
  h('option', { value: 'order' }, t('sortOrder')),
424
- h('option', { value: 'seq' }, t('sortSeq')),
425
501
  h('option', { value: 'tokens' }, t('sortTokens'))),
426
502
  h('button', { className: 'rz-btn', onClick: refreshAll, title: t('refresh') }, t('refresh'))),
503
+ // 类型筛选:与 chip 同口径的可点选按钮(user/injected/assistant/tool:<名>),
504
+ // 多选 toggle,与 tier 量级筛选正交叠加。按钮带条数;hover 看 token 占比。
505
+ categories.length > 0 && h('div', { key: 'kinds', className: 'rz-kindsbar' },
506
+ h('span', { className: 'rz-label' }, t('kindFilterLabel')),
507
+ h('span', { className: 'rz-kinds' + (kindFilter.size > 0 ? ' filtering' : '') },
508
+ categories.map(({ cat, count, tokens }) =>
509
+ h('button', { key: cat,
510
+ className: 'rz-kindbtn' + (kindFilter.has(cat) ? ' on' : ''),
511
+ title: categoryLabel(cat, t) + ' · ' + count + ' 条 · ≈' + formatNum(tokens) + ' token'
512
+ + (context.totalTokens ? '(' + (pctOf(tokens, context.totalTokens) || '0%') + ')' : ''),
513
+ onClick: () => toggleKind(cat) },
514
+ categoryLabel(cat, t),
515
+ h('span', { className: 'rz-kindcount' }, count))))),
427
516
  selected.size > 0 && h('div', { key: 'sel', className: 'rz-stats' },
428
517
  h('span', null, t('selectedStats', { n: selected.size, tokens: formatNum(selectedTokens) })),
429
518
  h('span', { className: 'rz-spacer' }),
@@ -431,7 +520,7 @@ window.__ModuleLoader__.load({
431
520
  h('button', { className: 'rz-btn', onClick: () => setSelected(new Set()) }, t('clearSelect')),
432
521
  h('button', { className: 'rz-btn rz-btn-danger', disabled: !canDelete,
433
522
  title: busy ? t('deleteBusyHint') : undefined,
434
- onClick: () => setConfirming(true) },
523
+ onClick: () => setConfirming([...selected]) },
435
524
  t('deleteSelected') + ` (${selected.size})`))),
436
525
  visible.length === 0
437
526
  ? h('div', { key: 'empty', className: 'rz-empty' }, t('emptyContext'))
@@ -442,15 +531,19 @@ window.__ModuleLoader__.load({
442
531
  role: 'button', tabIndex: 0, onClick: () => toggleRow(entry.seq),
443
532
  onKeyDown: e => e.key === 'Enter' && toggleRow(entry.seq) },
444
533
  h('input', { type: 'checkbox', checked: selected.has(entry.seq), onClick: e => e.stopPropagation(), onChange: () => toggleRow(entry.seq) }),
445
- h(TokenBadge, { entry }),
534
+ h(TokenBadge, { entry, total: context.totalTokens }),
446
535
  h(Chip, { ...entryChip(entry, t) }),
447
536
  h('span', { className: 'rz-row-preview', title: entry.preview,
448
537
  onClick: e => { e.stopPropagation(); openDetail(entry) } }, entry.preview || ' '),
449
- h('span', { className: 'rz-row-meta', title: 'seq ' + entry.seq }, formatDateTime(entry.time)))
538
+ h('span', { className: 'rz-row-meta', title: 'seq ' + entry.seq }, formatDateTime(entry.time)),
539
+ h('button', { className: 'rz-row-del', type: 'button', disabled: busy,
540
+ title: busy ? t('deleteBusyHint') : t('deleteOneHint', { tokens: formatNum(entry.tokens) }),
541
+ 'aria-label': t('deleteOne'),
542
+ onClick: e => { e.stopPropagation(); if (!busy) setConfirming([entry.seq]) } }, '✕'))
450
543
  } })),
451
- detail && h(DetailModal, { detail, t, onClose: () => setDetail(null) }),
452
- confirming && h(ConfirmDialog, { n: selected.size, tokens: formatNum(selectedTokens), deleting, t,
453
- onCancel: () => setConfirming(false), onOk: doDelete }),
544
+ detail && h(DetailModal, { detail, t, total: context.totalTokens, onClose: () => setDetail(null) }),
545
+ confirming && confirming.length > 0 && h(ConfirmDialog, { n: confirming.length, tokens: formatNum(confirmTokens), deleting, t,
546
+ onCancel: () => setConfirming(null), onOk: doDelete }),
454
547
  ],
455
548
  toast && h('div', { className: 'rz-toast' }, toast))
456
549
  }
@@ -462,7 +555,7 @@ window.__ModuleLoader__.load({
462
555
  module.exports = {
463
556
  name: CLIENT_NAME,
464
557
  inject: ['slots', 'locale'],
465
- __internals: { NS, ZH, EN, sortEntries, tierOf, RAZOR_TIERS, formatDateTime, entryChip },
558
+ __internals: { NS, ZH, EN, sortEntries, tierOf, RAZOR_TIERS, formatDateTime, entryChip, entryCategory, categoryLabel, pctOf },
466
559
  __boot(container, opts = {}) {
467
560
  ensureStyles()
468
561
  const t = opts.t || ((key, vars) => {
package/client/index.js CHANGED
@@ -54,9 +54,10 @@ const ZH = {
54
54
  busyTag: '运行中',
55
55
  sortLabel: '排序',
56
56
  sortOrder: '上下文顺序',
57
- sortSeq: '原始序号',
58
57
  sortTokens: 'token 高→低',
59
58
  orderNote: '本插件不会改变上下文条目顺序,只会剔除选中条目。',
59
+ legendTitle: '按 token 量级筛选(点选高亮某一档)',
60
+ kindFilterLabel: '类型',
60
61
  selectVisible: '选中可见',
61
62
  selectVisibleHint: '选中当前显示的全部条目(配合色块筛选)',
62
63
  clearSelect: '清空选择',
@@ -78,6 +79,8 @@ const ZH = {
78
79
  deleteTitle: '确认裁剪',
79
80
  deleteConfirm: '即将把 {n} 条消息(约 {tokens} token)从模型视野中移除,替换为一条标记消息。日志中保留痕迹,此操作不可恢复。继续?',
80
81
  deleteBusyHint: '会话正在运行,等当前回合结束后再裁剪',
82
+ deleteOne: '删除此条',
83
+ deleteOneHint: '删除此条(约 {tokens} token)',
81
84
  deleteOk: '执行',
82
85
  cancel: '取消',
83
86
  deletedToast: '已删除 {n} 条(约 {tokens} token)',
@@ -93,9 +96,10 @@ const EN = {
93
96
  busyTag: 'running',
94
97
  sortLabel: 'Sort',
95
98
  sortOrder: 'Context order',
96
- sortSeq: 'Original seq',
97
99
  sortTokens: 'tokens high→low',
98
100
  orderNote: 'This plugin never reorders context entries; it only removes the selected ones.',
101
+ legendTitle: 'Filter by token tier (click to isolate a band)',
102
+ kindFilterLabel: 'Type',
99
103
  selectVisible: 'Select shown',
100
104
  selectVisibleHint: 'Select every currently shown entry (pairs with tier filters)',
101
105
  clearSelect: 'Clear selection',
@@ -117,6 +121,8 @@ const EN = {
117
121
  deleteTitle: 'Confirm trim',
118
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?',
119
123
  deleteBusyHint: 'Session is running — trim after the current turn finishes',
124
+ deleteOne: 'Delete this entry',
125
+ deleteOneHint: 'Delete this entry (≈{tokens} tokens)',
120
126
  deleteOk: 'Delete',
121
127
  cancel: 'Cancel',
122
128
  deletedToast: 'Deleted {n} entries (≈{tokens} tokens)',
@@ -131,9 +137,10 @@ const PAGE_SIZE = 150
131
137
 
132
138
  const kindI18n = (key) => 'kind' + key[0].toUpperCase() + key.slice(1)
133
139
 
134
- /** 排序:order = 投影原序(模型可见序);seq = 原始序号升序(裁剪过的会话里与 order 不同:标记消息 seq 大但在中间);tokens = 降序(并列按 seq,缺 token 沉底)。 */
140
+ /** 排序:order = 投影原序(模型可见序);tokens = 降序(并列按 seq,缺 token 沉底)。
141
+ * 只有两档且都是显示层——裁剪过的会话里 seq 升序 ≠ 模型可见序(replace 标记 seq 大但位置在中间),
142
+ * 与「本页展示模型可见上下文」相悖,故不提供 seq 档。 */
135
143
  function sortEntries(entries, sortBy) {
136
- if (sortBy === 'seq') return [...entries].sort((a, b) => a.seq - b.seq)
137
144
  if (sortBy !== 'tokens') return entries
138
145
  return [...entries].sort((a, b) => {
139
146
  const va = a.tokens, vb = b.tokens
@@ -165,6 +172,38 @@ function entryChip(entry, t) {
165
172
  return { kind: entry.kind, label: t(kindI18n(entry.kind)) }
166
173
  }
167
174
 
175
+ /**
176
+ * 筛选用稳定分类键,与 entryChip 的可见口径一致:
177
+ * tool → 'tool:<工具名>'(无工具名则 'tool:',按钮文案退回「工具」)
178
+ * user 注入 → 'injected'(sourceKind !== 'user' 的 user 消息,统一一档;
179
+ * 细分来源在 chip 的 title 与详情里已可见,筛选层不再切分,避免按钮爆炸)
180
+ * user / assistant → 自身
181
+ * 「按钮文案」与 chip 同源(categoryLabel),用户在两处看到的是同一个词。
182
+ */
183
+ function entryCategory(entry) {
184
+ if (entry.kind === 'tool') return 'tool:' + (typeof entry.tool === 'string' ? entry.tool : '')
185
+ if (entry.kind === 'user' && entry.sourceKind && entry.sourceKind !== 'user') return 'injected'
186
+ return entry.kind
187
+ }
188
+ function categoryLabel(cat, t) {
189
+ if (cat === 'user') return t('kindUser')
190
+ if (cat === 'assistant') return t('kindAssistant')
191
+ if (cat === 'injected') return t('kindInjected')
192
+ if (cat.lastIndexOf('tool:', 0) === 0) {
193
+ const name = cat.slice(5)
194
+ return name || t('kindTool')
195
+ }
196
+ return cat
197
+ }
198
+
199
+ /** 该条 token 占全部上下文的百分比文案;total 非正或 part 为 0 返回 null(不显示)。 */
200
+ function pctOf(part, total) {
201
+ if (!total || !part) return null
202
+ const p = part / total * 100
203
+ if (p < 0.1) return '<0.1%'
204
+ return (p >= 10 ? p.toFixed(0) : p.toFixed(1)) + '%'
205
+ }
206
+
168
207
  // ── 彩虹分级:颜色越暖 = 占用越多 ────────────────────────────────────────
169
208
  // 固定对数档位(相邻约 ×2.5),跨会话语义稳定:绿→黄绿→黄→橙→红→品红。
170
209
  // 档位是「这条消息吃掉多少典型上下文预算」的粗标尺,不随会话内最大值缩放。
@@ -215,6 +254,13 @@ const STYLE = `<style>
215
254
  .rz-swatch.tier-3{background:hsl(32,80%,45%)}
216
255
  .rz-swatch.tier-4{background:hsl(8,75%,48%)}
217
256
  .rz-swatch.tier-5{background:hsl(320,65%,50%)}
257
+ .rz-kindsbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
258
+ .rz-kinds{display:inline-flex;gap:4px;align-items:center;flex-wrap:wrap}
259
+ .rz-kinds.filtering .rz-kindbtn:not(.on){opacity:.45}
260
+ .rz-kindbtn{display:inline-flex;align-items:center;gap:5px;padding:2px 10px;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;cursor:pointer;font-family:var(--dsw-font-family);white-space:nowrap}
261
+ .rz-kindbtn:hover{border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}
262
+ .rz-kindbtn.on{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary);font-weight:600}
263
+ .rz-kindcount{font-size:10px;opacity:.7}
218
264
  .rz-list{display:flex;flex-direction:column;gap:6px}
219
265
  .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%}
220
266
  .rz-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
@@ -222,6 +268,9 @@ const STYLE = `<style>
222
268
  .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
269
  .rz-row-preview:hover{text-decoration:underline;text-underline-offset:3px}
224
270
  .rz-row-meta{color:var(--dsw-alias-label-tertiary);font-size:11px;flex:none}
271
+ .rz-row-del{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;margin-left:4px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1;cursor:pointer;flex:none}
272
+ .rz-row-del:hover:not(:disabled){color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);background:hsla(0,75%,50%,.08)}
273
+ .rz-row-del:disabled{opacity:.35;cursor:not-allowed}
225
274
  .rz-badge.tier-0{color:hsl(120,55%,40%);border-color:hsla(120,55%,40%,.45);background:hsla(120,55%,40%,.10)}
226
275
  .rz-badge.tier-1{color:hsl(90,60%,38%);border-color:hsla(90,60%,38%,.45);background:hsla(90,60%,38%,.10)}
227
276
  .rz-badge.tier-2{color:hsl(60,70%,36%);border-color:hsla(60,70%,36%,.45);background:hsla(60,70%,36%,.12)}
@@ -254,10 +303,12 @@ const STYLE = `<style>
254
303
 
255
304
  const Chip = ({ kind, label, title }) => h('span', { className: 'rz-chip ' + kind, title }, label)
256
305
 
257
- const TokenBadge = ({ entry }) => {
306
+ const TokenBadge = ({ entry, total }) => {
258
307
  const tier = tierOf(entry)
259
- return h('span', { className: 'rz-badge tier-' + tier, title: RAZOR_TIERS[tier].label + ' token' },
260
- '≈' + formatNum(entry.tokens))
308
+ const pct = pctOf(entry.tokens, total)
309
+ const title = RAZOR_TIERS[tier].label + ' token' + (pct ? ' · ' + pct + ' ' + (total ? 'of ≈' + formatNum(total) + ' token' : 'of context') : '')
310
+ return h('span', { className: 'rz-badge tier-' + tier, title },
311
+ '≈' + formatNum(entry.tokens) + (pct ? ' · ' + pct : ''))
261
312
  }
262
313
 
263
314
  /** 分页列表:先渲染 pageSize 行,按需增长(大会话一次挂几千行会卡)。 */
@@ -272,14 +323,14 @@ function PagedList({ items, render, t }) {
272
323
  }
273
324
 
274
325
  /** 单条全文弹窗(正文经 /entry 异步补全)。 */
275
- function DetailModal({ detail, t, onClose }) {
326
+ function DetailModal({ detail, t, total, onClose }) {
276
327
  if (!detail) return null
277
328
  return h('div', { className: 'rz-dlg-backdrop', onClick: onClose },
278
329
  h('div', { className: 'rz-dlg', onClick: e => e.stopPropagation() },
279
330
  h('h3', null, t('detailTitle') + ' · ' + t('seqLabel', { seq: detail.seq })),
280
331
  h('div', { className: 'rz-stats', style: { marginBottom: 10 } },
281
332
  h(Chip, { ...entryChip(detail, t) }),
282
- h(TokenBadge, { entry: detail }),
333
+ h(TokenBadge, { entry: detail, total }),
283
334
  h('span', { className: 'rz-label', title: 'seq ' + detail.seq }, formatDateTime(detail.time)),
284
335
  h('span', { className: 'rz-label' }, t('detailChars', { chars: formatNum(detail.chars) })),
285
336
  detail.usage && h('span', { className: 'rz-label' }, t('detailUsage', {
@@ -310,10 +361,11 @@ function RazorPage({ t, fixedSessionId }) {
310
361
  const [ctxLoading, setCtxLoading] = useState(false)
311
362
  const [error, setError] = useState(null)
312
363
  const [tierFilter, setTierFilter] = useState(() => new Set())
364
+ const [kindFilter, setKindFilter] = useState(() => new Set())
313
365
  const [sortBy, setSortBy] = useState('order')
314
366
  const [selected, setSelected] = useState(() => new Set())
315
367
  const [detail, setDetail] = useState(null)
316
- const [confirming, setConfirming] = useState(false)
368
+ const [confirming, setConfirming] = useState(null) // null | number[] 待裁剪 seqs(单条/多选同源)
317
369
  const [deleting, setDeleting] = useState(false)
318
370
  const [toast, setToast] = useState(null)
319
371
  const sessionRef = useRef('')
@@ -344,16 +396,34 @@ function RazorPage({ t, fixedSessionId }) {
344
396
  if (!context) return []
345
397
  let rows = context.entries
346
398
  if (tierFilter.size > 0) rows = rows.filter(e => tierFilter.has(tierOf(e)))
399
+ if (kindFilter.size > 0) rows = rows.filter(e => kindFilter.has(entryCategory(e)))
347
400
  return sortEntries(rows, sortBy)
348
- }, [context, tierFilter, sortBy])
401
+ }, [context, tierFilter, kindFilter, sortBy])
349
402
 
350
- const selectedTokens = useMemo(() => {
351
- if (!context) return 0
352
- const bySeq = new Map(context.entries.map(e => [e.seq, e]))
403
+ // 当前上下文里实际出现的分类(cat/count/tokens),按条数降序——决定渲染哪些按钮。
404
+ const categories = useMemo(() => {
405
+ if (!context) return []
406
+ const seen = new Map()
407
+ for (const e of context.entries) {
408
+ const c = entryCategory(e)
409
+ const prev = seen.get(c) || { count: 0, tokens: 0 }
410
+ prev.count += 1
411
+ prev.tokens += (e.tokens || 0)
412
+ seen.set(c, prev)
413
+ }
414
+ return [...seen.entries()]
415
+ .map(([cat, v]) => ({ cat, count: v.count, tokens: v.tokens }))
416
+ .sort((a, b) => b.count - a.count || b.tokens - a.tokens)
417
+ }, [context])
418
+
419
+ const entriesBySeq = useMemo(() => context ? new Map(context.entries.map(e => [e.seq, e])) : null, [context])
420
+ const sumSeqs = (seqs) => {
353
421
  let sum = 0
354
- for (const seq of selected) { const e = bySeq.get(seq); if (e) sum += e.tokens }
422
+ if (entriesBySeq) for (const seq of seqs) { const e = entriesBySeq.get(seq); if (e) sum += e.tokens || 0 }
355
423
  return sum
356
- }, [context, selected])
424
+ }
425
+ const selectedTokens = useMemo(() => sumSeqs(selected), [selected, entriesBySeq])
426
+ const confirmTokens = useMemo(() => confirming ? sumSeqs(confirming) : 0, [confirming, entriesBySeq])
357
427
 
358
428
  const toggleRow = (seq) => setSelected(prev => {
359
429
  const next = new Set(prev)
@@ -367,6 +437,12 @@ function RazorPage({ t, fixedSessionId }) {
367
437
  else next.add(i)
368
438
  return next
369
439
  })
440
+ const toggleKind = (cat) => setKindFilter(prev => {
441
+ const next = new Set(prev)
442
+ if (next.has(cat)) next.delete(cat)
443
+ else next.add(cat)
444
+ return next
445
+ })
370
446
  const selectVisible = () => setSelected(new Set(visible.map(e => e.seq)))
371
447
 
372
448
  const openDetail = (entry) => {
@@ -377,17 +453,18 @@ function RazorPage({ t, fixedSessionId }) {
377
453
  }
378
454
 
379
455
  const doDelete = async () => {
456
+ if (!confirming || confirming.length === 0) return
380
457
  setDeleting(true)
381
458
  try {
382
- const r = await fetch(API + '/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session: sessionId, seqs: [...selected] }) })
459
+ const r = await fetch(API + '/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session: sessionId, seqs: confirming }) })
383
460
  const d = await r.json().catch(() => ({}))
384
461
  if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status)
385
- setConfirming(false)
462
+ setConfirming(null)
386
463
  setSelected(new Set())
387
464
  showToast(t('deletedToast', { n: d.removed, tokens: formatNum(d.tokensRemoved) }))
388
465
  refreshAll()
389
466
  } catch (e) {
390
- setConfirming(false)
467
+ setConfirming(null)
391
468
  setError(e.message)
392
469
  } finally { setDeleting(false) }
393
470
  }
@@ -411,9 +488,21 @@ function RazorPage({ t, fixedSessionId }) {
411
488
  t('selectVisible') + (visible.length ? ` (${visible.length})` : '')),
412
489
  h('select', { className: 'rz-select', value: sortBy, onChange: e => setSortBy(e.target.value), title: t('sortLabel'), 'aria-label': t('sortLabel') },
413
490
  h('option', { value: 'order' }, t('sortOrder')),
414
- h('option', { value: 'seq' }, t('sortSeq')),
415
491
  h('option', { value: 'tokens' }, t('sortTokens'))),
416
492
  h('button', { className: 'rz-btn', onClick: refreshAll, title: t('refresh') }, t('refresh'))),
493
+ // 类型筛选:与 chip 同口径的可点选按钮(user/injected/assistant/tool:<名>),
494
+ // 多选 toggle,与 tier 量级筛选正交叠加。按钮带条数;hover 看 token 占比。
495
+ categories.length > 0 && h('div', { key: 'kinds', className: 'rz-kindsbar' },
496
+ h('span', { className: 'rz-label' }, t('kindFilterLabel')),
497
+ h('span', { className: 'rz-kinds' + (kindFilter.size > 0 ? ' filtering' : '') },
498
+ categories.map(({ cat, count, tokens }) =>
499
+ h('button', { key: cat,
500
+ className: 'rz-kindbtn' + (kindFilter.has(cat) ? ' on' : ''),
501
+ title: categoryLabel(cat, t) + ' · ' + count + ' 条 · ≈' + formatNum(tokens) + ' token'
502
+ + (context.totalTokens ? '(' + (pctOf(tokens, context.totalTokens) || '0%') + ')' : ''),
503
+ onClick: () => toggleKind(cat) },
504
+ categoryLabel(cat, t),
505
+ h('span', { className: 'rz-kindcount' }, count))))),
417
506
  selected.size > 0 && h('div', { key: 'sel', className: 'rz-stats' },
418
507
  h('span', null, t('selectedStats', { n: selected.size, tokens: formatNum(selectedTokens) })),
419
508
  h('span', { className: 'rz-spacer' }),
@@ -421,7 +510,7 @@ function RazorPage({ t, fixedSessionId }) {
421
510
  h('button', { className: 'rz-btn', onClick: () => setSelected(new Set()) }, t('clearSelect')),
422
511
  h('button', { className: 'rz-btn rz-btn-danger', disabled: !canDelete,
423
512
  title: busy ? t('deleteBusyHint') : undefined,
424
- onClick: () => setConfirming(true) },
513
+ onClick: () => setConfirming([...selected]) },
425
514
  t('deleteSelected') + ` (${selected.size})`))),
426
515
  visible.length === 0
427
516
  ? h('div', { key: 'empty', className: 'rz-empty' }, t('emptyContext'))
@@ -432,15 +521,19 @@ function RazorPage({ t, fixedSessionId }) {
432
521
  role: 'button', tabIndex: 0, onClick: () => toggleRow(entry.seq),
433
522
  onKeyDown: e => e.key === 'Enter' && toggleRow(entry.seq) },
434
523
  h('input', { type: 'checkbox', checked: selected.has(entry.seq), onClick: e => e.stopPropagation(), onChange: () => toggleRow(entry.seq) }),
435
- h(TokenBadge, { entry }),
524
+ h(TokenBadge, { entry, total: context.totalTokens }),
436
525
  h(Chip, { ...entryChip(entry, t) }),
437
526
  h('span', { className: 'rz-row-preview', title: entry.preview,
438
527
  onClick: e => { e.stopPropagation(); openDetail(entry) } }, entry.preview || ' '),
439
- h('span', { className: 'rz-row-meta', title: 'seq ' + entry.seq }, formatDateTime(entry.time)))
528
+ h('span', { className: 'rz-row-meta', title: 'seq ' + entry.seq }, formatDateTime(entry.time)),
529
+ h('button', { className: 'rz-row-del', type: 'button', disabled: busy,
530
+ title: busy ? t('deleteBusyHint') : t('deleteOneHint', { tokens: formatNum(entry.tokens) }),
531
+ 'aria-label': t('deleteOne'),
532
+ onClick: e => { e.stopPropagation(); if (!busy) setConfirming([entry.seq]) } }, '✕'))
440
533
  } })),
441
- detail && h(DetailModal, { detail, t, onClose: () => setDetail(null) }),
442
- confirming && h(ConfirmDialog, { n: selected.size, tokens: formatNum(selectedTokens), deleting, t,
443
- onCancel: () => setConfirming(false), onOk: doDelete }),
534
+ detail && h(DetailModal, { detail, t, total: context.totalTokens, onClose: () => setDetail(null) }),
535
+ confirming && confirming.length > 0 && h(ConfirmDialog, { n: confirming.length, tokens: formatNum(confirmTokens), deleting, t,
536
+ onCancel: () => setConfirming(null), onOk: doDelete }),
444
537
  ],
445
538
  toast && h('div', { className: 'rz-toast' }, toast))
446
539
  }
@@ -452,7 +545,7 @@ const CLIENT_NAME = '@weibaohui/context-razor'
452
545
  module.exports = {
453
546
  name: CLIENT_NAME,
454
547
  inject: ['slots', 'locale'],
455
- __internals: { NS, ZH, EN, sortEntries, tierOf, RAZOR_TIERS, formatDateTime, entryChip },
548
+ __internals: { NS, ZH, EN, sortEntries, tierOf, RAZOR_TIERS, formatDateTime, entryChip, entryCategory, categoryLabel, pctOf },
456
549
  __boot(container, opts = {}) {
457
550
  ensureStyles()
458
551
  const t = opts.t || ((key, vars) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weibaohui/context-razor",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "dsh 插件 · 上下文剃刀:把当前会话上下文逐条列出(角色/预览/≈token,cl100k 估算),超阈值标红,勾选后精确裁剪——不经 LLM 总结,删了什么一目了然。",
5
5
  "license": "MIT",
6
6
  "keywords": [