package http import ( "context" "encoding/json" "log/slog" "net/http" "time" "github.com/nextlevelbuilder/goclaw/internal/channels" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" ) // PendingMessagesHandler handles pending message HTTP endpoints. type PendingMessagesHandler struct { store store.PendingMessageStore agentStore store.AgentStore token string providerReg *providers.Registry keepRecent int // global keepRecent from config (0 = use default 15) maxTokens int // max output tokens for LLM summarization (0 = use default) cfgProvider string // config-level provider override (empty = resolve from agent) cfgModel string // config-level model override (empty = resolve from agent) } func NewPendingMessagesHandler(s store.PendingMessageStore, agentStore store.AgentStore, token string, providerReg *providers.Registry) *PendingMessagesHandler { return &PendingMessagesHandler{store: s, agentStore: agentStore, token: token, providerReg: providerReg} } // SetKeepRecent sets the global keepRecent value from config. func (h *PendingMessagesHandler) SetKeepRecent(n int) { h.keepRecent = n } // SetMaxTokens sets the global maxTokens value from config. func (h *PendingMessagesHandler) SetMaxTokens(n int) { h.maxTokens = n } // SetProviderModel sets explicit provider/model from config. func (h *PendingMessagesHandler) SetProviderModel(provider, model string) { h.cfgProvider = provider h.cfgModel = model } func (h *PendingMessagesHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/pending-messages", h.authMiddleware(h.handleListGroups)) mux.HandleFunc("GET /v1/pending-messages/messages", h.authMiddleware(h.handleListMessages)) mux.HandleFunc("DELETE /v1/pending-messages", h.authMiddleware(h.handleDelete)) mux.HandleFunc("POST /v1/pending-messages/compact", h.authMiddleware(h.handleCompact)) } func (h *PendingMessagesHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc { return requireAuth(h.token, "", next) } // GET /v1/pending-messages — list all groups with resolved titles func (h *PendingMessagesHandler) handleListGroups(w http.ResponseWriter, r *http.Request) { groups, err := h.store.ListGroups(r.Context()) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } // Resolve group titles from session metadata (best-effort, non-blocking) if titles, err := h.store.ResolveGroupTitles(r.Context(), groups); err == nil { for i := range groups { if t, ok := titles[groups[i].ChannelName+":"+groups[i].HistoryKey]; ok { groups[i].GroupTitle = t } } } writeJSON(w, http.StatusOK, map[string]any{"groups": groups}) } // GET /v1/pending-messages/messages?channel=X&key=Y — list messages for a group func (h *PendingMessagesHandler) handleListMessages(w http.ResponseWriter, r *http.Request) { locale := store.LocaleFromContext(r.Context()) channel := r.URL.Query().Get("channel") key := r.URL.Query().Get("key") if channel == "" || key == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgChannelKeyReq)}) return } msgs, err := h.store.ListByKey(r.Context(), channel, key) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"messages": msgs}) } // DELETE /v1/pending-messages?channel=X&key=Y — clear a group func (h *PendingMessagesHandler) handleDelete(w http.ResponseWriter, r *http.Request) { locale := store.LocaleFromContext(r.Context()) channel := r.URL.Query().Get("channel") key := r.URL.Query().Get("key") if channel == "" || key == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgChannelKeyReq)}) return } if err := h.store.DeleteByKey(r.Context(), channel, key); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } type compactRequest struct { ChannelName string `json:"channel_name"` HistoryKey string `json:"history_key"` } // POST /v1/pending-messages/compact — LLM-based summarization of old messages, keeping recent ones. // Falls back to hard delete if no LLM provider is available. func (h *PendingMessagesHandler) handleCompact(w http.ResponseWriter, r *http.Request) { locale := store.LocaleFromContext(r.Context()) var req compactRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) return } if req.ChannelName == "" || req.HistoryKey == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "channel_name and history_key")}) return } // Resolve an LLM provider for summarization using the default agent's config provider, model := h.resolveProviderAndModel() if provider == nil { // Fallback: hard delete if no provider available slog.Warn("compact.no_provider", "channel", req.ChannelName, "key", req.HistoryKey) if err := h.store.DeleteByKey(r.Context(), req.ChannelName, req.HistoryKey); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "method": "deleted"}) return } // Run compaction in background so the HTTP response returns immediately. // The long-running LLM call (30-120s) was blocking the response, which // caused browser WebSocket connections to drop (pong timeout). keepRecent := h.keepRecent if keepRecent <= 0 { keepRecent = 15 } go func() { ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) defer cancel() remaining, err := channels.CompactGroup(ctx, h.store, req.ChannelName, req.HistoryKey, provider, model, keepRecent, h.maxTokens) if err != nil { slog.Warn("compact.failed", "channel", req.ChannelName, "key", req.HistoryKey, "error", err) } else { slog.Info("compact.done", "channel", req.ChannelName, "key", req.HistoryKey, "remaining", remaining) } }() writeJSON(w, http.StatusAccepted, map[string]any{"status": "accepted", "method": "summarizing"}) } // resolveProviderAndModel resolves the LLM provider+model for pending message compaction. // Priority: config provider/model > default agent's provider/model > first available provider. func (h *PendingMessagesHandler) resolveProviderAndModel() (providers.Provider, string) { if h.providerReg == nil { return nil, "" } // Config-level provider/model override. if h.cfgProvider != "" { if p, err := h.providerReg.Get(h.cfgProvider); err == nil { model := h.cfgModel if model == "" { model = p.DefaultModel() } if model != "" { return p, model } } } // Fallback: default agent's provider+model. if h.agentStore != nil { if ag, err := h.agentStore.GetDefault(context.Background()); err == nil && ag.Provider != "" { if p, err := h.providerReg.Get(ag.Provider); err == nil { model := ag.Model if model == "" { model = p.DefaultModel() } if model != "" { return p, model } } } } // Fallback: first provider with a valid default model for _, name := range h.providerReg.List() { p, err := h.providerReg.Get(name) if err != nil { continue } if p.DefaultModel() != "" { return p, p.DefaultModel() } } return nil, "" }