mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(chat): context occupancy panel with per-source breakdown (#492)
This commit is contained in:
parent
09d9fba1ff
commit
6802fc1c6a
@ -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()) {
|
||||
|
||||
161
mateclaw-ui/src/components/chat/ContextUsagePanel.vue
Normal file
161
mateclaw-ui/src/components/chat/ContextUsagePanel.vue
Normal 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>
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '总计',
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user