feat: add immediate context compaction API, UI button, and /compact slash command

- Add POST /bots/:bot_id/sessions/:session_id/compact endpoint for
  synchronous context compaction with fallback to chat model when no
  dedicated compaction model is configured
- Add "Compact Now" button to session info panel in the web UI
- Add /compact slash command for triggering compaction from chat
- Regenerate OpenAPI spec and TypeScript SDK
This commit is contained in:
Acbox
2026-04-14 21:30:05 +08:00
parent 6328281fc2
commit 27d2b99301
15 changed files with 529 additions and 40 deletions
+4 -1
View File
@@ -229,7 +229,10 @@
"infoCacheWrite": "Cache Write",
"infoSkills": "Skills",
"infoNoSkills": "No skills used in this session",
"infoNoData": "No data available"
"infoNoData": "No data available",
"compactNow": "Compact Now",
"compactSuccess": "Context compaction completed",
"compactFailed": "Context compaction failed"
},
"models": {
"title": "Models",
+4 -1
View File
@@ -225,7 +225,10 @@
"infoCacheWrite": "Cache 写入",
"infoSkills": "Skills",
"infoNoSkills": "此会话未使用任何 Skill",
"infoNoData": "暂无数据"
"infoNoData": "暂无数据",
"compactNow": "立即压缩",
"compactSuccess": "上下文压缩完成",
"compactFailed": "上下文压缩失败"
},
"models": {
"title": "模型",
@@ -65,6 +65,26 @@
</div>
</div>
<!-- Compact Now -->
<div class="mt-3">
<button
type="button"
class="flex items-center justify-center gap-1.5 w-full px-2 py-1.5 rounded-md text-xs font-medium text-foreground bg-accent hover:bg-accent/80 transition-colors disabled:opacity-50 disabled:pointer-events-none"
:disabled="!sessionId || usedTokens <= 0 || isCompacting"
@click="triggerCompact"
>
<Loader2
v-if="isCompacting"
class="size-3 animate-spin"
/>
<Minimize2
v-else
class="size-3"
/>
{{ $t('chat.compactNow') }}
</button>
</div>
<!-- Skills -->
<div class="mt-3">
<p class="text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
@@ -99,13 +119,16 @@
</template>
<script setup lang="ts">
import { computed, inject } from 'vue'
import { computed, inject, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useQuery } from '@pinia/colada'
import { Sparkles, ExternalLink } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import { useQuery, useQueryCache } from '@pinia/colada'
import { toast } from 'vue-sonner'
import { Sparkles, ExternalLink, Loader2, Minimize2 } from 'lucide-vue-next'
import { ScrollArea } from '@memohai/ui'
import { getBotsByBotIdSessionsBySessionIdStatus } from '@memohai/sdk'
import { getBotsByBotIdSessionsBySessionIdStatus, postBotsByBotIdSessionsBySessionIdCompact } from '@memohai/sdk'
import type { HandlersSessionInfoResponse } from '@memohai/sdk'
import { resolveApiErrorMessage } from '@/utils/api-error'
import { useChatStore } from '@/store/chat-list'
import { openInFileManagerKey } from '../composables/useFileManagerProvider'
@@ -114,9 +137,11 @@ const props = defineProps<{
overrideModelId?: string
}>()
const { t } = useI18n()
const chatStore = useChatStore()
const { currentBotId, sessionId } = storeToRefs(chatStore)
const openInFileManager = inject(openInFileManagerKey, undefined)
const queryCache = useQueryCache()
const { data: info } = useQuery({
key: () => ['session-status', currentBotId.value ?? '', sessionId.value ?? '', props.overrideModelId ?? ''],
@@ -165,4 +190,28 @@ function formatTokenCount(n: number): string {
function openSkillFile(skillName: string) {
openInFileManager?.(`/data/skills/${skillName}/SKILL.md`, false)
}
const isCompacting = ref(false)
async function triggerCompact() {
const botId = currentBotId.value
const sid = sessionId.value
if (!botId || !sid || isCompacting.value) return
isCompacting.value = true
try {
await postBotsByBotIdSessionsBySessionIdCompact({
path: { bot_id: botId, session_id: sid },
throwOnError: true,
})
toast.success(t('chat.compactSuccess'))
queryCache.invalidateQueries({ key: ['session-status', botId, sid] })
}
catch (error) {
toast.error(resolveApiErrorMessage(error, t('chat.compactFailed')))
}
finally {
isCompacting.value = false
}
}
</script>
+5 -2
View File
@@ -337,6 +337,7 @@ func provideChannelRouter(
emailService *emailpkg.Service,
emailOutboxService *emailpkg.OutboxService,
heartbeatService *heartbeat.Service,
compactionService *compaction.Service,
queries *dbsqlc.Queries,
containerdHandler *handlers.ContainerdHandler,
manager *workspace.Manager,
@@ -366,7 +367,7 @@ func provideChannelRouter(
processor.SetStreamObserver(local.NewRouteHubBroadcaster(hub))
processor.SetDispatcher(inbound.NewRouteDispatcher(log))
processor.SetTtsService(ttsService, &settingsTtsModelResolver{settings: settingsService})
processor.SetCommandHandler(command.NewHandler(
cmdHandler := command.NewHandler(
log,
&command.BotMemberRoleAdapter{BotService: botService},
scheduleService,
@@ -384,7 +385,9 @@ func provideChannelRouter(
aclService,
&commandSkillLoaderAdapter{handler: containerdHandler},
&commandContainerFSAdapter{manager: manager},
))
)
cmdHandler.SetCompactionService(compactionService, queries)
processor.SetCommandHandler(cmdHandler)
return processor
}
+1
View File
@@ -17,5 +17,6 @@ func (h *Handler) buildRegistry() *Registry {
r.RegisterGroup(h.buildFSGroup())
r.RegisterGroup(h.buildStatusGroup())
r.RegisterGroup(h.buildAccessGroup())
r.RegisterGroup(h.buildCompactGroup())
return r
}
+94
View File
@@ -0,0 +1,94 @@
package command
import (
"errors"
"fmt"
"github.com/google/uuid"
"github.com/memohai/memoh/internal/compaction"
"github.com/memohai/memoh/internal/db"
"github.com/memohai/memoh/internal/models"
"github.com/memohai/memoh/internal/providers"
)
func (h *Handler) buildCompactGroup() *CommandGroup {
g := newCommandGroup("compact", "Compact conversation context")
g.DefaultAction = "run"
g.Register(SubCommand{
Name: "run",
Usage: "run - Compact the current session's context immediately",
IsWrite: true,
Handler: func(cc CommandContext) (string, error) {
if h.compactionService == nil {
return "Compaction service is not available.", nil
}
sessionID := cc.SessionID
if sessionID == "" {
botUUID, err := db.ParseUUID(cc.BotID)
if err != nil {
return "", fmt.Errorf("invalid bot id: %w", err)
}
latestUUID, err := h.queries.GetLatestSessionIDByBot(cc.Ctx, botUUID)
if err != nil {
return "No active session found.", nil
}
sessionID = uuid.UUID(latestUUID.Bytes).String()
}
cfg, err := h.buildCompactConfig(cc, sessionID)
if err != nil {
return "", err
}
if err := h.compactionService.RunCompactionSync(cc.Ctx, cfg); err != nil {
return "", fmt.Errorf("compaction failed: %w", err)
}
return "Context compaction completed successfully.", nil
},
})
return g
}
func (h *Handler) buildCompactConfig(cc CommandContext, sessionID string) (compaction.TriggerConfig, error) {
botSettings, err := h.settingsService.GetBot(cc.Ctx, cc.BotID)
if err != nil {
return compaction.TriggerConfig{}, fmt.Errorf("failed to load settings: %w", err)
}
modelID := botSettings.CompactionModelID
if modelID == "" {
modelID = botSettings.ChatModelID
}
if modelID == "" {
return compaction.TriggerConfig{}, errors.New("no compaction or chat model configured for this bot")
}
compactModel, err := h.modelsService.GetByID(cc.Ctx, modelID)
if err != nil {
return compaction.TriggerConfig{}, fmt.Errorf("failed to load compaction model: %w", err)
}
compactProvider, err := models.FetchProviderByID(cc.Ctx, h.sqlcQueries, compactModel.ProviderID)
if err != nil {
return compaction.TriggerConfig{}, fmt.Errorf("failed to load provider: %w", err)
}
creds, err := h.providersService.ResolveModelCredentials(cc.Ctx, compactProvider)
if err != nil {
return compaction.TriggerConfig{}, fmt.Errorf("failed to resolve credentials: %w", err)
}
cfg := compaction.TriggerConfig{
BotID: cc.BotID,
SessionID: sessionID,
ModelID: compactModel.ModelID,
ClientType: compactProvider.ClientType,
APIKey: creds.APIKey,
CodexAccountID: creds.CodexAccountID,
BaseURL: providers.ProviderConfigString(compactProvider, "base_url"),
Ratio: 100,
TotalInputTokens: 1,
}
if compactModel.Config.ContextWindow != nil && *compactModel.Config.ContextWindow > 0 {
cfg.MaxCompactTokens = *compactModel.Config.ContextWindow * 90 / 100
}
return cfg, nil
}
+10
View File
@@ -8,6 +8,8 @@ import (
"github.com/memohai/memoh/internal/bots"
"github.com/memohai/memoh/internal/browsercontexts"
"github.com/memohai/memoh/internal/compaction"
"github.com/memohai/memoh/internal/db/sqlc"
emailpkg "github.com/memohai/memoh/internal/email"
"github.com/memohai/memoh/internal/heartbeat"
"github.com/memohai/memoh/internal/mcp"
@@ -56,7 +58,9 @@ type Handler struct {
emailService *emailpkg.Service
emailOutboxService *emailpkg.OutboxService
heartbeatService *heartbeat.Service
compactionService *compaction.Service
queries CommandQueries
sqlcQueries *sqlc.Queries
aclEvaluator AccessEvaluator
skillLoader SkillLoader
containerFS ContainerFS
@@ -124,6 +128,12 @@ func NewHandler(
return h
}
// SetCompactionService configures the compaction service for the /compact command.
func (h *Handler) SetCompactionService(s *compaction.Service, q *sqlc.Queries) {
h.compactionService = s
h.sqlcQueries = q
}
// topLevelCommands are standalone commands (no sub-actions) that are
// recognised by IsCommand and listed in /help. They are handled outside
// the regular resource-group dispatch (e.g. in the channel inbound
+129 -9
View File
@@ -11,21 +11,42 @@ import (
"github.com/memohai/memoh/internal/accounts"
"github.com/memohai/memoh/internal/bots"
"github.com/memohai/memoh/internal/compaction"
"github.com/memohai/memoh/internal/db/sqlc"
"github.com/memohai/memoh/internal/models"
"github.com/memohai/memoh/internal/providers"
"github.com/memohai/memoh/internal/settings"
)
type CompactionHandler struct {
service *compaction.Service
botService *bots.Service
accountService *accounts.Service
logger *slog.Logger
service *compaction.Service
botService *bots.Service
accountService *accounts.Service
settingsService *settings.Service
modelsService *models.Service
queries *sqlc.Queries
providersService *providers.Service
logger *slog.Logger
}
func NewCompactionHandler(log *slog.Logger, service *compaction.Service, botService *bots.Service, accountService *accounts.Service) *CompactionHandler {
func NewCompactionHandler(
log *slog.Logger,
service *compaction.Service,
botService *bots.Service,
accountService *accounts.Service,
settingsService *settings.Service,
modelsService *models.Service,
queries *sqlc.Queries,
providersService *providers.Service,
) *CompactionHandler {
return &CompactionHandler{
service: service,
botService: botService,
accountService: accountService,
logger: log.With(slog.String("handler", "compaction")),
service: service,
botService: botService,
accountService: accountService,
settingsService: settingsService,
modelsService: modelsService,
queries: queries,
providersService: providersService,
logger: log.With(slog.String("handler", "compaction")),
}
}
@@ -33,6 +54,7 @@ func (h *CompactionHandler) Register(e *echo.Echo) {
group := e.Group("/bots/:bot_id/compaction")
group.GET("/logs", h.ListLogs)
group.DELETE("/logs", h.DeleteLogs)
e.POST("/bots/:bot_id/sessions/:session_id/compact", h.TriggerCompact)
}
// ListLogs godoc
@@ -94,6 +116,104 @@ func (h *CompactionHandler) DeleteLogs(c echo.Context) error {
return c.NoContent(http.StatusNoContent)
}
// TriggerCompactResponse is the API response for triggering compaction.
type TriggerCompactResponse struct {
Status string `json:"status"`
Summary string `json:"summary,omitempty"`
MessageCount int `json:"message_count"`
}
// TriggerCompact godoc
// @Summary Trigger immediate context compaction
// @Description Run context compaction synchronously for a session
// @Tags compaction
// @Param bot_id path string true "Bot ID"
// @Param session_id path string true "Session ID"
// @Success 200 {object} TriggerCompactResponse
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/sessions/{session_id}/compact [post].
func (h *CompactionHandler) TriggerCompact(c echo.Context) error {
userID, err := h.requireUserID(c)
if err != nil {
return err
}
botID := strings.TrimSpace(c.Param("bot_id"))
if botID == "" {
return echo.NewHTTPError(http.StatusBadRequest, "bot id is required")
}
if _, err := h.authorizeBotAccess(c.Request().Context(), userID, botID); err != nil {
return err
}
sessionID := strings.TrimSpace(c.Param("session_id"))
if sessionID == "" {
return echo.NewHTTPError(http.StatusBadRequest, "session id is required")
}
cfg, err := h.buildTriggerConfig(c.Request().Context(), botID, sessionID)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if err := h.service.RunCompactionSync(c.Request().Context(), cfg); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
logs, _, err := h.service.ListLogs(c.Request().Context(), botID, 1, 0)
if err != nil || len(logs) == 0 {
return c.JSON(http.StatusOK, TriggerCompactResponse{Status: "ok"})
}
latest := logs[0]
return c.JSON(http.StatusOK, TriggerCompactResponse{
Status: latest.Status,
Summary: latest.Summary,
MessageCount: latest.MessageCount,
})
}
func (h *CompactionHandler) buildTriggerConfig(ctx context.Context, botID, sessionID string) (compaction.TriggerConfig, error) {
botSettings, err := h.settingsService.GetBot(ctx, botID)
if err != nil {
return compaction.TriggerConfig{}, err
}
modelID := botSettings.CompactionModelID
if modelID == "" {
modelID = botSettings.ChatModelID
}
if modelID == "" {
return compaction.TriggerConfig{}, echo.NewHTTPError(http.StatusBadRequest, "no compaction or chat model configured")
}
compactModel, err := h.modelsService.GetByID(ctx, modelID)
if err != nil {
return compaction.TriggerConfig{}, err
}
compactProvider, err := models.FetchProviderByID(ctx, h.queries, compactModel.ProviderID)
if err != nil {
return compaction.TriggerConfig{}, err
}
creds, err := h.providersService.ResolveModelCredentials(ctx, compactProvider)
if err != nil {
return compaction.TriggerConfig{}, err
}
cfg := compaction.TriggerConfig{
BotID: botID,
SessionID: sessionID,
ModelID: compactModel.ModelID,
ClientType: compactProvider.ClientType,
APIKey: creds.APIKey,
CodexAccountID: creds.CodexAccountID,
BaseURL: providers.ProviderConfigString(compactProvider, "base_url"),
Ratio: 100,
TotalInputTokens: 1,
}
if compactModel.Config.ContextWindow != nil && *compactModel.Config.ContextWindow > 0 {
cfg.MaxCompactTokens = *compactModel.Config.ContextWindow * 90 / 100
}
return cfg, nil
}
func (*CompactionHandler) requireUserID(c echo.Context) (string, error) {
return RequireChannelIdentityID(c)
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+44 -2
View File
@@ -1054,6 +1054,12 @@ export type HandlersTokenUsageResponse = {
schedule?: Array<HandlersDailyTokenUsage>;
};
export type HandlersTriggerCompactResponse = {
message_count?: number;
status?: string;
summary?: string;
};
export type HandlersCreateSessionRequest = {
channel_type?: string;
metadata?: {
@@ -1585,7 +1591,6 @@ export type SettingsSettings = {
compaction_model_id?: string;
compaction_ratio?: number;
compaction_threshold?: number;
context_token_budget?: number;
discuss_probe_model_id?: string;
heartbeat_enabled?: boolean;
heartbeat_interval?: number;
@@ -1610,7 +1615,6 @@ export type SettingsUpsertRequest = {
compaction_model_id?: string;
compaction_ratio?: number;
compaction_threshold?: number;
context_token_budget?: number;
discuss_probe_model_id?: string;
heartbeat_enabled?: boolean;
heartbeat_interval?: number;
@@ -5284,6 +5288,44 @@ export type PatchBotsByBotIdSessionsBySessionIdResponses = {
export type PatchBotsByBotIdSessionsBySessionIdResponse = PatchBotsByBotIdSessionsBySessionIdResponses[keyof PatchBotsByBotIdSessionsBySessionIdResponses];
export type PostBotsByBotIdSessionsBySessionIdCompactData = {
body?: never;
path: {
/**
* Bot ID
*/
bot_id: string;
/**
* Session ID
*/
session_id: string;
};
query?: never;
url: '/bots/{bot_id}/sessions/{session_id}/compact';
};
export type PostBotsByBotIdSessionsBySessionIdCompactErrors = {
/**
* Bad Request
*/
400: HandlersErrorResponse;
/**
* Internal Server Error
*/
500: HandlersErrorResponse;
};
export type PostBotsByBotIdSessionsBySessionIdCompactError = PostBotsByBotIdSessionsBySessionIdCompactErrors[keyof PostBotsByBotIdSessionsBySessionIdCompactErrors];
export type PostBotsByBotIdSessionsBySessionIdCompactResponses = {
/**
* OK
*/
200: HandlersTriggerCompactResponse;
};
export type PostBotsByBotIdSessionsBySessionIdCompactResponse = PostBotsByBotIdSessionsBySessionIdCompactResponses[keyof PostBotsByBotIdSessionsBySessionIdCompactResponses];
export type GetBotsByBotIdSessionsBySessionIdStatusData = {
body?: never;
path: {
+59 -6
View File
@@ -4395,6 +4395,51 @@ const docTemplate = `{
}
}
},
"/bots/{bot_id}/sessions/{session_id}/compact": {
"post": {
"description": "Run context compaction synchronously for a session",
"tags": [
"compaction"
],
"summary": "Trigger immediate context compaction",
"parameters": [
{
"type": "string",
"description": "Bot ID",
"name": "bot_id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Session ID",
"name": "session_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.TriggerCompactResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/bots/{bot_id}/sessions/{session_id}/status": {
"get": {
"description": "Get aggregated info for a chat session including message count, context usage, cache stats, and used skills",
@@ -11357,6 +11402,20 @@ const docTemplate = `{
}
}
},
"handlers.TriggerCompactResponse": {
"type": "object",
"properties": {
"message_count": {
"type": "integer"
},
"status": {
"type": "string"
},
"summary": {
"type": "string"
}
}
},
"handlers.createSessionRequest": {
"type": "object",
"properties": {
@@ -12678,9 +12737,6 @@ const docTemplate = `{
"compaction_threshold": {
"type": "integer"
},
"context_token_budget": {
"type": "integer"
},
"discuss_probe_model_id": {
"type": "string"
},
@@ -12749,9 +12805,6 @@ const docTemplate = `{
"compaction_threshold": {
"type": "integer"
},
"context_token_budget": {
"type": "integer"
},
"discuss_probe_model_id": {
"type": "string"
},
+59 -6
View File
@@ -4386,6 +4386,51 @@
}
}
},
"/bots/{bot_id}/sessions/{session_id}/compact": {
"post": {
"description": "Run context compaction synchronously for a session",
"tags": [
"compaction"
],
"summary": "Trigger immediate context compaction",
"parameters": [
{
"type": "string",
"description": "Bot ID",
"name": "bot_id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Session ID",
"name": "session_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.TriggerCompactResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/bots/{bot_id}/sessions/{session_id}/status": {
"get": {
"description": "Get aggregated info for a chat session including message count, context usage, cache stats, and used skills",
@@ -11348,6 +11393,20 @@
}
}
},
"handlers.TriggerCompactResponse": {
"type": "object",
"properties": {
"message_count": {
"type": "integer"
},
"status": {
"type": "string"
},
"summary": {
"type": "string"
}
}
},
"handlers.createSessionRequest": {
"type": "object",
"properties": {
@@ -12669,9 +12728,6 @@
"compaction_threshold": {
"type": "integer"
},
"context_token_budget": {
"type": "integer"
},
"discuss_probe_model_id": {
"type": "string"
},
@@ -12740,9 +12796,6 @@
"compaction_threshold": {
"type": "integer"
},
"context_token_budget": {
"type": "integer"
},
"discuss_probe_model_id": {
"type": "string"
},
+39 -4
View File
@@ -1780,6 +1780,15 @@ definitions:
$ref: '#/definitions/handlers.DailyTokenUsage'
type: array
type: object
handlers.TriggerCompactResponse:
properties:
message_count:
type: integer
status:
type: string
summary:
type: string
type: object
handlers.createSessionRequest:
properties:
channel_type:
@@ -2659,8 +2668,6 @@ definitions:
type: integer
compaction_threshold:
type: integer
context_token_budget:
type: integer
discuss_probe_model_id:
type: string
heartbeat_enabled:
@@ -2706,8 +2713,6 @@ definitions:
type: integer
compaction_threshold:
type: integer
context_token_budget:
type: integer
discuss_probe_model_id:
type: string
heartbeat_enabled:
@@ -5766,6 +5771,36 @@ paths:
summary: Update a session
tags:
- sessions
/bots/{bot_id}/sessions/{session_id}/compact:
post:
description: Run context compaction synchronously for a session
parameters:
- description: Bot ID
in: path
name: bot_id
required: true
type: string
- description: Session ID
in: path
name: session_id
required: true
type: string
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.TriggerCompactResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Trigger immediate context compaction
tags:
- compaction
/bots/{bot_id}/sessions/{session_id}/status:
get:
description: Get aggregated info for a chat session including message count,