feat(context): consume compact_status on the frontend; load history from latest boundary; backfill summaryId

This commit is contained in:
matevip 2026-05-13 08:50:35 +08:00
parent 51b5eceb7d
commit faf7f98358
8 changed files with 183 additions and 3 deletions

View File

@ -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;
}

View File

@ -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());
}

View File

@ -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<Props>(), {
@ -66,6 +90,7 @@ const props = withDefaults(defineProps<Props>(), {
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]

View File

@ -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<number>
/** Latest heartbeat data */
heartbeat: import('vue').Ref<HeartbeatData | null>
/**
* 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<CompactStatusEvent | 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
@ -127,6 +173,14 @@ export function useChat(options: UseChatOptions): UseChatReturn {
let stopFallbackTimer: ReturnType<typeof setTimeout> | null = null
const streamPhase = ref<StreamPhase>('idle')
const phaseInfo = ref<PhaseEventData | null>(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<CompactStatusEvent | null>(null)
/** All segments of the current assistant message (for segmented display) */
const currentSegments = ref<MessageSegment[]>([])
@ -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,

View File

@ -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

View File

@ -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.',

View File

@ -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: '正在基于现有信息进行判断、比较方案并组织回答结构。',

View File

@ -284,6 +284,7 @@
:running-tool-name="currentRunningToolName"
:has-queued="hasQueued"
:lifecycle-stage="lifecycleStage"
:compact-status="compactStatus"
/>
<!-- Multimodal routing hint: shown when pending attachments require a
@ -664,6 +665,7 @@ const {
hasQueued,
queueSize,
heartbeat,
compactStatus,
lifecycleStage,
sendMessage: sendChatMessage,
stopGeneration: stopChatGeneration,