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