diff --git a/cmd/gateway.go b/cmd/gateway.go index d094b591..eb29f780 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -14,6 +14,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/agent" "github.com/nextlevelbuilder/goclaw/internal/bootstrap" "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/cache" "github.com/nextlevelbuilder/goclaw/internal/channels" "github.com/nextlevelbuilder/goclaw/internal/channels/discord" "github.com/nextlevelbuilder/goclaw/internal/channels/feishu" @@ -618,6 +619,8 @@ func runGateway() { if pendingMessagesH != nil { if pc := cfg.Channels.PendingCompaction; pc != nil { pendingMessagesH.SetKeepRecent(pc.KeepRecent) + pendingMessagesH.SetMaxTokens(pc.MaxTokens) + pendingMessagesH.SetProviderModel(pc.Provider, pc.Model) } server.SetPendingMessagesHandler(pendingMessagesH) } @@ -852,7 +855,13 @@ func runGateway() { webFetchTool.UpdatePolicy(updatedCfg.Tools.WebFetch.Policy, updatedCfg.Tools.WebFetch.AllowedDomains, updatedCfg.Tools.WebFetch.BlockedDomains) }) - go consumeInboundMessages(ctx, msgBus, agentRouter, cfg, sched, channelMgr, consumerTeamStore, quotaChecker, delegateMgr, pgStores.Sessions, pgStores.Agents) + // Contact collector: auto-collect user info from channels with in-memory dedup cache. + var contactCollector *store.ContactCollector + if pgStores.Contacts != nil { + contactCollector = store.NewContactCollector(pgStores.Contacts, cache.NewInMemoryCache[bool]()) + } + + go consumeInboundMessages(ctx, msgBus, agentRouter, cfg, sched, channelMgr, consumerTeamStore, quotaChecker, delegateMgr, pgStores.Sessions, pgStores.Agents, contactCollector) go func() { sig := <-sigCh diff --git a/internal/channels/history_compaction.go b/internal/channels/history_compaction.go index 735632ca..a5812177 100644 --- a/internal/channels/history_compaction.go +++ b/internal/channels/history_compaction.go @@ -17,6 +17,7 @@ import ( type CompactionConfig struct { Threshold int // trigger compaction when entries exceed this (default 50) KeepRecent int // keep this many recent raw messages (default 15) + MaxTokens int // max output tokens for summarization (default 4096) Provider providers.Provider // LLM provider for summarization Model string // model to use for summarization } @@ -44,10 +45,13 @@ func (ph *PendingHistory) MaybeCompact(historyKey string, currentCount int, cfg // CompactGroup performs LLM-based compaction on a pending message group. // Reused by both auto-compact (channel) and HTTP compact endpoint. // Returns the number of entries remaining after compaction. -func CompactGroup(ctx context.Context, s store.PendingMessageStore, channelName, historyKey string, provider providers.Provider, model string, keepRecent int) (int, error) { +func CompactGroup(ctx context.Context, s store.PendingMessageStore, channelName, historyKey string, provider providers.Provider, model string, keepRecent, maxTokens int) (int, error) { if keepRecent <= 0 { keepRecent = 15 } + if maxTokens <= 0 { + maxTokens = 4096 + } // Step 1: Read entries from DB entries, err := s.ListByKey(ctx, channelName, historyKey) @@ -86,7 +90,7 @@ func CompactGroup(ctx context.Context, s store.PendingMessageStore, channelName, Content: "Summarize these group chat messages concisely, preserving key topics, decisions, names, and important context:\n\n" + sb.String(), }}, Model: model, - Options: map[string]any{"max_tokens": 512, "temperature": 0.3}, + Options: map[string]any{"max_tokens": maxTokens, "temperature": 0.3}, }) if err != nil { return 0, fmt.Errorf("llm summarize: %w", err) @@ -140,7 +144,7 @@ func (ph *PendingHistory) runCompaction(historyKey string, cfg *CompactionConfig return } - _, err = CompactGroup(ctx, ph.store, ph.channelName, historyKey, cfg.Provider, cfg.Model, cfg.KeepRecent) + _, err = CompactGroup(ctx, ph.store, ph.channelName, historyKey, cfg.Provider, cfg.Model, cfg.KeepRecent, cfg.MaxTokens) if err != nil { slog.Warn("compaction.failed", "channel", ph.channelName, "key", historyKey, "error", err) return diff --git a/internal/channels/instance_loader.go b/internal/channels/instance_loader.go index 7aded053..79f38109 100644 --- a/internal/channels/instance_loader.go +++ b/internal/channels/instance_loader.go @@ -235,28 +235,47 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns base.SetType(inst.ChannelType) } - // Wire pending message auto-compaction using the agent's configured provider+model. - if pc, ok := ch.(PendingCompactable); ok && ag != nil && l.providerReg != nil && ag.Provider != "" { - if p, err := l.providerReg.Get(ag.Provider); err == nil { - model := ag.Model - if model == "" { - model = p.DefaultModel() - } - if model != "" { - cc := &CompactionConfig{ - Provider: p, - Model: model, + // Wire pending message auto-compaction. + // Priority: config provider/model > agent's provider/model > fallback. + if pc, ok := ch.(PendingCompactable); ok && l.providerReg != nil { + var p providers.Provider + var model string + + // Try config-level provider/model first. + if l.pendingCompactCfg != nil && l.pendingCompactCfg.Provider != "" { + if cp, err := l.providerReg.Get(l.pendingCompactCfg.Provider); err == nil { + p = cp + model = l.pendingCompactCfg.Model + if model == "" { + model = cp.DefaultModel() } - // Apply global threshold/keepRecent from config if set. - if l.pendingCompactCfg != nil { - cc.Threshold = l.pendingCompactCfg.Threshold - cc.KeepRecent = l.pendingCompactCfg.KeepRecent - } - pc.SetPendingCompaction(cc) - slog.Debug("pending compaction configured", "channel", inst.Name, "provider", ag.Provider, "model", model, - "threshold", cc.Threshold, "keep_recent", cc.KeepRecent) } } + // Fallback: agent's provider/model. + if p == nil && ag != nil && ag.Provider != "" { + if ap, err := l.providerReg.Get(ag.Provider); err == nil { + p = ap + model = ag.Model + if model == "" { + model = ap.DefaultModel() + } + } + } + + if p != nil && model != "" { + cc := &CompactionConfig{ + Provider: p, + Model: model, + } + if l.pendingCompactCfg != nil { + cc.Threshold = l.pendingCompactCfg.Threshold + cc.KeepRecent = l.pendingCompactCfg.KeepRecent + cc.MaxTokens = l.pendingCompactCfg.MaxTokens + } + pc.SetPendingCompaction(cc) + slog.Debug("pending compaction configured", "channel", inst.Name, "provider", p.Name(), "model", model, + "threshold", cc.Threshold, "keep_recent", cc.KeepRecent, "max_tokens", cc.MaxTokens) + } } l.manager.RegisterChannel(inst.Name, ch) l.loaded[inst.Name] = struct{}{} diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index e6634a4d..68c5e5e7 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -4,8 +4,11 @@ package config // When a group accumulates more than Threshold pending messages, older messages are // summarized by an LLM and replaced with a compact summary, keeping KeepRecent raw messages. type PendingCompactionConfig struct { - Threshold int `json:"threshold,omitempty"` // trigger compaction when entries exceed this (default 50) - KeepRecent int `json:"keep_recent,omitempty"` // keep this many recent raw messages after compaction (default 15) + Threshold int `json:"threshold,omitempty"` // trigger compaction when entries exceed this (default 50) + KeepRecent int `json:"keep_recent,omitempty"` // keep this many recent raw messages after compaction (default 15) + MaxTokens int `json:"max_tokens,omitempty"` // max output tokens for LLM summarization (default 4096) + Provider string `json:"provider,omitempty"` // LLM provider name (e.g. "openai"); empty = use agent's provider + Model string `json:"model,omitempty"` // model for summarization; empty = use agent's model } // ChannelsConfig contains per-channel configuration. diff --git a/internal/http/pending_messages.go b/internal/http/pending_messages.go index 27766ddb..3b15b76d 100644 --- a/internal/http/pending_messages.go +++ b/internal/http/pending_messages.go @@ -19,7 +19,10 @@ type PendingMessagesHandler struct { agentStore store.AgentStore token string providerReg *providers.Registry - keepRecent int // global keepRecent from config (0 = use default 15) + 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 { @@ -29,6 +32,15 @@ func NewPendingMessagesHandler(s store.PendingMessageStore, agentStore store.Age // 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)) @@ -146,7 +158,7 @@ func (h *PendingMessagesHandler) handleCompact(w http.ResponseWriter, r *http.Re if keepRecent <= 0 { keepRecent = 15 } - remaining, err := channels.CompactGroup(ctx, h.store, req.ChannelName, req.HistoryKey, provider, model, keepRecent) + 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) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) @@ -156,14 +168,26 @@ func (h *PendingMessagesHandler) handleCompact(w http.ResponseWriter, r *http.Re } // resolveProviderAndModel resolves the LLM provider+model for pending message compaction. -// Uses the default agent's configured provider and model so compaction uses the same -// LLM as the agent that processes these messages. +// 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, "" } - // Use the default agent's provider+model + // 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 { diff --git a/ui/web/src/components/shared/provider-model-select.tsx b/ui/web/src/components/shared/provider-model-select.tsx index 2dd67b52..bc7551df 100644 --- a/ui/web/src/components/shared/provider-model-select.tsx +++ b/ui/web/src/components/shared/provider-model-select.tsx @@ -34,6 +34,8 @@ interface ProviderModelSelectProps { savedModel?: string; /** Called when verification status changes. True = save should be blocked (changed but not verified). */ onSaveBlockedChange?: (blocked: boolean) => void; + /** When true, skip auto-selecting the first provider when none is set. Useful when empty means "use default". */ + allowEmpty?: boolean; } export function ProviderModelSelect({ @@ -51,17 +53,18 @@ export function ProviderModelSelect({ savedProvider, savedModel, onSaveBlockedChange, + allowEmpty, }: ProviderModelSelectProps) { const { t } = useTranslation("common"); const { providers } = useProviders(); const enabledProviders = providers.filter((p) => p.enabled); - // Auto-select first enabled provider when none is set + // Auto-select first enabled provider when none is set (unless allowEmpty) useEffect(() => { - if (!provider && enabledProviders.length > 0) { + if (!allowEmpty && !provider && enabledProviders.length > 0) { onProviderChange(enabledProviders[0]!.name); } - }, [provider, enabledProviders, onProviderChange]); + }, [allowEmpty, provider, enabledProviders, onProviderChange]); const selectedProvider = useMemo( () => enabledProviders.find((p) => p.name === provider), diff --git a/ui/web/src/i18n/locales/en/config.json b/ui/web/src/i18n/locales/en/config.json index 08a9c8e6..7c58cf9f 100644 --- a/ui/web/src/i18n/locales/en/config.json +++ b/ui/web/src/i18n/locales/en/config.json @@ -130,6 +130,14 @@ "behavior.pendingCompactionThresholdTip": "Trigger compaction when a group accumulates more than this many pending messages. Set to 0 to use the default (50).", "behavior.pendingCompactionKeepRecent": "Keep Recent", "behavior.pendingCompactionKeepRecentTip": "Number of recent raw messages to keep after compaction. Older messages are replaced with an LLM summary. Default is 15.", + "behavior.pendingCompactionMaxTokens": "Max Tokens", + "behavior.pendingCompactionMaxTokensTip": "Maximum output tokens for LLM summarization. Default is 4096.", + "behavior.pendingCompactionProvider": "Provider", + "behavior.pendingCompactionProviderTip": "LLM provider for summarization. Leave empty to use the channel agent's provider.", + "behavior.pendingCompactionProviderPlaceholder": "Use agent's provider", + "behavior.pendingCompactionModel": "Model", + "behavior.pendingCompactionModelTip": "Model for summarization. Leave empty to use the channel agent's model.", + "behavior.pendingCompactionModelPlaceholder": "Use agent's model", "behavior.pendingCompactionInfo": "When a group chat exceeds the threshold, the LLM automatically summarizes older messages into a single compact entry, keeping the most recent ones intact for context.", "behavior.rateLimitTitle": "Rate Limiting", "behavior.rateLimitDescription": "Inbound message size limits and per-user request throttling", diff --git a/ui/web/src/i18n/locales/vi/config.json b/ui/web/src/i18n/locales/vi/config.json index 692a868e..6e044dde 100644 --- a/ui/web/src/i18n/locales/vi/config.json +++ b/ui/web/src/i18n/locales/vi/config.json @@ -130,6 +130,14 @@ "behavior.pendingCompactionThresholdTip": "Kích hoạt nén khi nhóm tích lũy nhiều hơn số tin nhắn chờ này. Đặt 0 để dùng mặc định (50).", "behavior.pendingCompactionKeepRecent": "Giữ gần đây", "behavior.pendingCompactionKeepRecentTip": "Số tin nhắn gần đây giữ lại sau khi nén. Tin cũ hơn được thay bằng bản tóm tắt LLM. Mặc định là 15.", + "behavior.pendingCompactionMaxTokens": "Token tối đa", + "behavior.pendingCompactionMaxTokensTip": "Số token tối đa cho kết quả tóm tắt LLM. Mặc định 4096.", + "behavior.pendingCompactionProvider": "Nhà cung cấp", + "behavior.pendingCompactionProviderTip": "Nhà cung cấp LLM cho tóm tắt. Để trống sẽ dùng provider của agent kênh.", + "behavior.pendingCompactionProviderPlaceholder": "Dùng provider của agent", + "behavior.pendingCompactionModel": "Mô hình", + "behavior.pendingCompactionModelTip": "Mô hình LLM cho tóm tắt. Để trống sẽ dùng model của agent kênh.", + "behavior.pendingCompactionModelPlaceholder": "Dùng model của agent", "behavior.pendingCompactionInfo": "Khi nhóm chat vượt ngưỡng, LLM tự động tóm tắt các tin nhắn cũ thành một mục rút gọn, giữ nguyên các tin gần đây nhất làm ngữ cảnh.", "behavior.rateLimitTitle": "Giới hạn tốc độ", "behavior.rateLimitDescription": "Giới hạn kích thước tin nhắn đến và kiểm soát tốc độ yêu cầu mỗi người dùng", diff --git a/ui/web/src/i18n/locales/zh/config.json b/ui/web/src/i18n/locales/zh/config.json index e0dfe327..dd7efa3a 100644 --- a/ui/web/src/i18n/locales/zh/config.json +++ b/ui/web/src/i18n/locales/zh/config.json @@ -130,6 +130,14 @@ "behavior.pendingCompactionThresholdTip": "当群组累积超过此数量的待处理消息时触发压缩。设为 0 使用默认值(50)。", "behavior.pendingCompactionKeepRecent": "保留最近", "behavior.pendingCompactionKeepRecentTip": "压缩后保留的最近原始消息数量。较旧的消息将替换为 LLM 摘要。默认为 15。", + "behavior.pendingCompactionMaxTokens": "最大 Token 数", + "behavior.pendingCompactionMaxTokensTip": "LLM 摘要的最大输出 token 数。默认 4096。", + "behavior.pendingCompactionProvider": "提供商", + "behavior.pendingCompactionProviderTip": "用于摘要的 LLM 提供商。留空将使用频道 Agent 的提供商。", + "behavior.pendingCompactionProviderPlaceholder": "使用 Agent 的提供商", + "behavior.pendingCompactionModel": "模型", + "behavior.pendingCompactionModelTip": "用于摘要的模型。留空将使用频道 Agent 的模型。", + "behavior.pendingCompactionModelPlaceholder": "使用 Agent 的模型", "behavior.pendingCompactionInfo": "当群聊超过阈值时,LLM 自动将旧消息总结为一条精简条目,保留最近的消息作为上下文。", "behavior.rateLimitTitle": "速率限制", "behavior.rateLimitDescription": "入站消息大小限制和每用户请求限流", diff --git a/ui/web/src/pages/config/sections/behavior-pending-compaction-card.tsx b/ui/web/src/pages/config/sections/behavior-pending-compaction-card.tsx index c1e013fd..22aad0b6 100644 --- a/ui/web/src/pages/config/sections/behavior-pending-compaction-card.tsx +++ b/ui/web/src/pages/config/sections/behavior-pending-compaction-card.tsx @@ -1,4 +1,4 @@ -import { Archive, Clock, Info } from "lucide-react"; +import { Archive, Clock, Hash, Info } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -9,10 +9,14 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { ProviderModelSelect } from "@/components/shared/provider-model-select"; export interface PendingCompactionValues { threshold?: number; keep_recent?: number; + max_tokens?: number; + provider?: string; + model?: string; } interface Props { @@ -38,6 +42,23 @@ export function BehaviorPendingCompactionCard({ value, onChange }: Props) { + {/* Provider & Model */} +
+ update({ provider: v })} + model={value.model ?? ""} + onModelChange={(v) => update({ model: v })} + providerLabel={t("behavior.pendingCompactionProvider")} + modelLabel={t("behavior.pendingCompactionModel")} + providerTip={t("behavior.pendingCompactionProviderTip")} + modelTip={t("behavior.pendingCompactionModelTip")} + providerPlaceholder={t("behavior.pendingCompactionProviderPlaceholder")} + modelPlaceholder={t("behavior.pendingCompactionModelPlaceholder")} + allowEmpty + /> +
+ {/* Threshold */}
@@ -64,7 +85,7 @@ export function BehaviorPendingCompactionCard({ value, onChange }: Props) {
{/* Keep Recent */} -
+
@@ -88,6 +109,31 @@ export function BehaviorPendingCompactionCard({ value, onChange }: Props) {
+ {/* Max Tokens */} +
+
+
+ +
+ +

+ {t("behavior.pendingCompactionMaxTokensTip")} +

+
+
+ update({ max_tokens: Number(e.target.value) })} + placeholder="4096" + min={256} + className="w-24 shrink-0" + /> +
+
+ {/* Info banner */}