mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(context): consume compact_status on the frontend; load history from latest boundary; backfill summaryId
This commit is contained in:
parent
51b5eceb7d
commit
faf7f98358
@ -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;
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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: '正在基于现有信息进行判断、比较方案并组织回答结构。',
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user