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