diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java index 253d5ddf..db0a2ae8 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java @@ -271,6 +271,16 @@ public class ConversationWindowManager { java.util.Collection 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 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 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 extra) { if (streamTracker == null || conversationId == null || conversationId.isEmpty()) { diff --git a/mateclaw-ui/src/components/chat/ContextUsagePanel.vue b/mateclaw-ui/src/components/chat/ContextUsagePanel.vue new file mode 100644 index 00000000..f8f08db1 --- /dev/null +++ b/mateclaw-ui/src/components/chat/ContextUsagePanel.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index f1611ab1..10bed838 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -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 + /** + * Latest context_usage SSE event. Persists between turns (occupancy stays + * meaningful after a reply lands); reset only when switching conversations. + */ + contextUsage: import('vue').Ref /** * 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(null) + /** Latest context-usage snapshot; kept across turns, cleared on conversation switch. */ + const contextUsage = ref(null) + /** All segments of the current assistant message (for segmented display) */ const currentSegments = ref([]) 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, diff --git a/mateclaw-ui/src/composables/chat/useStream.ts b/mateclaw-ui/src/composables/chat/useStream.ts index f1f00ce4..03780ba3 100644 --- a/mateclaw-ui/src/composables/chat/useStream.ts +++ b/mateclaw-ui/src/composables/chat/useStream.ts @@ -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 diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 6a4a1006..f159d8c5 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 877e248f..50783f5e 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -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: '总计', diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index 9a86b5c2..7199d8da 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -211,6 +211,11 @@ :capabilities="agentCapabilities" /> + +
+ +
+