feat(chat): context occupancy panel with per-source breakdown (#492)

This commit is contained in:
matevip 2026-07-06 16:44:59 +08:00
parent 09d9fba1ff
commit 6802fc1c6a
7 changed files with 290 additions and 1 deletions

View File

@ -271,6 +271,16 @@ public class ConversationWindowManager {
java.util.Collection<ToolCallback> toolCallbacks,
String workspaceBasePath) {
if (messages == null || messages.isEmpty()) {
// Still surface an occupancy snapshot for the very first turn so
// the chat panel shows context usage from message one on.
int firstTurnMax = (maxInputTokens != null && maxInputTokens > 0)
? maxInputTokens : properties.getDefaultMaxInputTokens();
broadcastContextUsage(conversationId, firstTurnMax,
TokenEstimator.estimateTokens(systemPrompt),
TokenEstimator.estimateTokens(currentUserMessage) + TokenEstimator.PER_MESSAGE_OVERHEAD,
0,
TokenEstimator.estimateToolsTokens(toolCallbacks),
false);
return messages;
}
long spillsAtEntry = (toolResultStorage != null) ? toolResultStorage.getSpillCount() : 0L;
@ -292,6 +302,9 @@ public class ConversationWindowManager {
int toolsTokens = TokenEstimator.estimateToolsTokens(toolCallbacks);
int totalTokens = systemTokens + currentMsgTokens + historyTokens + toolsTokens;
broadcastContextUsage(conversationId, effectiveMax, systemTokens, currentMsgTokens,
historyTokens, toolsTokens, totalTokens > triggerThreshold);
if (totalTokens <= triggerThreshold) {
return messages;
}
@ -319,9 +332,14 @@ public class ConversationWindowManager {
// 尾部保护 token 预算阈值的 20%
int tailTokenBudget = (int) (triggerThreshold * 0.20);
return compactMessages(messages, historyBudget, tailTokenBudget, chatModel,
List<Message> compacted = compactMessages(messages, historyBudget, tailTokenBudget, chatModel,
conversationId, agentId, totalTokens, spillsAtEntry, "token_threshold",
workspaceBasePath);
// Re-broadcast so the occupancy gauge falls immediately after
// compaction instead of waiting for the next window-fit pass.
broadcastContextUsage(conversationId, effectiveMax, systemTokens, currentMsgTokens,
TokenEstimator.estimateTokens(compacted), toolsTokens, false);
return compacted;
}
/**
@ -337,6 +355,42 @@ public class ConversationWindowManager {
// ==================== 核心压缩逻辑 ====================
/**
* Broadcast a {@code context_usage} snapshot: how much of the effective
* input window the next LLM call will occupy, split by source (system
* prompt / tool schemas / history / current input). Values come from
* {@link TokenEstimator}, so they are heuristic estimates for a gauge,
* not billing-grade counts the UI labels them as such. Fired once per
* window-fit pass (i.e. per reasoning step) and again after compaction
* completes so the gauge falls back. Silent no-op when no tracker is
* wired or the window size is unknown.
*/
private void broadcastContextUsage(String conversationId, int windowTokens,
int systemTokens, int currentTokens,
int historyTokens, int toolsTokens,
boolean willCompact) {
if (streamTracker == null || conversationId == null || conversationId.isEmpty()
|| windowTokens <= 0) {
return;
}
try {
int usedTokens = systemTokens + currentTokens + historyTokens + toolsTokens;
Map<String, Object> payload = new java.util.LinkedHashMap<>();
payload.put("windowTokens", windowTokens);
payload.put("usedTokens", usedTokens);
payload.put("systemTokens", systemTokens);
payload.put("currentTokens", currentTokens);
payload.put("historyTokens", historyTokens);
payload.put("toolsTokens", toolsTokens);
payload.put("ratio", windowTokens > 0 ? (double) usedTokens / windowTokens : 0d);
payload.put("willCompact", willCompact);
payload.put("timestamp", System.currentTimeMillis());
streamTracker.broadcastObject(conversationId, "context_usage", payload);
} catch (Exception e) {
log.debug("[ConversationWindow] broadcast context_usage failed: {}", e.getMessage());
}
}
/** Broadcast a single compact_status event; silent no-op when no tracker is wired. */
private void broadcastCompactStatus(String conversationId, String status, Map<String, Object> extra) {
if (streamTracker == null || conversationId == null || conversationId.isEmpty()) {

View File

@ -0,0 +1,161 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ContextUsageEvent } from '@/composables/chat/useChat'
const props = defineProps<{
usage: ContextUsageEvent | null
}>()
const { t } = useI18n()
/** Compact token count, e.g. 31200 → "~31.2K". */
function fmtTokens(n: number): string {
if (n <= 0) return '0'
return n >= 1000 ? '~' + (n / 1000).toFixed(1) + 'K' : '~' + String(n)
}
const percent = computed(() => {
const u = props.usage
if (!u || u.windowTokens <= 0) return 0
return Math.min(999, Math.round((u.usedTokens / u.windowTokens) * 1000) / 10)
})
/** Gauge color escalates as the window fills. */
const level = computed(() => {
const p = percent.value
if (p >= 90) return 'danger'
if (p >= 75) return 'warn'
return 'ok'
})
interface Segment { key: string; label: string; tokens: number; color: string }
const segments = computed<Segment[]>(() => {
const u = props.usage
if (!u) return []
return [
{ key: 'system', label: t('chat.contextUsage.system'), tokens: u.systemTokens, color: 'var(--mc-ctx-system, #64748b)' },
{ key: 'tools', label: t('chat.contextUsage.tools'), tokens: u.toolsTokens, color: 'var(--mc-ctx-tools, #8b5cf6)' },
{ key: 'history', label: t('chat.contextUsage.history'), tokens: u.historyTokens, color: 'var(--mc-ctx-history, #f97316)' },
{ key: 'current', label: t('chat.contextUsage.current'), tokens: u.currentTokens, color: 'var(--mc-ctx-current, #0ea5e9)' },
]
})
/** Bar widths in % of the window (free space stays track-colored). */
const barSegments = computed(() => {
const u = props.usage
if (!u || u.windowTokens <= 0) return []
// When over-committed (>100%), scale against usedTokens so segments still sum to 100.
const denom = Math.max(u.windowTokens, u.usedTokens)
return segments.value
.filter(s => s.tokens > 0)
.map(s => ({ ...s, width: Math.max(0.8, (s.tokens / denom) * 100) }))
})
</script>
<template>
<el-popover
v-if="usage && usage.windowTokens > 0"
placement="top-end"
trigger="click"
:width="300"
popper-class="mc-ctx-popover"
>
<template #reference>
<button class="ctx-chip" :class="'ctx-chip--' + level" type="button" :title="$t('chat.contextUsage.chipTooltip')">
<span class="ctx-chip__bar">
<span class="ctx-chip__fill" :style="{ width: Math.min(100, percent) + '%' }" />
</span>
<span class="ctx-chip__pct">{{ percent }}%</span>
</button>
</template>
<div class="ctx-panel">
<div class="ctx-panel__head">
<span class="ctx-panel__totals">
{{ fmtTokens(usage.usedTokens) }} / {{ fmtTokens(usage.windowTokens) }}
</span>
<span class="ctx-panel__pct" :class="'ctx-panel__pct--' + level">{{ percent }}%</span>
<span class="ctx-panel__title">{{ $t('chat.contextUsage.title') }}</span>
</div>
<div class="ctx-panel__track">
<span
v-for="s in barSegments"
:key="s.key"
class="ctx-panel__seg"
:style="{ width: s.width + '%', background: s.color }"
/>
</div>
<div class="ctx-panel__rows">
<div v-for="s in segments" :key="s.key" class="ctx-panel__row">
<span class="ctx-panel__dot" :style="{ background: s.color }" />
<span class="ctx-panel__label">{{ s.label }}</span>
<span class="ctx-panel__value">{{ fmtTokens(s.tokens) }}</span>
</div>
</div>
<div class="ctx-panel__foot">
<span v-if="usage.willCompact" class="ctx-panel__compact-hint">{{ $t('chat.contextUsage.willCompact') }}</span>
<span class="ctx-panel__estimate">{{ $t('chat.contextUsage.estimated') }}</span>
</div>
</div>
</el-popover>
</template>
<style scoped>
.ctx-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border: 1px solid var(--mc-border, #e5e7eb);
border-radius: 10px;
background: var(--mc-bg-elevated, #fff);
cursor: pointer;
line-height: 1.4;
}
.ctx-chip:hover { border-color: var(--mc-primary, #b4592d); }
.ctx-chip__bar {
width: 44px;
height: 5px;
border-radius: 3px;
background: var(--mc-border, #e5e7eb);
overflow: hidden;
}
.ctx-chip__fill { display: block; height: 100%; border-radius: 3px; background: #10b981; transition: width .3s ease; }
.ctx-chip--warn .ctx-chip__fill { background: #f59e0b; }
.ctx-chip--danger .ctx-chip__fill { background: #ef4444; }
.ctx-chip__pct { font-size: 11px; font-variant-numeric: tabular-nums; color: var(--mc-text-secondary, #6b7280); }
.ctx-chip--danger .ctx-chip__pct { color: #ef4444; }
.ctx-panel { font-size: 13px; }
.ctx-panel__head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 10px; }
.ctx-panel__totals { font-weight: 600; font-variant-numeric: tabular-nums; }
.ctx-panel__pct--ok { color: #10b981; font-weight: 600; }
.ctx-panel__pct--warn { color: #f59e0b; font-weight: 600; }
.ctx-panel__pct--danger { color: #ef4444; font-weight: 600; }
.ctx-panel__title { color: var(--mc-text-secondary, #6b7280); margin-left: auto; }
.ctx-panel__track {
display: flex;
height: 6px;
border-radius: 3px;
background: var(--mc-border, #e5e7eb);
overflow: hidden;
margin-bottom: 10px;
}
.ctx-panel__seg { display: block; height: 100%; }
.ctx-panel__rows { display: flex; flex-direction: column; gap: 6px; }
.ctx-panel__row { display: flex; align-items: center; gap: 8px; }
.ctx-panel__dot { width: 9px; height: 9px; border-radius: 2px; flex: none; }
.ctx-panel__label { color: var(--mc-text-primary, #111827); }
.ctx-panel__value { margin-left: auto; font-variant-numeric: tabular-nums; color: var(--mc-text-secondary, #6b7280); }
.ctx-panel__foot { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.ctx-panel__compact-hint { color: #f59e0b; font-size: 12px; }
.ctx-panel__estimate { margin-left: auto; color: var(--mc-text-tertiary, #9ca3af); font-size: 11px; }
</style>

View File

@ -59,6 +59,30 @@ export interface CompactStatusEvent {
fromCache?: boolean
}
/**
* Snapshot of a {@code context_usage} SSE event. Mirrors the payload built by
* ConversationWindowManager.broadcastContextUsage: how much of the effective
* input window the next LLM call occupies, split by source. All values are
* TokenEstimator heuristics a gauge, not a bill.
*/
export interface ContextUsageEvent {
/** Effective input window (tokens) of the active model. */
windowTokens: number
/** system + tools + history + current, in tokens. */
usedTokens: number
systemTokens: number
/** Tool / skill schemas advertised to the model (includes MCP tools). */
toolsTokens: number
historyTokens: number
/** Current user input (plus per-message overhead). */
currentTokens: number
/** usedTokens / windowTokens (may exceed 1 before compaction runs). */
ratio: number
/** True when this pass will trigger history compaction. */
willCompact: boolean
timestamp?: number
}
export interface UseChatOptions {
/** Base API URL */
baseUrl: string
@ -111,6 +135,11 @@ export interface UseChatReturn {
* to {@code null} when a turn finishes, so the chip auto-hides.
*/
compactStatus: import('vue').Ref<CompactStatusEvent | null>
/**
* Latest context_usage SSE event. Persists between turns (occupancy stays
* meaningful after a reply lands); reset only when switching conversations.
*/
contextUsage: import('vue').Ref<ContextUsageEvent | null>
/**
* Fine-grained pre-token lifecycle stage. Drives the loading bar copy in the
* window between "send pressed" and "first delta arrived". `null` once a
@ -189,6 +218,9 @@ export function useChat(options: UseChatOptions): UseChatReturn {
*/
const compactStatus = ref<CompactStatusEvent | null>(null)
/** Latest context-usage snapshot; kept across turns, cleared on conversation switch. */
const contextUsage = ref<ContextUsageEvent | null>(null)
/** All segments of the current assistant message (for segmented display) */
const currentSegments = ref<MessageSegment[]>([])
const segIdCounter = { value: 0 }
@ -988,6 +1020,13 @@ export function useChat(options: UseChatOptions): UseChatReturn {
compactStatus.value = { ...data } as CompactStatusEvent
})
// Context-window occupancy snapshot, fired once per window-fit pass and
// again after compaction. Drives the occupancy chip next to the input.
stream.on('context_usage', (data) => {
if (isStaleEvent(data)) return
contextUsage.value = { ...data } as ContextUsageEvent
})
stream.on('phase', (data) => {
if (isStaleEvent(data)) return
const phase = data.phase as StreamPhase
@ -2192,6 +2231,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
streamPhase.value = 'idle'
phaseInfo.value = null
compactStatus.value = null
contextUsage.value = null
lifecycleStage.value = null
error.value = null
messageQueue.clear()
@ -2212,6 +2252,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
queueSize: messageQueue.queueSize,
heartbeat,
compactStatus,
contextUsage,
lifecycleStage,
sendMessage,
stopGeneration,

View File

@ -80,6 +80,7 @@ export type SSEEventType =
// (start → pair_safe → summarize → done/skipped/failed). Payload
// carries preTokens/postTokens/messagesSummarized/tailKept/etc.
| 'compact_status'
| 'context_usage'
export interface SSEEvent {
type: SSEEventType

View File

@ -198,6 +198,16 @@ export default {
replyModel: 'Reply model: {model}',
tokenUsageTooltip: 'This turn used {total} tokens ({input} in · {output} out)',
tokenUsageTooltipDelegated: 'This turn used {total} tokens ({input} in · {output} out), of which {delegated} came from delegated sub-agents',
contextUsage: {
title: 'context used',
chipTooltip: 'Context usage (click for breakdown, estimated)',
system: 'System prompt',
tools: 'Tools & skills',
history: 'History',
current: 'Current input',
willCompact: 'Near the limit — older turns will be compacted',
estimated: 'estimated',
},
usageDetail: {
title: 'Token usage detail',
total: 'Total',

View File

@ -198,6 +198,16 @@ export default {
replyModel: '本条回复模型: {model}',
tokenUsageTooltip: '本轮共消耗 {total} tokens输入 {input} · 输出 {output}',
tokenUsageTooltipDelegated: '本轮共消耗 {total} tokens输入 {input} · 输出 {output}),其中子 Agent 委派占 {delegated}',
contextUsage: {
title: '上下文已用',
chipTooltip: '上下文占用(点击查看分项,估算值)',
system: '系统提示词',
tools: '工具与技能',
history: '历史消息',
current: '本轮输入',
willCompact: '接近上限,将压缩较早的对话',
estimated: '估算值',
},
usageDetail: {
title: 'Token 消耗明细',
total: '总计',

View File

@ -211,6 +211,11 @@
:capabilities="agentCapabilities"
/>
<!-- 上下文占用估算点击展开分项面板 -->
<div v-if="contextUsage" class="ctx-usage-row">
<ContextUsagePanel :usage="contextUsage" />
</div>
<!-- 使用组件化的 ChatInput -->
<ChatInput
ref="chatInputRef"
@ -294,6 +299,7 @@ import { agentIconColor } from '@/utils/agentIconColor'
import ChatInput from '@/components/chat/ChatInput.vue'
import MultimodalRoutingHint from '@/components/chat/MultimodalRoutingHint.vue'
import StreamLoadingBar from '@/components/chat/StreamLoadingBar.vue'
import ContextUsagePanel from '@/components/chat/ContextUsagePanel.vue'
import TalkMode from '@/components/chat/TalkMode.vue'
import ModelSelector from '@/components/chat/ModelSelector.vue'
import { useEChartsRenderer } from '@/composables/useEChartsRenderer'
@ -675,6 +681,7 @@ const {
queueSize,
heartbeat,
compactStatus,
contextUsage,
lifecycleStage,
sendMessage: sendChatMessage,
stopGeneration: stopChatGeneration,
@ -2250,6 +2257,11 @@ function handleCodeCopy(e: MouseEvent) {
</script>
<style scoped>
.ctx-usage-row {
display: flex;
justify-content: flex-end;
padding: 0 4px 4px;
}
.cron-running-bar {
display: flex;
flex-direction: column;