mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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 } },
|
||||
|
||||
Reference in New Issue
Block a user