feat: add context compaction to automatically summarize old messages (#compaction) (#276)

When input tokens exceed a configurable threshold after a conversation round,
the system asynchronously compacts older messages into a summary. Cascading
compactions reference prior summaries via <prior_context> tags to maintain
conversational continuity without duplicating content.

- Add bot_history_message_compacts table and compact_id on messages
- Add compaction_enabled, compaction_threshold, compaction_model_id to bots
- Implement compaction service (internal/compaction) with LLM summarization
- Integrate into conversation flow: replace compacted messages with summaries
  wrapped in <summary> tags during context loading
- Add REST API endpoints (GET/DELETE /bots/:bot_id/compaction/logs)
- Add frontend Compaction tab with settings and log viewer
- Wire compaction service into both dev (cmd/agent) and prod (cmd/memoh) entry points
- Update test mocks to include new GetBotByID columns
This commit is contained in:
Acbox Liu
2026-03-22 14:26:00 +08:00
committed by GitHub
parent 91e5e44509
commit de62f94315
40 changed files with 2375 additions and 197 deletions
+25
View File
@@ -564,6 +564,7 @@
"mcp": "MCP",
"subagents": "Subagents",
"heartbeat": "Heartbeat",
"compaction": "Compaction",
"schedule": "Schedule",
"history": "History",
"skills": "Skills",
@@ -777,6 +778,12 @@
"heartbeatModel": "Heartbeat Model",
"heartbeatModelDescription": "Select a model for heartbeat checks. Defaults to the bot's chat model if not set.",
"heartbeatModelPlaceholder": "Use chat model (default)",
"compactionEnabled": "Enable Context Compaction",
"compactionDescription": "Automatically summarize older messages when context gets too large",
"compactionThreshold": "Compaction Threshold (input tokens)",
"compactionModel": "Compaction Model",
"compactionModelDescription": "Select a model for summarization. Defaults to the bot's chat model if not set.",
"compactionModelPlaceholder": "Use chat model (default)",
"browserContext": "Browser Context",
"browserContextPlaceholder": "Select browser context (disabled if empty)",
"allowGuest": "Allow Guest Access",
@@ -935,6 +942,24 @@
"saveFailed": "Failed to save skill",
"loadFailed": "Failed to load skills"
},
"compaction": {
"title": "Compaction Logs",
"loadFailed": "Failed to load compaction logs",
"empty": "No compaction logs",
"loadMore": "Load More",
"clearLogs": "Clear Logs",
"clearConfirm": "Are you sure you want to clear all compaction logs? This cannot be undone.",
"clearSuccess": "Compaction logs cleared",
"clearFailed": "Failed to clear compaction logs",
"status": "Status",
"time": "Time",
"duration": "Duration",
"error": "Error",
"statusOk": "OK",
"statusPending": "Pending",
"statusError": "Error",
"filterAll": "All"
},
"heartbeat": {
"title": "Heartbeat Logs",
"loadFailed": "Failed to load heartbeat logs",
+25
View File
@@ -560,6 +560,7 @@
"mcp": "MCP",
"subagents": "子智能体",
"heartbeat": "心跳",
"compaction": "上下文压缩",
"schedule": "定时任务",
"history": "对话历史",
"skills": "技能",
@@ -773,6 +774,12 @@
"heartbeatModel": "心跳模型",
"heartbeatModelDescription": "选择心跳检查使用的模型,未设置时默认使用聊天模型。",
"heartbeatModelPlaceholder": "使用聊天模型(默认)",
"compactionEnabled": "启用上下文压缩",
"compactionDescription": "上下文过大时自动摘要旧消息以节省 token",
"compactionThreshold": "压缩阈值(输入 token 数)",
"compactionModel": "压缩模型",
"compactionModelDescription": "选择用于摘要的模型,未设置时默认使用聊天模型。",
"compactionModelPlaceholder": "使用聊天模型(默认)",
"browserContext": "浏览器上下文",
"browserContextPlaceholder": "选择浏览器上下文(未配置时不启用)",
"allowGuest": "允许游客访问",
@@ -931,6 +938,24 @@
"saveFailed": "保存技能失败",
"loadFailed": "加载技能失败"
},
"compaction": {
"title": "压缩记录",
"loadFailed": "加载压缩记录失败",
"empty": "暂无压缩记录",
"loadMore": "加载更多",
"clearLogs": "清空记录",
"clearConfirm": "确定要清空所有压缩记录吗?此操作不可撤销。",
"clearSuccess": "压缩记录已清空",
"clearFailed": "清空压缩记录失败",
"status": "状态",
"time": "时间",
"duration": "耗时",
"error": "错误",
"statusOk": "成功",
"statusPending": "进行中",
"statusError": "错误",
"filterAll": "全部"
},
"heartbeat": {
"title": "心跳日志",
"loadFailed": "加载心跳日志失败",
@@ -0,0 +1,431 @@
<template>
<div class="space-y-6">
<!-- Settings -->
<div class="space-y-4 mx-auto">
<div class="flex items-center justify-between">
<div>
<Label>{{ $t('bots.settings.compactionEnabled') }}</Label>
<p class="text-xs text-muted-foreground mt-0.5">
{{ $t('bots.settings.compactionDescription') }}
</p>
</div>
<Switch
:model-value="settingsForm.compaction_enabled"
@update:model-value="(val) => settingsForm.compaction_enabled = !!val"
/>
</div>
<div
v-if="settingsForm.compaction_enabled"
class="space-y-4"
>
<div class="space-y-2">
<Label>{{ $t('bots.settings.compactionThreshold') }}</Label>
<Input
v-model.number="settingsForm.compaction_threshold"
type="number"
:min="1"
:placeholder="'100000'"
:aria-label="$t('bots.settings.compactionThreshold')"
/>
</div>
<div class="space-y-2">
<Label>{{ $t('bots.settings.compactionModel') }}</Label>
<p class="text-xs text-muted-foreground mt-0.5">
{{ $t('bots.settings.compactionModelDescription') }}
</p>
<ModelSelect
v-model="settingsForm.compaction_model_id"
:models="models"
:providers="providers"
model-type="chat"
:placeholder="$t('bots.settings.compactionModelPlaceholder')"
/>
</div>
</div>
<div class="flex justify-end">
<Button
size="sm"
:disabled="!settingsChanged || isSaving"
@click="handleSaveSettings"
>
<Spinner
v-if="isSaving"
class="mr-2 size-4"
/>
{{ $t('bots.settings.save') }}
</Button>
</div>
</div>
<Separator />
<!-- Logs header -->
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium">
{{ $t('bots.compaction.title') }}
</h3>
<div class="flex items-center gap-2">
<NativeSelect
v-model="statusFilter"
class="h-9 w-28 text-sm"
>
<option value="">
{{ $t('bots.compaction.filterAll') }}
</option>
<option value="ok">
{{ $t('bots.compaction.statusOk') }}
</option>
<option value="pending">
{{ $t('bots.compaction.statusPending') }}
</option>
<option value="error">
{{ $t('bots.compaction.statusError') }}
</option>
</NativeSelect>
<ConfirmPopover
v-if="logs.length > 0"
:message="$t('bots.compaction.clearConfirm')"
:loading="isClearing"
:confirm-text="$t('bots.compaction.clearLogs')"
@confirm="handleClear"
>
<template #trigger>
<Button
variant="outline"
size="sm"
:disabled="isClearing"
>
{{ $t('bots.compaction.clearLogs') }}
</Button>
</template>
</ConfirmPopover>
<Button
variant="outline"
size="sm"
:disabled="isLoading"
@click="handleRefresh"
>
<Spinner
v-if="isLoading"
class="mr-2 size-4"
/>
{{ $t('common.refresh') }}
</Button>
</div>
</div>
<!-- Loading -->
<div
v-if="isLoading && logs.length === 0"
class="flex items-center justify-center py-8 text-sm text-muted-foreground"
>
<Spinner class="mr-2" />
{{ $t('common.loading') }}
</div>
<!-- Empty -->
<div
v-else-if="!isLoading && filteredLogs.length === 0"
class="flex flex-col items-center justify-center py-12 text-center"
>
<div class="rounded-full bg-muted p-3 mb-4">
<FontAwesomeIcon
:icon="['fas', 'compress']"
class="size-6 text-muted-foreground"
/>
</div>
<p class="text-sm text-muted-foreground">
{{ $t('bots.compaction.empty') }}
</p>
</div>
<!-- Logs -->
<template v-else>
<div class="rounded-md border">
<table class="w-full text-sm">
<thead>
<tr class="border-b bg-muted/50">
<th class="px-4 py-2 text-left font-medium">
{{ $t('bots.compaction.status') }}
</th>
<th class="px-4 py-2 text-left font-medium">
{{ $t('bots.compaction.time') }}
</th>
<th class="px-4 py-2 text-left font-medium">
{{ $t('bots.compaction.duration') }}
</th>
<th class="px-4 py-2 text-left font-medium">
{{ $t('bots.compaction.error') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="log in filteredLogs"
:key="log.id"
class="border-b last:border-0 hover:bg-muted/30 cursor-pointer"
@click="toggleExpand(log.id)"
>
<td class="px-4 py-2">
<Badge :variant="statusVariant(log.status)">
{{ statusLabel(log.status) }}
</Badge>
</td>
<td class="px-4 py-2 text-muted-foreground">
{{ formatDateTime(log.started_at) }}
</td>
<td class="px-4 py-2 text-muted-foreground">
{{ formatDuration(log.started_at, log.completed_at) }}
</td>
<td class="px-4 py-2">
<span
v-if="log.error_message"
class="text-destructive"
>{{ log.error_message }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Expanded detail -->
<div
v-for="log in filteredLogs.filter(l => l.id && expandedIds.has(l.id))"
:key="'detail-' + log.id"
class="rounded-md border bg-muted/20 p-4 text-sm whitespace-pre-wrap wrap-break-word"
>
<p
v-if="log.error_message"
class="text-destructive"
>
{{ log.error_message }}
</p>
<p
v-if="log.usage"
class="text-muted-foreground mt-2 text-xs"
>
Usage: {{ JSON.stringify(log.usage) }}
</p>
</div>
<!-- Load more -->
<div
v-if="hasMore"
class="flex justify-center pt-2"
>
<Button
variant="outline"
size="sm"
:disabled="isLoading"
@click="loadMore"
>
<Spinner
v-if="isLoading"
class="mr-2 size-4"
/>
{{ $t('bots.compaction.loadMore') }}
</Button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import {
Button, Badge, Spinner, NativeSelect, Label, Switch, Input, Separator,
} from '@memoh/ui'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import ModelSelect from './model-select.vue'
import {
getBotsByBotIdSettings, putBotsByBotIdSettings,
getBotsByBotIdCompactionLogs, deleteBotsByBotIdCompactionLogs,
getModels, getProviders,
} from '@memoh/sdk'
import type { SettingsSettings, SettingsUpsertRequest, CompactionLog } from '@memoh/sdk'
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
import { resolveApiErrorMessage } from '@/utils/api-error'
import { formatDateTime } from '@/utils/date-time'
import type { Ref } from 'vue'
const props = defineProps<{
botId: string
}>()
const { t } = useI18n()
const botIdRef = computed(() => props.botId) as Ref<string>
// ---- Settings ----
const queryCache = useQueryCache()
const { data: settings } = useQuery({
key: () => ['bot-settings', botIdRef.value],
query: async () => {
const { data } = await getBotsByBotIdSettings({ path: { bot_id: botIdRef.value }, throwOnError: true })
return data
},
enabled: () => !!botIdRef.value,
})
const { data: modelData } = useQuery({
key: ['all-models'],
query: async () => {
const { data } = await getModels({ throwOnError: true })
return data
},
})
const { data: providerData } = useQuery({
key: ['all-providers'],
query: async () => {
const { data } = await getProviders({ throwOnError: true })
return data
},
})
const models = computed(() => modelData.value ?? [])
const providers = computed(() => providerData.value ?? [])
const settingsForm = reactive({
compaction_enabled: false,
compaction_threshold: 100000,
compaction_model_id: '',
})
watch(settings, (val: SettingsSettings | undefined) => {
if (val) {
settingsForm.compaction_enabled = val.compaction_enabled ?? false
settingsForm.compaction_threshold = val.compaction_threshold ?? 100000
settingsForm.compaction_model_id = val.compaction_model_id ?? ''
}
}, { immediate: true })
const settingsChanged = computed(() => {
if (!settings.value) return false
const s: SettingsSettings = settings.value
return settingsForm.compaction_enabled !== (s.compaction_enabled ?? false)
|| settingsForm.compaction_threshold !== (s.compaction_threshold ?? 100000)
|| settingsForm.compaction_model_id !== (s.compaction_model_id ?? '')
})
const { mutateAsync: updateSettings, isLoading: isSaving } = useMutation({
mutation: async (body: SettingsUpsertRequest) => {
const { data } = await putBotsByBotIdSettings({
path: { bot_id: botIdRef.value },
body,
throwOnError: true,
})
return data
},
onSettled: () => queryCache.invalidateQueries({ key: ['bot-settings', botIdRef.value] }),
})
async function handleSaveSettings() {
try {
await updateSettings({ ...settingsForm })
toast.success(t('bots.settings.saveSuccess'))
} catch {
return
}
}
// ---- Logs ----
const isLoading = ref(false)
const isClearing = ref(false)
const logs = ref<CompactionLog[]>([])
const statusFilter = ref('')
const expandedIds = ref(new Set<string>())
const hasMore = ref(false)
const PAGE_SIZE = 50
const filteredLogs = computed(() => {
if (!statusFilter.value) return logs.value
return logs.value.filter(l => l.status === statusFilter.value)
})
function statusVariant(status: string | undefined) {
if (status === 'ok') return 'secondary' as const
if (status === 'pending') return 'default' as const
return 'destructive' as const
}
function statusLabel(status: string | undefined) {
if (status === 'ok') return t('bots.compaction.statusOk')
if (status === 'pending') return t('bots.compaction.statusPending')
return t('bots.compaction.statusError')
}
function formatDuration(startedAt: string | undefined, completedAt: string | null | undefined) {
if (!startedAt || !completedAt) return '—'
const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime()
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
function toggleExpand(id: string | undefined) {
if (!id) return
if (expandedIds.value.has(id)) {
expandedIds.value.delete(id)
} else {
expandedIds.value.add(id)
}
}
async function fetchLogs(before?: string) {
if (!props.botId) return
isLoading.value = true
try {
const { data } = await getBotsByBotIdCompactionLogs({
path: { bot_id: props.botId },
query: { limit: PAGE_SIZE, ...(before ? { before } : {}) },
throwOnError: true,
})
const items = data?.items ?? []
if (!before) {
logs.value = items
} else {
logs.value.push(...items)
}
hasMore.value = items.length >= PAGE_SIZE
} catch (error) {
toast.error(resolveApiErrorMessage(error, t('bots.compaction.loadFailed')))
} finally {
isLoading.value = false
}
}
async function loadMore() {
if (logs.value.length === 0) return
const lastLog = logs.value[logs.value.length - 1]
await fetchLogs(lastLog?.started_at)
}
async function handleRefresh() {
expandedIds.value.clear()
await fetchLogs()
}
async function handleClear() {
isClearing.value = true
try {
await deleteBotsByBotIdCompactionLogs({
path: { bot_id: props.botId },
throwOnError: true,
})
logs.value = []
expandedIds.value.clear()
toast.success(t('bots.compaction.clearSuccess'))
} catch (error) {
toast.error(resolveApiErrorMessage(error, t('bots.compaction.clearFailed')))
} finally {
isClearing.value = false
}
}
onMounted(() => {
fetchLogs()
})
</script>
+2
View File
@@ -243,6 +243,7 @@ import BotMemory from './components/bot-memory.vue'
import BotSkills from './components/bot-skills.vue'
import BotHistory from './components/bot-history.vue'
import BotHeartbeat from './components/bot-heartbeat.vue'
import BotCompaction from './components/bot-compaction.vue'
import BotEmail from './components/bot-email.vue'
import BotSubagents from './components/bot-subagents.vue'
import BotOverview from './components/bot-overview.vue'
@@ -287,6 +288,7 @@ const tabList = computed(() => {
{ value: 'mcp', label: 'bots.tabs.mcp', component: BotMcp, params: { 'bot-id': bot_id } },
{ value: 'subagents', label: 'bots.tabs.subagents', component: BotSubagents, params: { 'bot-id': bot_id } },
{ value: 'heartbeat', label: 'bots.tabs.heartbeat', component: BotHeartbeat, params: { 'bot-id': bot_id } },
{ value: 'compaction', label: 'bots.tabs.compaction', component: BotCompaction, params: { 'bot-id': bot_id } },
{ value: 'schedule', label: 'bots.tabs.schedule', component: BotSchedule, params: { 'bot-id': bot_id } },
{ value: 'history', label: 'bots.tabs.history', component: BotHistory, params: { 'bot-id': bot_id } },
{ value: 'skills', label: 'bots.tabs.skills', component: BotSkills, params: { 'bot-id': bot_id } },