mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +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:
@@ -74,9 +74,12 @@ func makeBotRow(botID, ownerUserID pgtype.UUID) *fakeRow {
|
||||
*dest[14].(*bool) = false
|
||||
*dest[15].(*int32) = 30
|
||||
*dest[16].(*string) = ""
|
||||
*dest[17].(*[]byte) = []byte(`{}`)
|
||||
*dest[18].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
|
||||
*dest[19].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
|
||||
*dest[17].(*bool) = false // CompactionEnabled
|
||||
*dest[18].(*int32) = 100000 // CompactionThreshold
|
||||
*dest[19].(*pgtype.UUID) = pgtype.UUID{} // CompactionModelID
|
||||
*dest[20].(*[]byte) = []byte(`{}`)
|
||||
*dest[21].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
|
||||
*dest[22].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -44,11 +44,13 @@ func (d *fakeDBTX) QueryRow(ctx context.Context, sql string, args ...any) pgx.Ro
|
||||
// Column order: id, owner_user_id, display_name, avatar_url, is_active, status,
|
||||
// max_context_load_time, max_context_tokens, language,
|
||||
// reasoning_enabled, reasoning_effort, chat_model_id, search_provider_id, memory_provider_id,
|
||||
// heartbeat_enabled, heartbeat_interval, heartbeat_prompt, metadata, created_at, updated_at.
|
||||
// heartbeat_enabled, heartbeat_interval, heartbeat_prompt,
|
||||
// compaction_enabled, compaction_threshold, compaction_model_id,
|
||||
// metadata, created_at, updated_at.
|
||||
func makeBotRow(botID, ownerUserID pgtype.UUID) *fakeRow {
|
||||
return &fakeRow{
|
||||
scanFunc: func(dest ...any) error {
|
||||
if len(dest) < 20 {
|
||||
if len(dest) < 23 {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
*dest[0].(*pgtype.UUID) = botID
|
||||
@@ -68,9 +70,12 @@ func makeBotRow(botID, ownerUserID pgtype.UUID) *fakeRow {
|
||||
*dest[14].(*bool) = false // HeartbeatEnabled
|
||||
*dest[15].(*int32) = 30 // HeartbeatInterval
|
||||
*dest[16].(*string) = "" // HeartbeatPrompt
|
||||
*dest[17].(*[]byte) = []byte(`{}`)
|
||||
*dest[18].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
|
||||
*dest[19].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
|
||||
*dest[17].(*bool) = false // CompactionEnabled
|
||||
*dest[18].(*int32) = 100000 // CompactionThreshold
|
||||
*dest[19].(*pgtype.UUID) = pgtype.UUID{} // CompactionModelID
|
||||
*dest[20].(*[]byte) = []byte(`{}`)
|
||||
*dest[21].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
|
||||
*dest[22].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const systemPrompt = `You are a conversation summarizer. Given a conversation history, produce a concise summary that preserves:
|
||||
- Key facts, decisions, and agreements
|
||||
- User preferences and requests
|
||||
- Important context needed for continuing the conversation
|
||||
- Names, dates, numbers, and specific details
|
||||
- Tool usage outcomes and their results
|
||||
|
||||
If <prior_context> is provided, it contains summaries of earlier conversation segments. Use them ONLY to understand the conversation flow and maintain continuity. Do NOT include, repeat, or rephrase any content from <prior_context> in your output.
|
||||
|
||||
Output ONLY the summary of the new conversation segment. No preamble, no headers.`
|
||||
|
||||
type messageEntry struct {
|
||||
Role string
|
||||
Content string
|
||||
}
|
||||
|
||||
func buildUserPrompt(priorSummaries []string, messages []messageEntry) string {
|
||||
var sb strings.Builder
|
||||
if len(priorSummaries) > 0 {
|
||||
sb.WriteString("<prior_context>\n")
|
||||
sb.WriteString("The following are summaries of earlier parts of this conversation. They are provided ONLY as reference context to help you understand the conversation flow. Do NOT include or repeat any of this content in your output summary.\n\n")
|
||||
sb.WriteString(strings.Join(priorSummaries, "\n---\n"))
|
||||
sb.WriteString("\n</prior_context>\n\n")
|
||||
sb.WriteString("Now summarize the following conversation segment:\n")
|
||||
} else {
|
||||
sb.WriteString("Summarize the following conversation:\n")
|
||||
}
|
||||
for _, m := range messages {
|
||||
fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
sdk "github.com/memohai/twilight-ai/sdk"
|
||||
|
||||
"github.com/memohai/memoh/internal/agent"
|
||||
"github.com/memohai/memoh/internal/db"
|
||||
"github.com/memohai/memoh/internal/db/sqlc"
|
||||
)
|
||||
|
||||
// Service manages context compaction for bot conversations.
|
||||
type Service struct {
|
||||
queries *sqlc.Queries
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewService creates a new compaction Service.
|
||||
func NewService(log *slog.Logger, queries *sqlc.Queries) *Service {
|
||||
return &Service{
|
||||
queries: queries,
|
||||
logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldCompact returns true if inputTokens exceeds the threshold.
|
||||
func ShouldCompact(inputTokens, threshold int) bool {
|
||||
return threshold > 0 && inputTokens >= threshold
|
||||
}
|
||||
|
||||
// TriggerCompaction runs compaction in the background.
|
||||
func (s *Service) TriggerCompaction(ctx context.Context, cfg TriggerConfig) {
|
||||
go func() {
|
||||
bgCtx := context.WithoutCancel(ctx)
|
||||
if err := s.runCompaction(bgCtx, cfg); err != nil {
|
||||
s.logger.Error("compaction failed", slog.String("bot_id", cfg.BotID), slog.String("session_id", cfg.SessionID), slog.String("error", err.Error()))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Service) runCompaction(ctx context.Context, cfg TriggerConfig) error {
|
||||
botUUID, err := db.ParseUUID(cfg.BotID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sessionUUID, err := db.ParseUUID(cfg.SessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logRow, err := s.queries.CreateCompactionLog(ctx, sqlc.CreateCompactionLogParams{
|
||||
BotID: botUUID,
|
||||
SessionID: sessionUUID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
compactErr := s.doCompaction(ctx, logRow.ID, sessionUUID, cfg)
|
||||
if compactErr != nil {
|
||||
s.completeLog(ctx, logRow.ID, "error", "", compactErr.Error(), nil, pgtype.UUID{})
|
||||
}
|
||||
return compactErr
|
||||
}
|
||||
|
||||
func (s *Service) doCompaction(ctx context.Context, logID pgtype.UUID, sessionUUID pgtype.UUID, cfg TriggerConfig) error {
|
||||
messages, err := s.queries.ListUncompactedMessagesBySession(ctx, sessionUUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
s.completeLog(ctx, logID, "ok", "", "", nil, pgtype.UUID{})
|
||||
return nil
|
||||
}
|
||||
|
||||
priorLogs, err := s.queries.ListCompactionLogsBySession(ctx, sessionUUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var priorSummaries []string
|
||||
for _, l := range priorLogs {
|
||||
if l.Summary != "" {
|
||||
priorSummaries = append(priorSummaries, l.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
entries := make([]messageEntry, 0, len(messages))
|
||||
messageIDs := make([]pgtype.UUID, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
entries = append(entries, messageEntry{
|
||||
Role: m.Role,
|
||||
Content: extractTextContent(m.Content),
|
||||
})
|
||||
messageIDs = append(messageIDs, m.ID)
|
||||
}
|
||||
|
||||
userPrompt := buildUserPrompt(priorSummaries, entries)
|
||||
|
||||
model := agent.CreateModel(agent.ModelConfig{
|
||||
ClientType: cfg.ClientType,
|
||||
BaseURL: cfg.BaseURL,
|
||||
APIKey: cfg.APIKey,
|
||||
ModelID: cfg.ModelID,
|
||||
})
|
||||
|
||||
result, err := sdk.GenerateTextResult(ctx,
|
||||
sdk.WithModel(model),
|
||||
sdk.WithSystem(systemPrompt),
|
||||
sdk.WithMessages([]sdk.Message{sdk.UserMessage(userPrompt)}),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
usageJSON, _ := json.Marshal(result.Usage)
|
||||
|
||||
modelUUID := db.ParseUUIDOrEmpty(cfg.ModelID)
|
||||
|
||||
if err := s.queries.MarkMessagesCompacted(ctx, sqlc.MarkMessagesCompactedParams{
|
||||
CompactID: logID,
|
||||
MessageIds: messageIDs,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.completeLog(ctx, logID, "ok", result.Text, "", usageJSON, modelUUID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) completeLog(ctx context.Context, logID pgtype.UUID, status, summary, errMsg string, usage []byte, modelID pgtype.UUID) {
|
||||
if _, err := s.queries.CompleteCompactionLog(ctx, sqlc.CompleteCompactionLogParams{
|
||||
ID: logID,
|
||||
Status: status,
|
||||
Summary: summary,
|
||||
MessageCount: 0,
|
||||
ErrorMessage: errMsg,
|
||||
Usage: usage,
|
||||
ModelID: modelID,
|
||||
}); err != nil {
|
||||
s.logger.Error("failed to complete compaction log", slog.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
// ListLogs returns paginated compaction logs for a bot.
|
||||
func (s *Service) ListLogs(ctx context.Context, botID string, before *time.Time, limit int) ([]Log, error) {
|
||||
botUUID, err := db.ParseUUID(botID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var beforeTS pgtype.Timestamptz
|
||||
if before != nil {
|
||||
beforeTS = pgtype.Timestamptz{Time: *before, Valid: true}
|
||||
}
|
||||
|
||||
clampedLimit := limit
|
||||
if clampedLimit > 1000 {
|
||||
clampedLimit = 1000
|
||||
}
|
||||
rows, err := s.queries.ListCompactionLogsByBot(ctx, sqlc.ListCompactionLogsByBotParams{
|
||||
BotID: botUUID,
|
||||
Column2: beforeTS,
|
||||
Limit: int32(clampedLimit), //nolint:gosec // clamped above
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logs := make([]Log, len(rows))
|
||||
for i, r := range rows {
|
||||
logs[i] = toLog(r)
|
||||
}
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
// DeleteLogs deletes all compaction logs for a bot.
|
||||
func (s *Service) DeleteLogs(ctx context.Context, botID string) error {
|
||||
botUUID, err := db.ParseUUID(botID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.queries.DeleteCompactionLogsByBot(ctx, botUUID)
|
||||
}
|
||||
|
||||
func toLog(r sqlc.BotHistoryMessageCompact) Log {
|
||||
l := Log{
|
||||
ID: formatUUID(r.ID),
|
||||
BotID: formatUUID(r.BotID),
|
||||
SessionID: formatUUID(r.SessionID),
|
||||
Status: r.Status,
|
||||
Summary: r.Summary,
|
||||
MessageCount: int(r.MessageCount),
|
||||
ErrorMessage: r.ErrorMessage,
|
||||
ModelID: formatUUID(r.ModelID),
|
||||
StartedAt: r.StartedAt.Time,
|
||||
}
|
||||
if r.CompletedAt.Valid {
|
||||
t := r.CompletedAt.Time
|
||||
l.CompletedAt = &t
|
||||
}
|
||||
if len(r.Usage) > 0 {
|
||||
var u any
|
||||
if json.Unmarshal(r.Usage, &u) == nil {
|
||||
l.Usage = u
|
||||
}
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func formatUUID(id pgtype.UUID) string {
|
||||
if !id.Valid {
|
||||
return ""
|
||||
}
|
||||
return uuid.UUID(id.Bytes).String()
|
||||
}
|
||||
|
||||
// extractTextContent extracts plain text from a message content JSONB field.
|
||||
// The content may be a JSON string, an array of content parts, or raw bytes.
|
||||
func extractTextContent(content []byte) string {
|
||||
if len(content) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var s string
|
||||
if json.Unmarshal(content, &s) == nil {
|
||||
return s
|
||||
}
|
||||
|
||||
var parts []map[string]any
|
||||
if json.Unmarshal(content, &parts) == nil {
|
||||
var texts []string
|
||||
for _, p := range parts {
|
||||
if t, ok := p["type"].(string); ok && t == "text" {
|
||||
if text, ok := p["text"].(string); ok {
|
||||
texts = append(texts, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(texts) > 0 {
|
||||
return joinTexts(texts)
|
||||
}
|
||||
}
|
||||
|
||||
return string(content)
|
||||
}
|
||||
|
||||
func joinTexts(parts []string) string {
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package compaction
|
||||
|
||||
import "time"
|
||||
|
||||
// Log represents a compaction log entry.
|
||||
type Log struct {
|
||||
ID string `json:"id"`
|
||||
BotID string `json:"bot_id"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
MessageCount int `json:"message_count"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
Usage any `json:"usage,omitempty"`
|
||||
ModelID string `json:"model_id,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
// ListLogsResponse is the API response for listing compaction logs.
|
||||
type ListLogsResponse struct {
|
||||
Items []Log `json:"items"`
|
||||
}
|
||||
|
||||
// TriggerConfig holds the parameters needed to trigger a compaction.
|
||||
type TriggerConfig struct {
|
||||
BotID string
|
||||
SessionID string
|
||||
ModelID string
|
||||
ClientType string
|
||||
APIKey string //nolint:gosec // runtime credential, not a hardcoded secret
|
||||
BaseURL string
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
sdk "github.com/memohai/twilight-ai/sdk"
|
||||
|
||||
agentpkg "github.com/memohai/memoh/internal/agent"
|
||||
"github.com/memohai/memoh/internal/compaction"
|
||||
"github.com/memohai/memoh/internal/conversation"
|
||||
"github.com/memohai/memoh/internal/db/sqlc"
|
||||
memprovider "github.com/memohai/memoh/internal/memory/adapters"
|
||||
@@ -49,19 +50,20 @@ type gatewayAssetLoader interface {
|
||||
|
||||
// Resolver orchestrates chat with the internal agent.
|
||||
type Resolver struct {
|
||||
agent *agentpkg.Agent
|
||||
modelsService *models.Service
|
||||
queries *sqlc.Queries
|
||||
memoryRegistry *memprovider.Registry
|
||||
conversationSvc ConversationSettingsReader
|
||||
messageService messagepkg.Service
|
||||
settingsService *settings.Service
|
||||
sessionService SessionService
|
||||
eventPublisher messageevent.Publisher
|
||||
skillLoader SkillLoader
|
||||
assetLoader gatewayAssetLoader
|
||||
timeout time.Duration
|
||||
logger *slog.Logger
|
||||
agent *agentpkg.Agent
|
||||
modelsService *models.Service
|
||||
queries *sqlc.Queries
|
||||
memoryRegistry *memprovider.Registry
|
||||
conversationSvc ConversationSettingsReader
|
||||
messageService messagepkg.Service
|
||||
settingsService *settings.Service
|
||||
sessionService SessionService
|
||||
compactionService *compaction.Service
|
||||
eventPublisher messageevent.Publisher
|
||||
skillLoader SkillLoader
|
||||
assetLoader gatewayAssetLoader
|
||||
timeout time.Duration
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewResolver creates a Resolver that uses the internal agent directly.
|
||||
@@ -106,6 +108,11 @@ func (r *Resolver) SetGatewayAssetLoader(loader gatewayAssetLoader) {
|
||||
r.assetLoader = loader
|
||||
}
|
||||
|
||||
// SetCompactionService configures the compaction service for context compaction.
|
||||
func (r *Resolver) SetCompactionService(s *compaction.Service) {
|
||||
r.compactionService = s
|
||||
}
|
||||
|
||||
type usageInfo struct {
|
||||
InputTokens *int `json:"inputTokens"`
|
||||
OutputTokens *int `json:"outputTokens"`
|
||||
@@ -199,6 +206,7 @@ func (r *Resolver) resolve(ctx context.Context, req conversation.ChatRequest) (r
|
||||
}
|
||||
loaded = pruneHistoryForGateway(loaded)
|
||||
loaded = dedupePersistedCurrentUserMessage(loaded, req)
|
||||
loaded = r.replaceCompactedMessages(ctx, loaded)
|
||||
messages = trimMessagesByTokens(r.logger, loaded, historyBudget)
|
||||
r.logger.Debug("context trim result",
|
||||
slog.Int("loaded_messages", len(loaded)),
|
||||
@@ -318,6 +326,11 @@ func (r *Resolver) Chat(ctx context.Context, req conversation.ChatRequest) (conv
|
||||
if err := r.storeRound(ctx, req, roundMessages, rc.model.ID); err != nil {
|
||||
return conversation.ChatResponse{}, err
|
||||
}
|
||||
|
||||
if result.Usage != nil {
|
||||
go r.maybeCompact(context.WithoutCancel(ctx), req, rc, result.Usage.InputTokens)
|
||||
}
|
||||
|
||||
return conversation.ChatResponse{
|
||||
Messages: outputMessages,
|
||||
Model: rc.model.ModelID,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/memohai/memoh/internal/compaction"
|
||||
"github.com/memohai/memoh/internal/conversation"
|
||||
"github.com/memohai/memoh/internal/models"
|
||||
)
|
||||
|
||||
func (r *Resolver) maybeCompact(ctx context.Context, req conversation.ChatRequest, rc resolvedContext, inputTokens int) {
|
||||
if r.compactionService == nil || r.settingsService == nil {
|
||||
return
|
||||
}
|
||||
settings, err := r.settingsService.GetBot(ctx, req.BotID)
|
||||
if err != nil {
|
||||
r.logger.Warn("compaction: failed to load settings", slog.Any("error", err))
|
||||
return
|
||||
}
|
||||
if !settings.CompactionEnabled || settings.CompactionThreshold <= 0 {
|
||||
return
|
||||
}
|
||||
if !compaction.ShouldCompact(inputTokens, settings.CompactionThreshold) {
|
||||
return
|
||||
}
|
||||
|
||||
modelID := settings.CompactionModelID
|
||||
if modelID == "" {
|
||||
modelID = rc.model.ID
|
||||
}
|
||||
|
||||
cfg := compaction.TriggerConfig{
|
||||
BotID: req.BotID,
|
||||
SessionID: req.SessionID,
|
||||
}
|
||||
|
||||
model, err := r.modelsService.GetByID(ctx, modelID)
|
||||
if err != nil {
|
||||
r.logger.Warn("compaction: failed to resolve model", slog.Any("error", err))
|
||||
return
|
||||
}
|
||||
cfg.ModelID = model.ModelID
|
||||
cfg.ClientType = string(model.ClientType)
|
||||
|
||||
provider, err := models.FetchProviderByID(ctx, r.queries, model.LlmProviderID)
|
||||
if err != nil {
|
||||
r.logger.Warn("compaction: failed to fetch provider", slog.Any("error", err))
|
||||
return
|
||||
}
|
||||
cfg.APIKey = provider.ApiKey
|
||||
cfg.BaseURL = provider.BaseUrl
|
||||
|
||||
r.compactionService.TriggerCompaction(ctx, cfg)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/memohai/memoh/internal/conversation"
|
||||
"github.com/memohai/memoh/internal/db"
|
||||
messagepkg "github.com/memohai/memoh/internal/message"
|
||||
)
|
||||
|
||||
@@ -19,6 +20,7 @@ type messageWithUsage struct {
|
||||
ExternalMessageID string
|
||||
Platform string
|
||||
SenderChannelID string
|
||||
CompactID string
|
||||
}
|
||||
|
||||
func (r *Resolver) loadMessages(ctx context.Context, chatID string, sessionID string, maxContextMinutes int) ([]messageWithUsage, error) {
|
||||
@@ -63,6 +65,7 @@ func (r *Resolver) loadMessages(ctx context.Context, chatID string, sessionID st
|
||||
ExternalMessageID: strings.TrimSpace(m.ExternalMessageID),
|
||||
Platform: strings.TrimSpace(m.Platform),
|
||||
SenderChannelID: strings.TrimSpace(m.SenderChannelIdentityID),
|
||||
CompactID: strings.TrimSpace(m.CompactID),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
@@ -165,3 +168,62 @@ func trimMessagesByTokens(log *slog.Logger, messages []messageWithUsage, maxToke
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *Resolver) replaceCompactedMessages(ctx context.Context, messages []messageWithUsage) []messageWithUsage {
|
||||
if r.queries == nil {
|
||||
return messages
|
||||
}
|
||||
|
||||
compactGroups := make(map[string][]int) // compact_id -> indices
|
||||
for i, m := range messages {
|
||||
if m.CompactID != "" {
|
||||
compactGroups[m.CompactID] = append(compactGroups[m.CompactID], i)
|
||||
}
|
||||
}
|
||||
if len(compactGroups) == 0 {
|
||||
return messages
|
||||
}
|
||||
|
||||
summaries := make(map[string]string)
|
||||
for compactID := range compactGroups {
|
||||
cUUID, err := db.ParseUUID(compactID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
log, err := r.queries.GetCompactionLogByID(ctx, cUUID)
|
||||
if err != nil {
|
||||
r.logger.Warn("replaceCompactedMessages: failed to load compact log", slog.String("compact_id", compactID), slog.Any("error", err))
|
||||
continue
|
||||
}
|
||||
if log.Status == "ok" && log.Summary != "" {
|
||||
summaries[compactID] = log.Summary
|
||||
}
|
||||
}
|
||||
|
||||
var result []messageWithUsage
|
||||
replaced := make(map[string]bool)
|
||||
for _, m := range messages {
|
||||
if m.CompactID == "" {
|
||||
result = append(result, m)
|
||||
continue
|
||||
}
|
||||
if replaced[m.CompactID] {
|
||||
continue
|
||||
}
|
||||
replaced[m.CompactID] = true
|
||||
summary, ok := summaries[m.CompactID]
|
||||
if !ok || summary == "" {
|
||||
for _, idx := range compactGroups[m.CompactID] {
|
||||
result = append(result, messages[idx])
|
||||
}
|
||||
continue
|
||||
}
|
||||
result = append(result, messageWithUsage{
|
||||
Message: conversation.ModelMessage{
|
||||
Role: "user",
|
||||
Content: json.RawMessage(`"<summary>\n` + summary + `\n</summary>"`),
|
||||
},
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func (r *Resolver) StreamChat(ctx context.Context, req conversation.ChatRequest)
|
||||
continue
|
||||
}
|
||||
if !stored && event.IsTerminal() && len(event.Messages) > 0 {
|
||||
if _, storeErr := r.tryStoreStream(ctx, streamReq, data, rc.model.ID); storeErr != nil {
|
||||
if _, storeErr := r.tryStoreStream(ctx, streamReq, data, rc.model.ID, rc); storeErr != nil {
|
||||
r.logger.Error("stream persist failed", slog.Any("error", storeErr))
|
||||
} else {
|
||||
stored = true
|
||||
@@ -124,7 +124,7 @@ func (r *Resolver) StreamChatWS(
|
||||
}
|
||||
|
||||
if !stored && event.IsTerminal() && len(event.Messages) > 0 {
|
||||
if _, storeErr := r.tryStoreStream(ctx, req, data, modelID); storeErr != nil {
|
||||
if _, storeErr := r.tryStoreStream(ctx, req, data, modelID, rc); storeErr != nil {
|
||||
r.logger.Error("ws persist failed", slog.Any("error", storeErr))
|
||||
} else {
|
||||
stored = true
|
||||
@@ -142,10 +142,11 @@ func (r *Resolver) StreamChatWS(
|
||||
}
|
||||
|
||||
// tryStoreStream attempts to extract final messages from a stream event and persist them.
|
||||
func (r *Resolver) tryStoreStream(ctx context.Context, req conversation.ChatRequest, data []byte, modelID string) (bool, error) {
|
||||
func (r *Resolver) tryStoreStream(ctx context.Context, req conversation.ChatRequest, data []byte, modelID string, rc resolvedContext) (bool, error) {
|
||||
var envelope struct {
|
||||
Type string `json:"type"`
|
||||
Messages json.RawMessage `json:"messages"`
|
||||
Usage json.RawMessage `json:"usage,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &envelope); err != nil {
|
||||
return false, nil
|
||||
@@ -161,5 +162,26 @@ func (r *Resolver) tryStoreStream(ctx context.Context, req conversation.ChatRequ
|
||||
outputMessages := sdkMessagesToModelMessages(sdkMsgs)
|
||||
roundMessages := prependUserMessage(req.Query, outputMessages)
|
||||
|
||||
return true, r.storeRound(ctx, req, roundMessages, modelID)
|
||||
if err := r.storeRound(ctx, req, roundMessages, modelID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if inputTokens := extractInputTokensFromUsage(envelope.Usage); inputTokens > 0 {
|
||||
go r.maybeCompact(context.WithoutCancel(ctx), req, rc, inputTokens)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func extractInputTokensFromUsage(raw json.RawMessage) int {
|
||||
if len(raw) == 0 {
|
||||
return 0
|
||||
}
|
||||
var u struct {
|
||||
InputTokens int `json:"inputTokens"`
|
||||
}
|
||||
if json.Unmarshal(raw, &u) != nil {
|
||||
return 0
|
||||
}
|
||||
return u.InputTokens
|
||||
}
|
||||
|
||||
@@ -94,32 +94,35 @@ func (q *Queries) DeleteBotByID(ctx context.Context, id pgtype.UUID) error {
|
||||
}
|
||||
|
||||
const getBotByID = `-- name: GetBotByID :one
|
||||
SELECT id, owner_user_id, display_name, avatar_url, is_active, status, max_context_load_time, max_context_tokens, language, reasoning_enabled, reasoning_effort, chat_model_id, search_provider_id, memory_provider_id, heartbeat_enabled, heartbeat_interval, heartbeat_prompt, metadata, created_at, updated_at
|
||||
SELECT id, owner_user_id, display_name, avatar_url, is_active, status, max_context_load_time, max_context_tokens, language, reasoning_enabled, reasoning_effort, chat_model_id, search_provider_id, memory_provider_id, heartbeat_enabled, heartbeat_interval, heartbeat_prompt, compaction_enabled, compaction_threshold, compaction_model_id, metadata, created_at, updated_at
|
||||
FROM bots
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
type GetBotByIDRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
OwnerUserID pgtype.UUID `json:"owner_user_id"`
|
||||
DisplayName pgtype.Text `json:"display_name"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Status string `json:"status"`
|
||||
MaxContextLoadTime int32 `json:"max_context_load_time"`
|
||||
MaxContextTokens int32 `json:"max_context_tokens"`
|
||||
Language string `json:"language"`
|
||||
ReasoningEnabled bool `json:"reasoning_enabled"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
ChatModelID pgtype.UUID `json:"chat_model_id"`
|
||||
SearchProviderID pgtype.UUID `json:"search_provider_id"`
|
||||
MemoryProviderID pgtype.UUID `json:"memory_provider_id"`
|
||||
HeartbeatEnabled bool `json:"heartbeat_enabled"`
|
||||
HeartbeatInterval int32 `json:"heartbeat_interval"`
|
||||
HeartbeatPrompt string `json:"heartbeat_prompt"`
|
||||
Metadata []byte `json:"metadata"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
OwnerUserID pgtype.UUID `json:"owner_user_id"`
|
||||
DisplayName pgtype.Text `json:"display_name"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Status string `json:"status"`
|
||||
MaxContextLoadTime int32 `json:"max_context_load_time"`
|
||||
MaxContextTokens int32 `json:"max_context_tokens"`
|
||||
Language string `json:"language"`
|
||||
ReasoningEnabled bool `json:"reasoning_enabled"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
ChatModelID pgtype.UUID `json:"chat_model_id"`
|
||||
SearchProviderID pgtype.UUID `json:"search_provider_id"`
|
||||
MemoryProviderID pgtype.UUID `json:"memory_provider_id"`
|
||||
HeartbeatEnabled bool `json:"heartbeat_enabled"`
|
||||
HeartbeatInterval int32 `json:"heartbeat_interval"`
|
||||
HeartbeatPrompt string `json:"heartbeat_prompt"`
|
||||
CompactionEnabled bool `json:"compaction_enabled"`
|
||||
CompactionThreshold int32 `json:"compaction_threshold"`
|
||||
CompactionModelID pgtype.UUID `json:"compaction_model_id"`
|
||||
Metadata []byte `json:"metadata"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetBotByID(ctx context.Context, id pgtype.UUID) (GetBotByIDRow, error) {
|
||||
@@ -143,6 +146,9 @@ func (q *Queries) GetBotByID(ctx context.Context, id pgtype.UUID) (GetBotByIDRow
|
||||
&i.HeartbeatEnabled,
|
||||
&i.HeartbeatInterval,
|
||||
&i.HeartbeatPrompt,
|
||||
&i.CompactionEnabled,
|
||||
&i.CompactionThreshold,
|
||||
&i.CompactionModelID,
|
||||
&i.Metadata,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: compaction_logs.sql
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const completeCompactionLog = `-- name: CompleteCompactionLog :one
|
||||
UPDATE bot_history_message_compacts
|
||||
SET status = $2,
|
||||
summary = $3,
|
||||
message_count = $4,
|
||||
error_message = $5,
|
||||
usage = $6,
|
||||
model_id = $7,
|
||||
completed_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, bot_id, session_id, status, summary, message_count, error_message, usage, model_id, started_at, completed_at
|
||||
`
|
||||
|
||||
type CompleteCompactionLogParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
MessageCount int32 `json:"message_count"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
Usage []byte `json:"usage"`
|
||||
ModelID pgtype.UUID `json:"model_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CompleteCompactionLog(ctx context.Context, arg CompleteCompactionLogParams) (BotHistoryMessageCompact, error) {
|
||||
row := q.db.QueryRow(ctx, completeCompactionLog,
|
||||
arg.ID,
|
||||
arg.Status,
|
||||
arg.Summary,
|
||||
arg.MessageCount,
|
||||
arg.ErrorMessage,
|
||||
arg.Usage,
|
||||
arg.ModelID,
|
||||
)
|
||||
var i BotHistoryMessageCompact
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.BotID,
|
||||
&i.SessionID,
|
||||
&i.Status,
|
||||
&i.Summary,
|
||||
&i.MessageCount,
|
||||
&i.ErrorMessage,
|
||||
&i.Usage,
|
||||
&i.ModelID,
|
||||
&i.StartedAt,
|
||||
&i.CompletedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createCompactionLog = `-- name: CreateCompactionLog :one
|
||||
INSERT INTO bot_history_message_compacts (bot_id, session_id, started_at)
|
||||
VALUES ($1, $2::uuid, now())
|
||||
RETURNING id, bot_id, session_id, status, summary, message_count, error_message, usage, started_at, completed_at
|
||||
`
|
||||
|
||||
type CreateCompactionLogParams struct {
|
||||
BotID pgtype.UUID `json:"bot_id"`
|
||||
SessionID pgtype.UUID `json:"session_id"`
|
||||
}
|
||||
|
||||
type CreateCompactionLogRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
BotID pgtype.UUID `json:"bot_id"`
|
||||
SessionID pgtype.UUID `json:"session_id"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
MessageCount int32 `json:"message_count"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
Usage []byte `json:"usage"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
CompletedAt pgtype.Timestamptz `json:"completed_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateCompactionLog(ctx context.Context, arg CreateCompactionLogParams) (CreateCompactionLogRow, error) {
|
||||
row := q.db.QueryRow(ctx, createCompactionLog, arg.BotID, arg.SessionID)
|
||||
var i CreateCompactionLogRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.BotID,
|
||||
&i.SessionID,
|
||||
&i.Status,
|
||||
&i.Summary,
|
||||
&i.MessageCount,
|
||||
&i.ErrorMessage,
|
||||
&i.Usage,
|
||||
&i.StartedAt,
|
||||
&i.CompletedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteCompactionLogsByBot = `-- name: DeleteCompactionLogsByBot :exec
|
||||
DELETE FROM bot_history_message_compacts WHERE bot_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteCompactionLogsByBot(ctx context.Context, botID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteCompactionLogsByBot, botID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getCompactionLogByID = `-- name: GetCompactionLogByID :one
|
||||
SELECT id, bot_id, session_id, status, summary, message_count, error_message, usage, model_id, started_at, completed_at
|
||||
FROM bot_history_message_compacts
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetCompactionLogByID(ctx context.Context, id pgtype.UUID) (BotHistoryMessageCompact, error) {
|
||||
row := q.db.QueryRow(ctx, getCompactionLogByID, id)
|
||||
var i BotHistoryMessageCompact
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.BotID,
|
||||
&i.SessionID,
|
||||
&i.Status,
|
||||
&i.Summary,
|
||||
&i.MessageCount,
|
||||
&i.ErrorMessage,
|
||||
&i.Usage,
|
||||
&i.ModelID,
|
||||
&i.StartedAt,
|
||||
&i.CompletedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listCompactionLogsByBot = `-- name: ListCompactionLogsByBot :many
|
||||
SELECT id, bot_id, session_id, status, summary, message_count, error_message, usage, model_id, started_at, completed_at
|
||||
FROM bot_history_message_compacts
|
||||
WHERE bot_id = $1
|
||||
AND ($2::timestamptz IS NULL OR started_at < $2::timestamptz)
|
||||
ORDER BY started_at DESC
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type ListCompactionLogsByBotParams struct {
|
||||
BotID pgtype.UUID `json:"bot_id"`
|
||||
Column2 pgtype.Timestamptz `json:"column_2"`
|
||||
Limit int32 `json:"limit"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListCompactionLogsByBot(ctx context.Context, arg ListCompactionLogsByBotParams) ([]BotHistoryMessageCompact, error) {
|
||||
rows, err := q.db.Query(ctx, listCompactionLogsByBot, arg.BotID, arg.Column2, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []BotHistoryMessageCompact
|
||||
for rows.Next() {
|
||||
var i BotHistoryMessageCompact
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.BotID,
|
||||
&i.SessionID,
|
||||
&i.Status,
|
||||
&i.Summary,
|
||||
&i.MessageCount,
|
||||
&i.ErrorMessage,
|
||||
&i.Usage,
|
||||
&i.ModelID,
|
||||
&i.StartedAt,
|
||||
&i.CompletedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listCompactionLogsBySession = `-- name: ListCompactionLogsBySession :many
|
||||
SELECT id, bot_id, session_id, status, summary, message_count, error_message, usage, model_id, started_at, completed_at
|
||||
FROM bot_history_message_compacts
|
||||
WHERE session_id = $1
|
||||
AND status = 'ok'
|
||||
ORDER BY started_at ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListCompactionLogsBySession(ctx context.Context, sessionID pgtype.UUID) ([]BotHistoryMessageCompact, error) {
|
||||
rows, err := q.db.Query(ctx, listCompactionLogsBySession, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []BotHistoryMessageCompact
|
||||
for rows.Next() {
|
||||
var i BotHistoryMessageCompact
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.BotID,
|
||||
&i.SessionID,
|
||||
&i.Status,
|
||||
&i.Summary,
|
||||
&i.MessageCount,
|
||||
&i.ErrorMessage,
|
||||
&i.Usage,
|
||||
&i.ModelID,
|
||||
&i.StartedAt,
|
||||
&i.CompletedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -511,7 +511,7 @@ WITH updated AS (
|
||||
SET display_name = $1,
|
||||
updated_at = now()
|
||||
WHERE bots.id = $2
|
||||
RETURNING id, owner_user_id, display_name, avatar_url, is_active, status, max_context_load_time, max_context_tokens, language, reasoning_enabled, reasoning_effort, chat_model_id, search_provider_id, memory_provider_id, heartbeat_enabled, heartbeat_interval, heartbeat_prompt, heartbeat_model_id, title_model_id, tts_model_id, browser_context_id, metadata, created_at, updated_at
|
||||
RETURNING id, owner_user_id, display_name, avatar_url, is_active, status, max_context_load_time, max_context_tokens, language, reasoning_enabled, reasoning_effort, chat_model_id, search_provider_id, memory_provider_id, heartbeat_enabled, heartbeat_interval, heartbeat_prompt, heartbeat_model_id, title_model_id, tts_model_id, browser_context_id, compaction_enabled, compaction_threshold, compaction_model_id, metadata, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
updated.id AS id,
|
||||
|
||||
@@ -147,6 +147,7 @@ SELECT
|
||||
m.content,
|
||||
m.metadata,
|
||||
m.usage,
|
||||
m.compact_id,
|
||||
m.created_at,
|
||||
ci.display_name AS sender_display_name,
|
||||
ci.avatar_url AS sender_avatar_url,
|
||||
@@ -177,6 +178,7 @@ type ListActiveMessagesSinceRow struct {
|
||||
Content []byte `json:"content"`
|
||||
Metadata []byte `json:"metadata"`
|
||||
Usage []byte `json:"usage"`
|
||||
CompactID pgtype.UUID `json:"compact_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
SenderDisplayName pgtype.Text `json:"sender_display_name"`
|
||||
SenderAvatarUrl pgtype.Text `json:"sender_avatar_url"`
|
||||
@@ -204,6 +206,7 @@ func (q *Queries) ListActiveMessagesSince(ctx context.Context, arg ListActiveMes
|
||||
&i.Content,
|
||||
&i.Metadata,
|
||||
&i.Usage,
|
||||
&i.CompactID,
|
||||
&i.CreatedAt,
|
||||
&i.SenderDisplayName,
|
||||
&i.SenderAvatarUrl,
|
||||
@@ -232,6 +235,7 @@ SELECT
|
||||
m.content,
|
||||
m.metadata,
|
||||
m.usage,
|
||||
m.compact_id,
|
||||
m.created_at,
|
||||
ci.display_name AS sender_display_name,
|
||||
ci.avatar_url AS sender_avatar_url,
|
||||
@@ -262,6 +266,7 @@ type ListActiveMessagesSinceBySessionRow struct {
|
||||
Content []byte `json:"content"`
|
||||
Metadata []byte `json:"metadata"`
|
||||
Usage []byte `json:"usage"`
|
||||
CompactID pgtype.UUID `json:"compact_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
SenderDisplayName pgtype.Text `json:"sender_display_name"`
|
||||
SenderAvatarUrl pgtype.Text `json:"sender_avatar_url"`
|
||||
@@ -289,6 +294,7 @@ func (q *Queries) ListActiveMessagesSinceBySession(ctx context.Context, arg List
|
||||
&i.Content,
|
||||
&i.Metadata,
|
||||
&i.Usage,
|
||||
&i.CompactID,
|
||||
&i.CreatedAt,
|
||||
&i.SenderDisplayName,
|
||||
&i.SenderAvatarUrl,
|
||||
@@ -1050,6 +1056,64 @@ func (q *Queries) ListObservedConversationsByChannelIdentity(ctx context.Context
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listUncompactedMessagesBySession = `-- name: ListUncompactedMessagesBySession :many
|
||||
SELECT id, role, content, usage, created_at
|
||||
FROM bot_history_messages
|
||||
WHERE session_id = $1
|
||||
AND compact_id IS NULL
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
type ListUncompactedMessagesBySessionRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Content []byte `json:"content"`
|
||||
Usage []byte `json:"usage"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListUncompactedMessagesBySession(ctx context.Context, sessionID pgtype.UUID) ([]ListUncompactedMessagesBySessionRow, error) {
|
||||
rows, err := q.db.Query(ctx, listUncompactedMessagesBySession, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListUncompactedMessagesBySessionRow
|
||||
for rows.Next() {
|
||||
var i ListUncompactedMessagesBySessionRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Role,
|
||||
&i.Content,
|
||||
&i.Usage,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const markMessagesCompacted = `-- name: MarkMessagesCompacted :exec
|
||||
UPDATE bot_history_messages
|
||||
SET compact_id = $1
|
||||
WHERE id = ANY($2::uuid[])
|
||||
`
|
||||
|
||||
type MarkMessagesCompactedParams struct {
|
||||
CompactID pgtype.UUID `json:"compact_id"`
|
||||
MessageIds []pgtype.UUID `json:"message_ids"`
|
||||
}
|
||||
|
||||
func (q *Queries) MarkMessagesCompacted(ctx context.Context, arg MarkMessagesCompactedParams) error {
|
||||
_, err := q.db.Exec(ctx, markMessagesCompacted, arg.CompactID, arg.MessageIds)
|
||||
return err
|
||||
}
|
||||
|
||||
const searchMessages = `-- name: SearchMessages :many
|
||||
SELECT
|
||||
m.id,
|
||||
|
||||
+42
-24
@@ -9,30 +9,33 @@ import (
|
||||
)
|
||||
|
||||
type Bot struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
OwnerUserID pgtype.UUID `json:"owner_user_id"`
|
||||
DisplayName pgtype.Text `json:"display_name"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Status string `json:"status"`
|
||||
MaxContextLoadTime int32 `json:"max_context_load_time"`
|
||||
MaxContextTokens int32 `json:"max_context_tokens"`
|
||||
Language string `json:"language"`
|
||||
ReasoningEnabled bool `json:"reasoning_enabled"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
ChatModelID pgtype.UUID `json:"chat_model_id"`
|
||||
SearchProviderID pgtype.UUID `json:"search_provider_id"`
|
||||
MemoryProviderID pgtype.UUID `json:"memory_provider_id"`
|
||||
HeartbeatEnabled bool `json:"heartbeat_enabled"`
|
||||
HeartbeatInterval int32 `json:"heartbeat_interval"`
|
||||
HeartbeatPrompt string `json:"heartbeat_prompt"`
|
||||
HeartbeatModelID pgtype.UUID `json:"heartbeat_model_id"`
|
||||
TitleModelID pgtype.UUID `json:"title_model_id"`
|
||||
TtsModelID pgtype.UUID `json:"tts_model_id"`
|
||||
BrowserContextID pgtype.UUID `json:"browser_context_id"`
|
||||
Metadata []byte `json:"metadata"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
OwnerUserID pgtype.UUID `json:"owner_user_id"`
|
||||
DisplayName pgtype.Text `json:"display_name"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Status string `json:"status"`
|
||||
MaxContextLoadTime int32 `json:"max_context_load_time"`
|
||||
MaxContextTokens int32 `json:"max_context_tokens"`
|
||||
Language string `json:"language"`
|
||||
ReasoningEnabled bool `json:"reasoning_enabled"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
ChatModelID pgtype.UUID `json:"chat_model_id"`
|
||||
SearchProviderID pgtype.UUID `json:"search_provider_id"`
|
||||
MemoryProviderID pgtype.UUID `json:"memory_provider_id"`
|
||||
HeartbeatEnabled bool `json:"heartbeat_enabled"`
|
||||
HeartbeatInterval int32 `json:"heartbeat_interval"`
|
||||
HeartbeatPrompt string `json:"heartbeat_prompt"`
|
||||
HeartbeatModelID pgtype.UUID `json:"heartbeat_model_id"`
|
||||
TitleModelID pgtype.UUID `json:"title_model_id"`
|
||||
TtsModelID pgtype.UUID `json:"tts_model_id"`
|
||||
BrowserContextID pgtype.UUID `json:"browser_context_id"`
|
||||
CompactionEnabled bool `json:"compaction_enabled"`
|
||||
CompactionThreshold int32 `json:"compaction_threshold"`
|
||||
CompactionModelID pgtype.UUID `json:"compaction_model_id"`
|
||||
Metadata []byte `json:"metadata"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type BotAclRule struct {
|
||||
@@ -121,6 +124,7 @@ type BotHistoryMessage struct {
|
||||
Metadata []byte `json:"metadata"`
|
||||
Usage []byte `json:"usage"`
|
||||
ModelID pgtype.UUID `json:"model_id"`
|
||||
CompactID pgtype.UUID `json:"compact_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -135,6 +139,20 @@ type BotHistoryMessageAsset struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type BotHistoryMessageCompact struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
BotID pgtype.UUID `json:"bot_id"`
|
||||
SessionID pgtype.UUID `json:"session_id"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
MessageCount int32 `json:"message_count"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
Usage []byte `json:"usage"`
|
||||
ModelID pgtype.UUID `json:"model_id"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
CompletedAt pgtype.Timestamptz `json:"completed_at"`
|
||||
}
|
||||
|
||||
type BotSession struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
BotID pgtype.UUID `json:"bot_id"`
|
||||
|
||||
@@ -21,8 +21,11 @@ SET max_context_load_time = 1440,
|
||||
heartbeat_enabled = false,
|
||||
heartbeat_interval = 30,
|
||||
heartbeat_prompt = '',
|
||||
compaction_enabled = false,
|
||||
compaction_threshold = 100000,
|
||||
chat_model_id = NULL,
|
||||
heartbeat_model_id = NULL,
|
||||
compaction_model_id = NULL,
|
||||
title_model_id = NULL,
|
||||
search_provider_id = NULL,
|
||||
memory_provider_id = NULL,
|
||||
@@ -48,8 +51,11 @@ SELECT
|
||||
bots.heartbeat_enabled,
|
||||
bots.heartbeat_interval,
|
||||
bots.heartbeat_prompt,
|
||||
bots.compaction_enabled,
|
||||
bots.compaction_threshold,
|
||||
chat_models.id AS chat_model_id,
|
||||
heartbeat_models.id AS heartbeat_model_id,
|
||||
compaction_models.id AS compaction_model_id,
|
||||
title_models.id AS title_model_id,
|
||||
search_providers.id AS search_provider_id,
|
||||
memory_providers.id AS memory_provider_id,
|
||||
@@ -58,6 +64,7 @@ SELECT
|
||||
FROM bots
|
||||
LEFT JOIN models AS chat_models ON chat_models.id = bots.chat_model_id
|
||||
LEFT JOIN models AS heartbeat_models ON heartbeat_models.id = bots.heartbeat_model_id
|
||||
LEFT JOIN models AS compaction_models ON compaction_models.id = bots.compaction_model_id
|
||||
LEFT JOIN models AS title_models ON title_models.id = bots.title_model_id
|
||||
LEFT JOIN search_providers ON search_providers.id = bots.search_provider_id
|
||||
LEFT JOIN memory_providers ON memory_providers.id = bots.memory_provider_id
|
||||
@@ -67,22 +74,25 @@ WHERE bots.id = $1
|
||||
`
|
||||
|
||||
type GetSettingsByBotIDRow struct {
|
||||
BotID pgtype.UUID `json:"bot_id"`
|
||||
MaxContextLoadTime int32 `json:"max_context_load_time"`
|
||||
MaxContextTokens int32 `json:"max_context_tokens"`
|
||||
Language string `json:"language"`
|
||||
ReasoningEnabled bool `json:"reasoning_enabled"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
HeartbeatEnabled bool `json:"heartbeat_enabled"`
|
||||
HeartbeatInterval int32 `json:"heartbeat_interval"`
|
||||
HeartbeatPrompt string `json:"heartbeat_prompt"`
|
||||
ChatModelID pgtype.UUID `json:"chat_model_id"`
|
||||
HeartbeatModelID pgtype.UUID `json:"heartbeat_model_id"`
|
||||
TitleModelID pgtype.UUID `json:"title_model_id"`
|
||||
SearchProviderID pgtype.UUID `json:"search_provider_id"`
|
||||
MemoryProviderID pgtype.UUID `json:"memory_provider_id"`
|
||||
TtsModelID pgtype.UUID `json:"tts_model_id"`
|
||||
BrowserContextID pgtype.UUID `json:"browser_context_id"`
|
||||
BotID pgtype.UUID `json:"bot_id"`
|
||||
MaxContextLoadTime int32 `json:"max_context_load_time"`
|
||||
MaxContextTokens int32 `json:"max_context_tokens"`
|
||||
Language string `json:"language"`
|
||||
ReasoningEnabled bool `json:"reasoning_enabled"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
HeartbeatEnabled bool `json:"heartbeat_enabled"`
|
||||
HeartbeatInterval int32 `json:"heartbeat_interval"`
|
||||
HeartbeatPrompt string `json:"heartbeat_prompt"`
|
||||
CompactionEnabled bool `json:"compaction_enabled"`
|
||||
CompactionThreshold int32 `json:"compaction_threshold"`
|
||||
ChatModelID pgtype.UUID `json:"chat_model_id"`
|
||||
HeartbeatModelID pgtype.UUID `json:"heartbeat_model_id"`
|
||||
CompactionModelID pgtype.UUID `json:"compaction_model_id"`
|
||||
TitleModelID pgtype.UUID `json:"title_model_id"`
|
||||
SearchProviderID pgtype.UUID `json:"search_provider_id"`
|
||||
MemoryProviderID pgtype.UUID `json:"memory_provider_id"`
|
||||
TtsModelID pgtype.UUID `json:"tts_model_id"`
|
||||
BrowserContextID pgtype.UUID `json:"browser_context_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetSettingsByBotID(ctx context.Context, id pgtype.UUID) (GetSettingsByBotIDRow, error) {
|
||||
@@ -98,8 +108,11 @@ func (q *Queries) GetSettingsByBotID(ctx context.Context, id pgtype.UUID) (GetSe
|
||||
&i.HeartbeatEnabled,
|
||||
&i.HeartbeatInterval,
|
||||
&i.HeartbeatPrompt,
|
||||
&i.CompactionEnabled,
|
||||
&i.CompactionThreshold,
|
||||
&i.ChatModelID,
|
||||
&i.HeartbeatModelID,
|
||||
&i.CompactionModelID,
|
||||
&i.TitleModelID,
|
||||
&i.SearchProviderID,
|
||||
&i.MemoryProviderID,
|
||||
@@ -120,16 +133,19 @@ WITH updated AS (
|
||||
heartbeat_enabled = $6,
|
||||
heartbeat_interval = $7,
|
||||
heartbeat_prompt = $8,
|
||||
chat_model_id = COALESCE($9::uuid, bots.chat_model_id),
|
||||
heartbeat_model_id = COALESCE($10::uuid, bots.heartbeat_model_id),
|
||||
title_model_id = COALESCE($11::uuid, bots.title_model_id),
|
||||
search_provider_id = COALESCE($12::uuid, bots.search_provider_id),
|
||||
memory_provider_id = COALESCE($13::uuid, bots.memory_provider_id),
|
||||
tts_model_id = COALESCE($14::uuid, bots.tts_model_id),
|
||||
browser_context_id = COALESCE($15::uuid, bots.browser_context_id),
|
||||
compaction_enabled = $9,
|
||||
compaction_threshold = $10,
|
||||
chat_model_id = COALESCE($11::uuid, bots.chat_model_id),
|
||||
heartbeat_model_id = COALESCE($12::uuid, bots.heartbeat_model_id),
|
||||
compaction_model_id = COALESCE($13::uuid, bots.compaction_model_id),
|
||||
title_model_id = COALESCE($14::uuid, bots.title_model_id),
|
||||
search_provider_id = COALESCE($15::uuid, bots.search_provider_id),
|
||||
memory_provider_id = COALESCE($16::uuid, bots.memory_provider_id),
|
||||
tts_model_id = COALESCE($17::uuid, bots.tts_model_id),
|
||||
browser_context_id = COALESCE($18::uuid, bots.browser_context_id),
|
||||
updated_at = now()
|
||||
WHERE bots.id = $16
|
||||
RETURNING bots.id, bots.max_context_load_time, bots.max_context_tokens, bots.language, bots.reasoning_enabled, bots.reasoning_effort, bots.heartbeat_enabled, bots.heartbeat_interval, bots.heartbeat_prompt, bots.chat_model_id, bots.heartbeat_model_id, bots.title_model_id, bots.search_provider_id, bots.memory_provider_id, bots.tts_model_id, bots.browser_context_id
|
||||
WHERE bots.id = $19
|
||||
RETURNING bots.id, bots.max_context_load_time, bots.max_context_tokens, bots.language, bots.reasoning_enabled, bots.reasoning_effort, bots.heartbeat_enabled, bots.heartbeat_interval, bots.heartbeat_prompt, bots.compaction_enabled, bots.compaction_threshold, bots.chat_model_id, bots.heartbeat_model_id, bots.compaction_model_id, bots.title_model_id, bots.search_provider_id, bots.memory_provider_id, bots.tts_model_id, bots.browser_context_id
|
||||
)
|
||||
SELECT
|
||||
updated.id AS bot_id,
|
||||
@@ -141,8 +157,11 @@ SELECT
|
||||
updated.heartbeat_enabled,
|
||||
updated.heartbeat_interval,
|
||||
updated.heartbeat_prompt,
|
||||
updated.compaction_enabled,
|
||||
updated.compaction_threshold,
|
||||
chat_models.id AS chat_model_id,
|
||||
heartbeat_models.id AS heartbeat_model_id,
|
||||
compaction_models.id AS compaction_model_id,
|
||||
title_models.id AS title_model_id,
|
||||
search_providers.id AS search_provider_id,
|
||||
memory_providers.id AS memory_provider_id,
|
||||
@@ -151,6 +170,7 @@ SELECT
|
||||
FROM updated
|
||||
LEFT JOIN models AS chat_models ON chat_models.id = updated.chat_model_id
|
||||
LEFT JOIN models AS heartbeat_models ON heartbeat_models.id = updated.heartbeat_model_id
|
||||
LEFT JOIN models AS compaction_models ON compaction_models.id = updated.compaction_model_id
|
||||
LEFT JOIN models AS title_models ON title_models.id = updated.title_model_id
|
||||
LEFT JOIN search_providers ON search_providers.id = updated.search_provider_id
|
||||
LEFT JOIN memory_providers ON memory_providers.id = updated.memory_provider_id
|
||||
@@ -159,41 +179,47 @@ LEFT JOIN browser_contexts ON browser_contexts.id = updated.browser_context_id
|
||||
`
|
||||
|
||||
type UpsertBotSettingsParams struct {
|
||||
MaxContextLoadTime int32 `json:"max_context_load_time"`
|
||||
MaxContextTokens int32 `json:"max_context_tokens"`
|
||||
Language string `json:"language"`
|
||||
ReasoningEnabled bool `json:"reasoning_enabled"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
HeartbeatEnabled bool `json:"heartbeat_enabled"`
|
||||
HeartbeatInterval int32 `json:"heartbeat_interval"`
|
||||
HeartbeatPrompt string `json:"heartbeat_prompt"`
|
||||
ChatModelID pgtype.UUID `json:"chat_model_id"`
|
||||
HeartbeatModelID pgtype.UUID `json:"heartbeat_model_id"`
|
||||
TitleModelID pgtype.UUID `json:"title_model_id"`
|
||||
SearchProviderID pgtype.UUID `json:"search_provider_id"`
|
||||
MemoryProviderID pgtype.UUID `json:"memory_provider_id"`
|
||||
TtsModelID pgtype.UUID `json:"tts_model_id"`
|
||||
BrowserContextID pgtype.UUID `json:"browser_context_id"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
MaxContextLoadTime int32 `json:"max_context_load_time"`
|
||||
MaxContextTokens int32 `json:"max_context_tokens"`
|
||||
Language string `json:"language"`
|
||||
ReasoningEnabled bool `json:"reasoning_enabled"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
HeartbeatEnabled bool `json:"heartbeat_enabled"`
|
||||
HeartbeatInterval int32 `json:"heartbeat_interval"`
|
||||
HeartbeatPrompt string `json:"heartbeat_prompt"`
|
||||
CompactionEnabled bool `json:"compaction_enabled"`
|
||||
CompactionThreshold int32 `json:"compaction_threshold"`
|
||||
ChatModelID pgtype.UUID `json:"chat_model_id"`
|
||||
HeartbeatModelID pgtype.UUID `json:"heartbeat_model_id"`
|
||||
CompactionModelID pgtype.UUID `json:"compaction_model_id"`
|
||||
TitleModelID pgtype.UUID `json:"title_model_id"`
|
||||
SearchProviderID pgtype.UUID `json:"search_provider_id"`
|
||||
MemoryProviderID pgtype.UUID `json:"memory_provider_id"`
|
||||
TtsModelID pgtype.UUID `json:"tts_model_id"`
|
||||
BrowserContextID pgtype.UUID `json:"browser_context_id"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
}
|
||||
|
||||
type UpsertBotSettingsRow struct {
|
||||
BotID pgtype.UUID `json:"bot_id"`
|
||||
MaxContextLoadTime int32 `json:"max_context_load_time"`
|
||||
MaxContextTokens int32 `json:"max_context_tokens"`
|
||||
Language string `json:"language"`
|
||||
ReasoningEnabled bool `json:"reasoning_enabled"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
HeartbeatEnabled bool `json:"heartbeat_enabled"`
|
||||
HeartbeatInterval int32 `json:"heartbeat_interval"`
|
||||
HeartbeatPrompt string `json:"heartbeat_prompt"`
|
||||
ChatModelID pgtype.UUID `json:"chat_model_id"`
|
||||
HeartbeatModelID pgtype.UUID `json:"heartbeat_model_id"`
|
||||
TitleModelID pgtype.UUID `json:"title_model_id"`
|
||||
SearchProviderID pgtype.UUID `json:"search_provider_id"`
|
||||
MemoryProviderID pgtype.UUID `json:"memory_provider_id"`
|
||||
TtsModelID pgtype.UUID `json:"tts_model_id"`
|
||||
BrowserContextID pgtype.UUID `json:"browser_context_id"`
|
||||
BotID pgtype.UUID `json:"bot_id"`
|
||||
MaxContextLoadTime int32 `json:"max_context_load_time"`
|
||||
MaxContextTokens int32 `json:"max_context_tokens"`
|
||||
Language string `json:"language"`
|
||||
ReasoningEnabled bool `json:"reasoning_enabled"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
HeartbeatEnabled bool `json:"heartbeat_enabled"`
|
||||
HeartbeatInterval int32 `json:"heartbeat_interval"`
|
||||
HeartbeatPrompt string `json:"heartbeat_prompt"`
|
||||
CompactionEnabled bool `json:"compaction_enabled"`
|
||||
CompactionThreshold int32 `json:"compaction_threshold"`
|
||||
ChatModelID pgtype.UUID `json:"chat_model_id"`
|
||||
HeartbeatModelID pgtype.UUID `json:"heartbeat_model_id"`
|
||||
CompactionModelID pgtype.UUID `json:"compaction_model_id"`
|
||||
TitleModelID pgtype.UUID `json:"title_model_id"`
|
||||
SearchProviderID pgtype.UUID `json:"search_provider_id"`
|
||||
MemoryProviderID pgtype.UUID `json:"memory_provider_id"`
|
||||
TtsModelID pgtype.UUID `json:"tts_model_id"`
|
||||
BrowserContextID pgtype.UUID `json:"browser_context_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertBotSettings(ctx context.Context, arg UpsertBotSettingsParams) (UpsertBotSettingsRow, error) {
|
||||
@@ -206,8 +232,11 @@ func (q *Queries) UpsertBotSettings(ctx context.Context, arg UpsertBotSettingsPa
|
||||
arg.HeartbeatEnabled,
|
||||
arg.HeartbeatInterval,
|
||||
arg.HeartbeatPrompt,
|
||||
arg.CompactionEnabled,
|
||||
arg.CompactionThreshold,
|
||||
arg.ChatModelID,
|
||||
arg.HeartbeatModelID,
|
||||
arg.CompactionModelID,
|
||||
arg.TitleModelID,
|
||||
arg.SearchProviderID,
|
||||
arg.MemoryProviderID,
|
||||
@@ -226,8 +255,11 @@ func (q *Queries) UpsertBotSettings(ctx context.Context, arg UpsertBotSettingsPa
|
||||
&i.HeartbeatEnabled,
|
||||
&i.HeartbeatInterval,
|
||||
&i.HeartbeatPrompt,
|
||||
&i.CompactionEnabled,
|
||||
&i.CompactionThreshold,
|
||||
&i.ChatModelID,
|
||||
&i.HeartbeatModelID,
|
||||
&i.CompactionModelID,
|
||||
&i.TitleModelID,
|
||||
&i.SearchProviderID,
|
||||
&i.MemoryProviderID,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/memohai/memoh/internal/accounts"
|
||||
"github.com/memohai/memoh/internal/bots"
|
||||
"github.com/memohai/memoh/internal/compaction"
|
||||
)
|
||||
|
||||
type CompactionHandler struct {
|
||||
service *compaction.Service
|
||||
botService *bots.Service
|
||||
accountService *accounts.Service
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewCompactionHandler(log *slog.Logger, service *compaction.Service, botService *bots.Service, accountService *accounts.Service) *CompactionHandler {
|
||||
return &CompactionHandler{
|
||||
service: service,
|
||||
botService: botService,
|
||||
accountService: accountService,
|
||||
logger: log.With(slog.String("handler", "compaction")),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *CompactionHandler) Register(e *echo.Echo) {
|
||||
group := e.Group("/bots/:bot_id/compaction")
|
||||
group.GET("/logs", h.ListLogs)
|
||||
group.DELETE("/logs", h.DeleteLogs)
|
||||
}
|
||||
|
||||
// ListLogs godoc
|
||||
// @Summary List compaction logs
|
||||
// @Description List compaction logs for a bot
|
||||
// @Tags compaction
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Param before query string false "Before timestamp (RFC3339)"
|
||||
// @Param limit query int false "Limit" default(50)
|
||||
// @Success 200 {object} compaction.ListLogsResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /bots/{bot_id}/compaction/logs [get].
|
||||
func (h *CompactionHandler) ListLogs(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
|
||||
}
|
||||
|
||||
var before *time.Time
|
||||
if raw := strings.TrimSpace(c.QueryParam("before")); raw != "" {
|
||||
t, err := time.Parse(time.RFC3339Nano, raw)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid before timestamp")
|
||||
}
|
||||
before = &t
|
||||
}
|
||||
limit := 50
|
||||
if raw := strings.TrimSpace(c.QueryParam("limit")); raw != "" {
|
||||
if v, err := strconv.Atoi(raw); err == nil && v > 0 {
|
||||
limit = v
|
||||
}
|
||||
}
|
||||
|
||||
items, err := h.service.ListLogs(c.Request().Context(), botID, before, limit)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.JSON(http.StatusOK, compaction.ListLogsResponse{Items: items})
|
||||
}
|
||||
|
||||
// DeleteLogs godoc
|
||||
// @Summary Delete compaction logs
|
||||
// @Description Delete all compaction logs for a bot
|
||||
// @Tags compaction
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /bots/{bot_id}/compaction/logs [delete].
|
||||
func (h *CompactionHandler) DeleteLogs(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
|
||||
}
|
||||
if err := h.service.DeleteLogs(c.Request().Context(), botID); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (*CompactionHandler) requireUserID(c echo.Context) (string, error) {
|
||||
return RequireChannelIdentityID(c)
|
||||
}
|
||||
|
||||
func (h *CompactionHandler) authorizeBotAccess(ctx context.Context, userID, botID string) (bots.Bot, error) {
|
||||
return AuthorizeBotAccess(ctx, h.botService, h.accountService, userID, botID)
|
||||
}
|
||||
@@ -482,7 +482,7 @@ func toMessageFromSinceBySessionRow(row sqlc.ListMessagesSinceBySessionRow) Mess
|
||||
}
|
||||
|
||||
func toMessageFromActiveSinceRow(row sqlc.ListActiveMessagesSinceRow) Message {
|
||||
return toMessageFields(
|
||||
m := toMessageFields(
|
||||
row.ID,
|
||||
row.BotID,
|
||||
row.SessionID,
|
||||
@@ -499,10 +499,14 @@ func toMessageFromActiveSinceRow(row sqlc.ListActiveMessagesSinceRow) Message {
|
||||
row.Usage,
|
||||
row.CreatedAt,
|
||||
)
|
||||
if row.CompactID.Valid {
|
||||
m.CompactID = row.CompactID.String()
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func toMessageFromActiveSinceBySessionRow(row sqlc.ListActiveMessagesSinceBySessionRow) Message {
|
||||
return toMessageFields(
|
||||
m := toMessageFields(
|
||||
row.ID,
|
||||
row.BotID,
|
||||
row.SessionID,
|
||||
@@ -519,6 +523,10 @@ func toMessageFromActiveSinceBySessionRow(row sqlc.ListActiveMessagesSinceBySess
|
||||
row.Usage,
|
||||
row.CreatedAt,
|
||||
)
|
||||
if row.CompactID.Valid {
|
||||
m.CompactID = row.CompactID.String()
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func toMessageFromLatestRow(row sqlc.ListMessagesLatestRow) Message {
|
||||
|
||||
@@ -36,6 +36,7 @@ type Message struct {
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
Usage json.RawMessage `json:"usage,omitempty"`
|
||||
Assets []MessageAsset `json:"assets,omitempty"`
|
||||
CompactID string `json:"compact_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ func (s *Service) UpsertBot(ctx context.Context, botID string, req UpsertRequest
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
current := normalizeBotSetting(botRow.MaxContextLoadTime, botRow.MaxContextTokens, botRow.Language, allowGuest, botRow.ReasoningEnabled, botRow.ReasoningEffort, botRow.HeartbeatEnabled, botRow.HeartbeatInterval)
|
||||
current := normalizeBotSetting(botRow.MaxContextLoadTime, botRow.MaxContextTokens, botRow.Language, allowGuest, botRow.ReasoningEnabled, botRow.ReasoningEffort, botRow.HeartbeatEnabled, botRow.HeartbeatInterval, botRow.CompactionEnabled, botRow.CompactionThreshold)
|
||||
if req.MaxContextLoadTime != nil && *req.MaxContextLoadTime > 0 {
|
||||
current.MaxContextLoadTime = *req.MaxContextLoadTime
|
||||
}
|
||||
@@ -95,6 +95,12 @@ func (s *Service) UpsertBot(ctx context.Context, botID string, req UpsertRequest
|
||||
if req.HeartbeatInterval != nil && *req.HeartbeatInterval > 0 {
|
||||
current.HeartbeatInterval = *req.HeartbeatInterval
|
||||
}
|
||||
if req.CompactionEnabled != nil {
|
||||
current.CompactionEnabled = *req.CompactionEnabled
|
||||
}
|
||||
if req.CompactionThreshold != nil && *req.CompactionThreshold >= 0 {
|
||||
current.CompactionThreshold = *req.CompactionThreshold
|
||||
}
|
||||
chatModelUUID := pgtype.UUID{}
|
||||
if value := strings.TrimSpace(req.ChatModelID); value != "" {
|
||||
modelID, err := s.resolveModelUUID(ctx, value)
|
||||
@@ -111,6 +117,16 @@ func (s *Service) UpsertBot(ctx context.Context, botID string, req UpsertRequest
|
||||
}
|
||||
heartbeatModelUUID = modelID
|
||||
}
|
||||
compactionModelUUID := pgtype.UUID{}
|
||||
if req.CompactionModelID != nil {
|
||||
if value := strings.TrimSpace(*req.CompactionModelID); value != "" {
|
||||
modelID, err := s.resolveModelUUID(ctx, value)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
compactionModelUUID = modelID
|
||||
}
|
||||
}
|
||||
titleModelUUID := pgtype.UUID{}
|
||||
if value := strings.TrimSpace(req.TitleModelID); value != "" {
|
||||
modelID, err := s.resolveModelUUID(ctx, value)
|
||||
@@ -153,27 +169,31 @@ func (s *Service) UpsertBot(ctx context.Context, botID string, req UpsertRequest
|
||||
}
|
||||
if current.MaxContextLoadTime < math.MinInt32 || current.MaxContextLoadTime > math.MaxInt32 ||
|
||||
current.MaxContextTokens < math.MinInt32 || current.MaxContextTokens > math.MaxInt32 ||
|
||||
current.HeartbeatInterval < math.MinInt32 || current.HeartbeatInterval > math.MaxInt32 {
|
||||
current.HeartbeatInterval < math.MinInt32 || current.HeartbeatInterval > math.MaxInt32 ||
|
||||
current.CompactionThreshold < math.MinInt32 || current.CompactionThreshold > math.MaxInt32 {
|
||||
return Settings{}, errors.New("settings numeric value out of int32 range")
|
||||
}
|
||||
|
||||
updated, err := s.queries.UpsertBotSettings(ctx, sqlc.UpsertBotSettingsParams{
|
||||
ID: pgID,
|
||||
MaxContextLoadTime: int32(current.MaxContextLoadTime), //nolint:gosec // range validated above
|
||||
MaxContextTokens: int32(current.MaxContextTokens),
|
||||
Language: current.Language,
|
||||
ReasoningEnabled: current.ReasoningEnabled,
|
||||
ReasoningEffort: current.ReasoningEffort,
|
||||
HeartbeatEnabled: current.HeartbeatEnabled,
|
||||
HeartbeatInterval: int32(current.HeartbeatInterval),
|
||||
HeartbeatPrompt: "",
|
||||
ChatModelID: chatModelUUID,
|
||||
HeartbeatModelID: heartbeatModelUUID,
|
||||
TitleModelID: titleModelUUID,
|
||||
SearchProviderID: searchProviderUUID,
|
||||
MemoryProviderID: memoryProviderUUID,
|
||||
TtsModelID: ttsModelUUID,
|
||||
BrowserContextID: browserContextUUID,
|
||||
ID: pgID,
|
||||
MaxContextLoadTime: int32(current.MaxContextLoadTime), //nolint:gosec // range validated above
|
||||
MaxContextTokens: int32(current.MaxContextTokens),
|
||||
Language: current.Language,
|
||||
ReasoningEnabled: current.ReasoningEnabled,
|
||||
ReasoningEffort: current.ReasoningEffort,
|
||||
HeartbeatEnabled: current.HeartbeatEnabled,
|
||||
HeartbeatInterval: int32(current.HeartbeatInterval),
|
||||
HeartbeatPrompt: "",
|
||||
CompactionEnabled: current.CompactionEnabled,
|
||||
CompactionThreshold: int32(current.CompactionThreshold), //nolint:gosec // range validated above
|
||||
ChatModelID: chatModelUUID,
|
||||
HeartbeatModelID: heartbeatModelUUID,
|
||||
CompactionModelID: compactionModelUUID,
|
||||
TitleModelID: titleModelUUID,
|
||||
SearchProviderID: searchProviderUUID,
|
||||
MemoryProviderID: memoryProviderUUID,
|
||||
TtsModelID: ttsModelUUID,
|
||||
BrowserContextID: browserContextUUID,
|
||||
})
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
@@ -204,16 +224,18 @@ func (s *Service) Delete(ctx context.Context, botID string) error {
|
||||
return s.setAllowGuest(ctx, botID, "", false)
|
||||
}
|
||||
|
||||
func normalizeBotSetting(maxContextLoadTime int32, maxContextTokens int32, language string, allowGuest bool, reasoningEnabled bool, reasoningEffort string, heartbeatEnabled bool, heartbeatInterval int32) Settings {
|
||||
func normalizeBotSetting(maxContextLoadTime int32, maxContextTokens int32, language string, allowGuest bool, reasoningEnabled bool, reasoningEffort string, heartbeatEnabled bool, heartbeatInterval int32, compactionEnabled bool, compactionThreshold int32) Settings {
|
||||
settings := Settings{
|
||||
MaxContextLoadTime: int(maxContextLoadTime),
|
||||
MaxContextTokens: int(maxContextTokens),
|
||||
Language: strings.TrimSpace(language),
|
||||
AllowGuest: allowGuest,
|
||||
ReasoningEnabled: reasoningEnabled,
|
||||
ReasoningEffort: strings.TrimSpace(reasoningEffort),
|
||||
HeartbeatEnabled: heartbeatEnabled,
|
||||
HeartbeatInterval: int(heartbeatInterval),
|
||||
MaxContextLoadTime: int(maxContextLoadTime),
|
||||
MaxContextTokens: int(maxContextTokens),
|
||||
Language: strings.TrimSpace(language),
|
||||
AllowGuest: allowGuest,
|
||||
ReasoningEnabled: reasoningEnabled,
|
||||
ReasoningEffort: strings.TrimSpace(reasoningEffort),
|
||||
HeartbeatEnabled: heartbeatEnabled,
|
||||
HeartbeatInterval: int(heartbeatInterval),
|
||||
CompactionEnabled: compactionEnabled,
|
||||
CompactionThreshold: int(compactionThreshold),
|
||||
}
|
||||
if settings.MaxContextLoadTime <= 0 {
|
||||
settings.MaxContextLoadTime = DefaultMaxContextLoadTime
|
||||
@@ -230,6 +252,9 @@ func normalizeBotSetting(maxContextLoadTime int32, maxContextTokens int32, langu
|
||||
if settings.HeartbeatInterval <= 0 {
|
||||
settings.HeartbeatInterval = DefaultHeartbeatInterval
|
||||
}
|
||||
if settings.CompactionThreshold < 0 {
|
||||
settings.CompactionThreshold = 0
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
@@ -251,8 +276,11 @@ func normalizeBotSettingsReadRow(row sqlc.GetSettingsByBotIDRow) Settings {
|
||||
row.ReasoningEffort,
|
||||
row.HeartbeatEnabled,
|
||||
row.HeartbeatInterval,
|
||||
row.CompactionEnabled,
|
||||
row.CompactionThreshold,
|
||||
row.ChatModelID,
|
||||
row.HeartbeatModelID,
|
||||
row.CompactionModelID,
|
||||
row.TitleModelID,
|
||||
row.SearchProviderID,
|
||||
row.MemoryProviderID,
|
||||
@@ -270,8 +298,11 @@ func normalizeBotSettingsWriteRow(row sqlc.UpsertBotSettingsRow) Settings {
|
||||
row.ReasoningEffort,
|
||||
row.HeartbeatEnabled,
|
||||
row.HeartbeatInterval,
|
||||
row.CompactionEnabled,
|
||||
row.CompactionThreshold,
|
||||
row.ChatModelID,
|
||||
row.HeartbeatModelID,
|
||||
row.CompactionModelID,
|
||||
row.TitleModelID,
|
||||
row.SearchProviderID,
|
||||
row.MemoryProviderID,
|
||||
@@ -288,21 +319,27 @@ func normalizeBotSettingsFields(
|
||||
reasoningEffort string,
|
||||
heartbeatEnabled bool,
|
||||
heartbeatInterval int32,
|
||||
compactionEnabled bool,
|
||||
compactionThreshold int32,
|
||||
chatModelID pgtype.UUID,
|
||||
heartbeatModelID pgtype.UUID,
|
||||
compactionModelID pgtype.UUID,
|
||||
titleModelID pgtype.UUID,
|
||||
searchProviderID pgtype.UUID,
|
||||
memoryProviderID pgtype.UUID,
|
||||
ttsModelID pgtype.UUID,
|
||||
browserContextID pgtype.UUID,
|
||||
) Settings {
|
||||
settings := normalizeBotSetting(maxContextLoadTime, maxContextTokens, language, false, reasoningEnabled, reasoningEffort, heartbeatEnabled, heartbeatInterval)
|
||||
settings := normalizeBotSetting(maxContextLoadTime, maxContextTokens, language, false, reasoningEnabled, reasoningEffort, heartbeatEnabled, heartbeatInterval, compactionEnabled, compactionThreshold)
|
||||
if chatModelID.Valid {
|
||||
settings.ChatModelID = uuid.UUID(chatModelID.Bytes).String()
|
||||
}
|
||||
if heartbeatModelID.Valid {
|
||||
settings.HeartbeatModelID = uuid.UUID(heartbeatModelID.Bytes).String()
|
||||
}
|
||||
if compactionModelID.Valid {
|
||||
settings.CompactionModelID = uuid.UUID(compactionModelID.Bytes).String()
|
||||
}
|
||||
if titleModelID.Valid {
|
||||
settings.TitleModelID = uuid.UUID(titleModelID.Bytes).String()
|
||||
}
|
||||
|
||||
+36
-30
@@ -8,37 +8,43 @@ const (
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
ChatModelID string `json:"chat_model_id"`
|
||||
SearchProviderID string `json:"search_provider_id"`
|
||||
MemoryProviderID string `json:"memory_provider_id"`
|
||||
TtsModelID string `json:"tts_model_id"`
|
||||
BrowserContextID string `json:"browser_context_id"`
|
||||
MaxContextLoadTime int `json:"max_context_load_time"`
|
||||
MaxContextTokens int `json:"max_context_tokens"`
|
||||
Language string `json:"language"`
|
||||
AllowGuest bool `json:"allow_guest"`
|
||||
ReasoningEnabled bool `json:"reasoning_enabled"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
HeartbeatEnabled bool `json:"heartbeat_enabled"`
|
||||
HeartbeatInterval int `json:"heartbeat_interval"`
|
||||
HeartbeatModelID string `json:"heartbeat_model_id"`
|
||||
TitleModelID string `json:"title_model_id"`
|
||||
ChatModelID string `json:"chat_model_id"`
|
||||
SearchProviderID string `json:"search_provider_id"`
|
||||
MemoryProviderID string `json:"memory_provider_id"`
|
||||
TtsModelID string `json:"tts_model_id"`
|
||||
BrowserContextID string `json:"browser_context_id"`
|
||||
MaxContextLoadTime int `json:"max_context_load_time"`
|
||||
MaxContextTokens int `json:"max_context_tokens"`
|
||||
Language string `json:"language"`
|
||||
AllowGuest bool `json:"allow_guest"`
|
||||
ReasoningEnabled bool `json:"reasoning_enabled"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
HeartbeatEnabled bool `json:"heartbeat_enabled"`
|
||||
HeartbeatInterval int `json:"heartbeat_interval"`
|
||||
HeartbeatModelID string `json:"heartbeat_model_id"`
|
||||
TitleModelID string `json:"title_model_id"`
|
||||
CompactionEnabled bool `json:"compaction_enabled"`
|
||||
CompactionThreshold int `json:"compaction_threshold"`
|
||||
CompactionModelID string `json:"compaction_model_id,omitempty"`
|
||||
}
|
||||
|
||||
type UpsertRequest struct {
|
||||
ChatModelID string `json:"chat_model_id,omitempty"`
|
||||
SearchProviderID string `json:"search_provider_id,omitempty"`
|
||||
MemoryProviderID string `json:"memory_provider_id,omitempty"`
|
||||
TtsModelID string `json:"tts_model_id,omitempty"`
|
||||
BrowserContextID string `json:"browser_context_id,omitempty"`
|
||||
MaxContextLoadTime *int `json:"max_context_load_time,omitempty"`
|
||||
MaxContextTokens *int `json:"max_context_tokens,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
AllowGuest *bool `json:"allow_guest,omitempty"`
|
||||
ReasoningEnabled *bool `json:"reasoning_enabled,omitempty"`
|
||||
ReasoningEffort *string `json:"reasoning_effort,omitempty"`
|
||||
HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"`
|
||||
HeartbeatInterval *int `json:"heartbeat_interval,omitempty"`
|
||||
HeartbeatModelID string `json:"heartbeat_model_id,omitempty"`
|
||||
TitleModelID string `json:"title_model_id,omitempty"`
|
||||
ChatModelID string `json:"chat_model_id,omitempty"`
|
||||
SearchProviderID string `json:"search_provider_id,omitempty"`
|
||||
MemoryProviderID string `json:"memory_provider_id,omitempty"`
|
||||
TtsModelID string `json:"tts_model_id,omitempty"`
|
||||
BrowserContextID string `json:"browser_context_id,omitempty"`
|
||||
MaxContextLoadTime *int `json:"max_context_load_time,omitempty"`
|
||||
MaxContextTokens *int `json:"max_context_tokens,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
AllowGuest *bool `json:"allow_guest,omitempty"`
|
||||
ReasoningEnabled *bool `json:"reasoning_enabled,omitempty"`
|
||||
ReasoningEffort *string `json:"reasoning_effort,omitempty"`
|
||||
HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"`
|
||||
HeartbeatInterval *int `json:"heartbeat_interval,omitempty"`
|
||||
HeartbeatModelID string `json:"heartbeat_model_id,omitempty"`
|
||||
TitleModelID string `json:"title_model_id,omitempty"`
|
||||
CompactionEnabled *bool `json:"compaction_enabled,omitempty"`
|
||||
CompactionThreshold *int `json:"compaction_threshold,omitempty"`
|
||||
CompactionModelID *string `json:"compaction_model_id,omitempty"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user