diff --git a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java index 979cbe59..26d958fd 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java @@ -235,12 +235,17 @@ public abstract class BaseAgent { agentName, history.size(), totalCount, windowSize); } - // ===== 识别持久化的压缩摘要:从摘要位置开始,跳过更早消息 ===== - for (int i = 0; i < history.size(); i++) { + // ===== Slice from the LATEST compression boundary, not the first ===== + // A long-running conversation can accumulate several boundaries; the + // newest one is the only relevant cut-off because every earlier + // boundary's content is already folded into the newer summary. Walking + // forward and breaking on the first boundary kept everything between + // boundaries — the very redundancy compaction was supposed to remove. + for (int i = history.size() - 1; i >= 0; i--) { MessageEntity msg = history.get(i); if ("system".equals(msg.getRole()) && isCompressionSummary(msg)) { history = new ArrayList<>(history.subList(i, history.size())); - log.info("[{}] Found compression summary, loading from index {} ({} messages)", + log.info("[{}] Found latest compression boundary at index {}; loading {} messages forward", agentName, i, history.size()); break; } diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 1d5d1895..8e57faae 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -477,6 +477,11 @@ public class ConversationService { if (v != null) metadata.put(k, v); }); } + // First write a placeholder so the row lands with the structured + // fields; we backfill summaryId in a second step once MyBatis Plus + // has assigned the snowflake id. ASSIGN_ID actually populates the + // id BEFORE flushing the INSERT, but reading it back this way means + // the contract holds even if the ID generation strategy changes. try { entity.setMetadata(objectMapper.writeValueAsString(metadata)); } catch (com.fasterxml.jackson.core.JsonProcessingException e) { @@ -485,6 +490,20 @@ public class ConversationService { entity.setMetadata("{\"type\":\"compression_summary\",\"compressedCount\":" + compressedCount + "}"); } messageMapper.insert(entity); + + // Backfill summaryId now that the row owns an id. Best-effort: a + // failure here doesn't invalidate the boundary itself, it just + // means SSE clients won't have a deep-link target for this row. + if (entity.getId() != null) { + metadata.put("summaryId", entity.getId()); + try { + entity.setMetadata(objectMapper.writeValueAsString(metadata)); + messageMapper.updateById(entity); + } catch (Exception e) { + log.warn("[Conversation] Failed to backfill summaryId on compression boundary: {}", + e.getMessage()); + } + } log.info("[Conversation] Saved compression boundary conv={}, compressedCount={}, metadata={}", conversationId, compressedCount, entity.getMetadata()); } diff --git a/mateclaw-ui/src/components/chat/StreamLoadingBar.vue b/mateclaw-ui/src/components/chat/StreamLoadingBar.vue index bb831e31..ad1d4047 100644 --- a/mateclaw-ui/src/components/chat/StreamLoadingBar.vue +++ b/mateclaw-ui/src/components/chat/StreamLoadingBar.vue @@ -36,6 +36,28 @@ interface LifecycleStage { since: number } +/** Latest compact_status SSE event from useChat. Renders an inline chip + * so the user can see that the pause is the window manager compacting + * history, not a network stall. */ +interface CompactStatus { + status: 'start' | 'pair_safe' | 'summarize' | 'done' | 'skipped' | 'failed' + preTokens?: number + postTokens?: number + messagesIn?: number + messagesSummarized?: number + tailKept?: number + toolResultsSpilled?: number + reason?: string + anchored?: boolean + fromCache?: boolean + movedFrom?: number + movedTo?: number + summaryBudget?: number + trigger?: string + fallbackKept?: number + timestamp?: number +} + interface Props { isLoading: boolean toolCount?: number @@ -53,6 +75,8 @@ interface Props { hasQueued?: boolean /** Fine-grained pre-token stage. Preferred over `phase` while no token has arrived. */ lifecycleStage?: LifecycleStage | null + /** Latest compact_status event. When non-null and not 'done', the bar shows compaction copy. */ + compactStatus?: CompactStatus | null } const props = withDefaults(defineProps(), { @@ -66,6 +90,7 @@ const props = withDefaults(defineProps(), { runningToolName: '', hasQueued: false, lifecycleStage: null, + compactStatus: null, }) const { t } = useI18n() @@ -119,7 +144,51 @@ const inPreTokenWindow = computed(() => { return !!ls && ls.stage !== 'streaming' }) +/** + * Compaction copy. Takes priority over both lifecycleStage and phase + * while the compactor is mid-pass (any status except done/skipped/failed) + * because the user cares more about "we paused to compact" than the + * underlying llm-request lifecycle. After done/skipped/failed we let the + * regular phase text take over — the chip's transient hint suffices. + */ +const compactStatusText = computed(() => { + const cs = props.compactStatus + if (!cs) return '' + switch (cs.status) { + case 'start': + return cs.preTokens + ? t('chat.compactStartWithTokens', { tokens: formatTokens(cs.preTokens) }) + : t('chat.compactStart') + case 'pair_safe': + return t('chat.compactPairSafe') + case 'summarize': { + const n = cs.messagesSummarized ?? cs.messagesIn ?? 0 + return n > 0 + ? t('chat.compactSummarizeWithCount', { count: n }) + : t('chat.compactSummarize') + } + default: + return '' + } +}) + +const isCompactActive = computed(() => { + const s = props.compactStatus?.status + return s === 'start' || s === 'pair_safe' || s === 'summarize' +}) + +function formatTokens(n: number): string { + if (n >= 1000) return `${(n / 1000).toFixed(1)}k tokens` + return `${n} tokens` +} + const statusText = computed(() => { + // Compaction copy wins while a pass is in flight. Done/skipped/failed + // fall through to the regular phase text so the chip releases focus. + if (isCompactActive.value) { + const cText = compactStatusText.value + if (cText) return cText + } // Prefer fine-grained pre-token text when no first delta has arrived yet. if (inPreTokenWindow.value && props.lifecycleStage) { const key = lifecycleI18nMap[props.lifecycleStage.stage] diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index 2d06cec4..add89324 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -17,6 +17,45 @@ import type { Message, MessageContentPart, MessageSegment, StreamPhase, Heartbea import { classifyBackendError, type ChatErrorInfo } from '@/types/chatError' import { http } from '@/api' +/** + * Snapshot of a {@code compact_status} SSE event. Mirrors the payload built + * by ConversationWindowManager.broadcastCompactStatus so the UI can render a + * progress chip without each consumer reverse-engineering field names. + */ +export interface CompactStatusEvent { + /** start | pair_safe | summarize | done | skipped | failed */ + status: 'start' | 'pair_safe' | 'summarize' | 'done' | 'skipped' | 'failed' + /** Server clock when the event fired. */ + timestamp?: number + /** Total prompt tokens before compaction began (start / done payloads). */ + preTokens?: number + /** Total prompt tokens after the boundary lands (done payload). */ + postTokens?: number + /** Messages in scope at start. */ + messagesIn?: number + /** Messages folded into the structured summary (done payload). */ + messagesSummarized?: number + /** Recent messages preserved verbatim (done payload). */ + tailKept?: number + /** Tool-result bodies spilled to disk this turn (done payload). */ + toolResultsSpilled?: number + /** Whether the first-user anchor was injected (done payload). */ + anchored?: boolean + /** Why compaction was skipped or failed: insufficient_messages, pair_boundary_collapsed, summary_generation_failed, ... */ + reason?: string + /** Pair-safety boundary moved from / to indices (pair_safe payload). */ + movedFrom?: number + movedTo?: number + /** Summary budget the LLM was asked to fit into (summarize payload). */ + summaryBudget?: number + /** Trigger label baked in by the backend (start / done — currently token_threshold). */ + trigger?: string + /** Tail kept fallback when summary generation failed. */ + fallbackKept?: number + /** True when the boundary was served from the in-memory summary cache. */ + fromCache?: boolean +} + export interface UseChatOptions { /** Base API URL */ baseUrl: string @@ -62,6 +101,13 @@ export interface UseChatReturn { queueSize: import('vue').ComputedRef /** Latest heartbeat data */ heartbeat: import('vue').Ref + /** + * Latest compact_status SSE event for the active turn. Drives the in-prompt + * compaction chip / boundary marker so the user can see "preparing context" + * pauses (start → pair_safe → summarize → done/skipped/failed). Cleared back + * to {@code null} when a turn finishes, so the chip auto-hides. + */ + compactStatus: 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 @@ -127,6 +173,14 @@ export function useChat(options: UseChatOptions): UseChatReturn { let stopFallbackTimer: ReturnType | null = null const streamPhase = ref('idle') const phaseInfo = ref(null) + /** + * Latest compact_status event for the current turn. Reset to null on + * stream end and on every conversation switch so the chip auto-hides. + * "done" events are kept on screen for a short interval by the consumer + * (see StreamLoadingBar / CompactStatusBadge) rather than being cleared + * immediately, so the user gets a chance to see the result. + */ + const compactStatus = ref(null) /** All segments of the current assistant message (for segmented display) */ const currentSegments = ref([]) @@ -502,6 +556,7 @@ export function useChat(options: UseChatOptions): UseChatReturn { : data.status === 'stopped' ? 'stopped' : 'completed' if (data.status !== 'awaiting_approval') { phaseInfo.value = null + compactStatus.value = null lifecycleStage.value = null expirePendingApprovals(data.status === 'stopped' ? 'stopped' : 'completed') } @@ -584,6 +639,7 @@ export function useChat(options: UseChatOptions): UseChatReturn { error.value = new Error(errorMessage) streamPhase.value = 'idle' phaseInfo.value = null + compactStatus.value = null lifecycleStage.value = null // Clear queue on error to avoid stale state messageQueue.clear() @@ -756,6 +812,15 @@ export function useChat(options: UseChatOptions): UseChatReturn { } as any) }) + // Context-compaction progress. Fires before the LLM call when the window + // manager has to evict old turns to fit budget. The chip uses this to show + // the user that an unexpected pause is the planner thinking about + // context, not a network stall. + stream.on('compact_status', (data) => { + if (isStaleEvent(data)) return + compactStatus.value = { ...data } as CompactStatusEvent + }) + stream.on('phase', (data) => { if (isStaleEvent(data)) return const phase = data.phase as StreamPhase @@ -1645,6 +1710,7 @@ export function useChat(options: UseChatOptions): UseChatReturn { // Mark as stopped immediately so the UI gives instant feedback streamPhase.value = 'stopped' phaseInfo.value = null + compactStatus.value = null // Install fallback timer before any await so it is not missed by a concurrent resetForNewConversation if (stopFallbackTimer) clearTimeout(stopFallbackTimer) @@ -1792,6 +1858,7 @@ export function useChat(options: UseChatOptions): UseChatReturn { segIdCounter.value = 0 streamPhase.value = 'idle' phaseInfo.value = null + compactStatus.value = null lifecycleStage.value = null error.value = null messageQueue.clear() @@ -1811,6 +1878,7 @@ export function useChat(options: UseChatOptions): UseChatReturn { hasQueued: messageQueue.hasQueued, queueSize: messageQueue.queueSize, heartbeat, + compactStatus, lifecycleStage, sendMessage, stopGeneration, diff --git a/mateclaw-ui/src/composables/chat/useStream.ts b/mateclaw-ui/src/composables/chat/useStream.ts index 01f92d6c..b4c8ec3c 100644 --- a/mateclaw-ui/src/composables/chat/useStream.ts +++ b/mateclaw-ui/src/composables/chat/useStream.ts @@ -60,6 +60,11 @@ export type SSEEventType = | 'delegation_batch' // Recovery affordance for non-transient errors (ERROR_FALLBACK turns) | 'feedback_event' + // Context compaction lifecycle. Fired by ConversationWindowManager + // around each compaction pass so the UI can show a progress chip + // (start → pair_safe → summarize → done/skipped/failed). Payload + // carries preTokens/postTokens/messagesSummarized/tailKept/etc. + | 'compact_status' 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 46082e1a..6418bae8 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -297,6 +297,12 @@ export default { streamReconnecting: 'Reconnecting...', streamStopped: 'Stopped', streamCompleted: 'Completed', + // Context compaction (compact_status SSE) + compactStart: 'Compacting context…', + compactPairSafe: 'Adjusting boundary to preserve tool_call pairs…', + compactSummarize: 'Summarizing the older turns…', + compactStartWithTokens: 'Compacting context… ({tokens})', + compactSummarizeWithCount: 'Summarizing the older turns… ({count} msgs)', streamPreparingContextDetail: 'Collecting the current question, conversation history, and required context.', streamReadingMemoryDetail: 'Looking up the most relevant long-term memory and recent notes for this question.', streamReasoningDetail: 'Comparing options and organizing the answer structure based on the available information.', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 80266b66..49ba1b74 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -297,6 +297,12 @@ export default { streamReconnecting: '重新连接...', streamStopped: '已停止', streamCompleted: '已完成', + // Context compaction (compact_status SSE) + compactStart: '正在压缩上下文…', + compactPairSafe: '调整边界以保留 tool_call 配对…', + compactSummarize: '正在生成压缩摘要…', + compactStartWithTokens: '正在压缩上下文… ({tokens})', + compactSummarizeWithCount: '正在生成压缩摘要… ({count} 条)', streamPreparingContextDetail: '正在收集当前问题、历史对话和必要配置。', streamReadingMemoryDetail: '正在查找与你当前问题最相关的长期记忆和今日记录。', streamReasoningDetail: '正在基于现有信息进行判断、比较方案并组织回答结构。', diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index bcba6d5c..dcad686d 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -284,6 +284,7 @@ :running-tool-name="currentRunningToolName" :has-queued="hasQueued" :lifecycle-stage="lifecycleStage" + :compact-status="compactStatus" />