diff --git a/cmd/gateway.go b/cmd/gateway.go index 324440ce..61cb56e5 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -588,7 +588,7 @@ func runGateway() { registerConfigChannels(cfg, channelMgr, msgBus, pgStores, instanceLoader, audioMgr) // Register channels/instances/links/teams RPC methods - chInstancesM := wireChannelRPCMethods(server, pgStores, channelMgr, instanceLoader, agentRouter, msgBus, workspace) + chInstancesM := wireChannelRPCMethods(server, pgStores, channelMgr, instanceLoader, agentRouter, msgBus, cfg, workspace) // Bitrix24 orphan-bot cleaner. Fires from channel_instances delete handler // when the channel is no longer loaded in the Manager (typical scenario: diff --git a/cmd/gateway_channels_setup.go b/cmd/gateway_channels_setup.go index eb81e188..5676a71f 100644 --- a/cmd/gateway_channels_setup.go +++ b/cmd/gateway_channels_setup.go @@ -148,9 +148,10 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms // Returns the channel-instances methods handler so the caller can register // per-channel-type orphan cleaners (e.g. Bitrix24 imbot.unregister) after // per-channel dependencies (portal store, encryption key) are in scope. -func wireChannelRPCMethods(server *gateway.Server, pgStores *store.Stores, channelMgr *channels.Manager, instanceLoader *channels.InstanceLoader, agentRouter *agent.Router, msgBus *bus.MessageBus, dataDir string) *methods.ChannelInstancesMethods { +func wireChannelRPCMethods(server *gateway.Server, pgStores *store.Stores, channelMgr *channels.Manager, instanceLoader *channels.InstanceLoader, agentRouter *agent.Router, msgBus *bus.MessageBus, cfg *config.Config, dataDir string) *methods.ChannelInstancesMethods { // Register channels RPC methods (after channelMgr is initialized with all channels) methods.NewChannelsMethods(channelMgr).Register(server.Router()) + methods.NewChatBehaviorMethods(cfg, channelMgr).Register(server.Router()) // Register channel instances WS RPC methods var chInstancesM *methods.ChannelInstancesMethods diff --git a/cmd/gateway_consumer_normal.go b/cmd/gateway_consumer_normal.go index a719c446..68b53142 100644 --- a/cmd/gateway_consumer_normal.go +++ b/cmd/gateway_consumer_normal.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "strings" + "time" "github.com/google/uuid" @@ -239,9 +240,13 @@ func processNormalMessage( chatIDForRun = lk } blockReply := deps.ChannelMgr != nil && deps.ChannelMgr.ResolveBlockReply(msg.Channel, deps.Cfg.Gateway.BlockReply) + chatBehavior := channels.ResolvedChatBehavior{} + if deps.ChannelMgr != nil { + chatBehavior = deps.ChannelMgr.ResolveChatBehavior(msg.Channel, deps.Cfg.Gateway.ChatBehavior) + } toolStatus := deps.Cfg.Gateway.ToolStatus == nil || *deps.Cfg.Gateway.ToolStatus // default true if deps.ChannelMgr != nil { - deps.ChannelMgr.RegisterRun(runID, msg.Channel, chatIDForRun, messageID, outMeta, msg.TenantID, enableStream, blockReply, toolStatus) + deps.ChannelMgr.RegisterRunWithBehavior(runID, msg.Channel, chatIDForRun, messageID, outMeta, msg.TenantID, enableStream, blockReply, toolStatus, chatBehavior) } // Group-aware system prompt: help the LLM adapt tone and behavior for group chats. @@ -429,37 +434,37 @@ func processNormalMessage( // Schedule through main lane (per-session concurrency controlled by maxConcurrent) outCh := deps.Sched.ScheduleWithOpts(schedCtx, "main", agent.RunRequest{ - SessionKey: sessionKey, - Message: msg.Content, - Media: reqMedia, - ForwardMedia: fwdMedia, - Channel: msg.Channel, - ChannelType: resolveChannelType(deps.ChannelMgr, msg.Channel), + SessionKey: sessionKey, + Message: msg.Content, + Media: reqMedia, + ForwardMedia: fwdMedia, + Channel: msg.Channel, + ChannelType: resolveChannelType(deps.ChannelMgr, msg.Channel), // Forward Bitrix24 portal domain from channel metadata so the // system prompt can teach the LLM the correct entity URL host. // Empty for non-bitrix24 channels — section is skipped downstream. BitrixPortalDomain: msg.Metadata["bitrix_portal"], ChatTitle: msg.Metadata[tools.MetaChatTitle], - ChatID: msg.ChatID, - WorkspaceChatID: msg.ChatID, - PeerKind: peerKind, - LocalKey: msg.Metadata["local_key"], - UserID: userID, - SenderID: effectiveSenderID, - Role: effectiveRole, - SenderName: resolveSenderName(msg), - RunID: runID, - Stream: enableStream, - HistoryLimit: msg.HistoryLimit, - ToolAllow: msg.ToolAllow, - ExtraSystemPrompt: extraPrompt, - SkillFilter: skillFilter, + ChatID: msg.ChatID, + WorkspaceChatID: msg.ChatID, + PeerKind: peerKind, + LocalKey: msg.Metadata["local_key"], + UserID: userID, + SenderID: effectiveSenderID, + Role: effectiveRole, + SenderName: resolveSenderName(msg), + RunID: runID, + Stream: enableStream, + HistoryLimit: msg.HistoryLimit, + ToolAllow: msg.ToolAllow, + ExtraSystemPrompt: extraPrompt, + SkillFilter: skillFilter, }, scheduler.ScheduleOpts{ MaxConcurrent: maxConcurrent, }) // Handle result asynchronously to not block the flush callback. - go func(agentKey, channel, chatID, session, rID, peerKind, inboundContent string, meta map[string]string, blockReplyEnabled bool, ptd *tools.PendingTeamDispatch, tenantID, agentUUID uuid.UUID, agentOtherConfig []byte) { + go func(agentKey, channel, chatID, session, rID, peerKind, inboundContent string, meta map[string]string, blockReplyEnabled bool, chatBehavior channels.ResolvedChatBehavior, streaming bool, ptd *tools.PendingTeamDispatch, tenantID, agentUUID uuid.UUID, agentOtherConfig []byte) { outcome := <-outCh // Release team create lock — tasks already visible in DB, other goroutines can list. @@ -573,13 +578,27 @@ func processNormalMessage( appendMediaToOutbound(&outMsg, outcome.Result.Media) - deps.MsgBus.PublishOutbound(outMsg) + parts := []string{replyContent} + if !streaming && len(outMsg.Media) == 0 { + parts = channels.SplitFinalMessages(replyContent, chatBehavior.FinalSplit) + } + for i, part := range parts { + msgPart := outMsg + msgPart.Content = part + if i > 0 { + msgPart.Metadata = channels.CopyFollowupRoutingMeta(meta) + if chatBehavior.FinalSplit.DelayMs > 0 { + time.Sleep(time.Duration(chatBehavior.FinalSplit.DelayMs) * time.Millisecond) + } + } + deps.MsgBus.PublishOutbound(msgPart) + } // Auto-set followup when lead agent replies on a real channel with in_progress tasks. if deps.TeamStore != nil && channel != tools.ChannelSystem && channel != tools.ChannelTeammate && channel != tools.ChannelDashboard { go autoSetFollowup(ctx, deps.TeamStore, deps.AgentStore, agentKey, channel, chatID, replyContent) } - }(agentID, msg.Channel, msg.ChatID, sessionKey, runID, peerKind, msg.Content, outMeta, blockReply, ptd, msg.TenantID, agentLoop.UUID(), agentLoop.OtherConfig()) + }(agentID, msg.Channel, msg.ChatID, sessionKey, runID, peerKind, msg.Content, outMeta, blockReply, chatBehavior, enableStream, ptd, msg.TenantID, agentLoop.UUID(), agentLoop.OtherConfig()) } // isSafeBitrixEntityToken validates a webhook-sourced Bitrix entity token before diff --git a/docs/05-channels-messaging.md b/docs/05-channels-messaging.md index bb7546cd..cc32f8a7 100644 --- a/docs/05-channels-messaging.md +++ b/docs/05-channels-messaging.md @@ -71,6 +71,16 @@ The consumer routes system messages based on sender ID prefixes: Normal channel messages pass through the shared inbound debouncer before agent execution. `gateway.inbound_debounce_ms` merges rapid text messages from the same `channel:chatID:senderID:agentID`; `0` means no debounce and positive values set the wait window. Agents can override the global value with `other_config.inbound_debounce_ms`; unset inherits the global config. Command/control messages such as `/stop`, `/reset`, and system escalations bypass the debouncer. +### Human-like Delivery + +`gateway.chat_behavior` controls optional channel-only delivery polish: + +- `quick_ack` sends one short acknowledgement after a configurable delay, only for non-streaming channel runs. +- `final_split` splits long final text replies into a bounded number of paragraph messages. +- Per-channel `chat_behavior` overrides inherit the gateway config unless a field is explicitly set. + +Splitting is intentionally conservative. Replies containing fenced code, tables, lists, quotes, JSON/XML-ish blocks, or URL-only paragraphs remain a single message. Media replies and streaming deliveries are not split. + **Multi-attachment coalescing (#63).** Messages carrying attachments do NOT bypass the debouncer — that pre-fix shortcut was the source of N-replies for one user action. Instead, when media is present the effective window is `max(configured, mediaFloor)` so multi-file uploads land in the same buffer and flush together. Three surfaces apply the same invariant: | Surface | Buffer key | Trigger | @@ -105,6 +115,7 @@ Every channel must implement the base interface: | `WebhookChannel` | Webhook HTTP handler mounting | Facebook, Feishu/Lark, Pancake | | `ReactionChannel` | Status reactions on messages | Telegram, Slack, Feishu | | `BlockReplyChannel` | Override gateway block_reply setting | Discord, Feishu/Lark, Pancake, Slack, Zalo OA, Zalo Personal | +| `ChatBehaviorChannel` | Override gateway chat_behavior setting | Bitrix24, Discord, Feishu/Lark, Pancake, Slack, Telegram, WhatsApp, Zalo OA, Zalo Personal | `BaseChannel` provides a shared implementation that all channels embed: allowlist matching, `HandleMessage()`, `CheckPolicy()`, and user ID extraction. diff --git a/docs/project-changelog.md b/docs/project-changelog.md index bf1a7cde..07e4d1fb 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -6,6 +6,27 @@ Significant changes, features, and fixes in reverse chronological order. ## 2026-05-29 +### Human-like channel delivery MVP (issue #67) + +**New** + +- Added `gateway.chat_behavior` runtime config for global quick acknowledgement and safe final multi-message splitting. +- Added per-channel `chat_behavior` override support for channel instances that already participate in channel delivery settings. +- Quick acknowledgements are emitted only for non-streaming channel runs and are cancelled when a block reply or terminal event arrives. +- Final splitting applies only to non-streaming text-only final replies; unsafe Markdown, code, tables, lists, quotes, JSON, and URL-only paragraphs stay as one message. +- Added `chat_behavior.preview` RPC plus dashboard controls and per-channel override fields. + +**Validation** + +- Added Go coverage for config resolution, preview, conservative splitting, and non-streaming quick acknowledgement delivery. +- Verified focused Go packages, both Go builds, `go vet`, web Vitest, web production build, and `git diff --check`. + +**Out of scope** + +- No archive/timeline storage, renderer, share/export, or interleaved run history changes. Those remain issue #76 scope. + +--- + ### GitHub Releases update scratch dir fallback (issue #94) - Changed GitHub Releases package updates to prefer `{runtimeDir}/tmp` for diff --git a/internal/channels/bitrix24/channel.go b/internal/channels/bitrix24/channel.go index f1bd958e..2a92abb7 100644 --- a/internal/channels/bitrix24/channel.go +++ b/internal/channels/bitrix24/channel.go @@ -13,6 +13,7 @@ import ( "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -66,11 +67,11 @@ type Channel struct { // access_token — no shared admin secret is required. mcpServerID is // resolved once at Start() via mcpStore.GetServerByName and then // cached — avoids looking up the server on every inbound message. - mcpStore store.MCPServerStore - mcpClient *mcpClient - mcpServerID uuid.UUID - mcpProvMu sync.Mutex - mcpDebounce map[mcpDebounceKey]time.Time + mcpStore store.MCPServerStore + mcpClient *mcpClient + mcpServerID uuid.UUID + mcpProvMu sync.Mutex + mcpDebounce map[mcpDebounceKey]time.Time // User-facing degradation notice state. When provisionIfMissing fails // in an UNEXPECTED way (HTTP failure, persist failure, not one of the @@ -120,6 +121,12 @@ func (c *Channel) PortalName() string { return c.cfg.Portal } // Config returns a copy of the instance config. Exported for tests. func (c *Channel) Config() bitrixInstanceConfig { return c.cfg } +// BlockReplyEnabled returns the per-channel block_reply override (nil = inherit gateway default). +func (c *Channel) BlockReplyEnabled() *bool { return c.cfg.BlockReply } + +// ChatBehaviorConfig returns the per-channel chat_behavior override. +func (c *Channel) ChatBehaviorConfig() *config.ChatBehaviorConfig { return c.cfg.ChatBehavior } + // IsOpenChannelBot reports whether this channel was registered as a Bitrix24 // Open Channel bot (TYPE "O"), i.e. a customer-facing bot attached to an // Open Channel queue. Standard internal bots (TYPE "B") return false. diff --git a/internal/channels/bitrix24/factory.go b/internal/channels/bitrix24/factory.go index 06bf285d..435327f1 100644 --- a/internal/channels/bitrix24/factory.go +++ b/internal/channels/bitrix24/factory.go @@ -8,6 +8,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -29,9 +30,9 @@ type bitrixCreds struct{} // sent to imbot.register). type bitrixInstanceConfig struct { // Resource link (required) - Portal string `json:"portal"` // bitrix_portals.name scoped by tenant_id - BotCode string `json:"bot_code"` // stable key passed to imbot.register / LookupRegisteredBot - BotName string `json:"bot_name"` // display name + Portal string `json:"portal"` // bitrix_portals.name scoped by tenant_id + BotCode string `json:"bot_code"` // stable key passed to imbot.register / LookupRegisteredBot + BotName string `json:"bot_name"` // display name BotAvatar string `json:"bot_avatar,omitempty"` // optional URL; factory resolves and base64-encodes at Start() // BotType — forwarded verbatim to imbot.register TYPE param. @@ -72,8 +73,9 @@ type bitrixInstanceConfig struct { ReactionLevel string `json:"reaction_level,omitempty"` // off|minimal|full // Misc - HistoryLimit int `json:"history_limit,omitempty"` - BlockReply *bool `json:"block_reply,omitempty"` + HistoryLimit int `json:"history_limit,omitempty"` + BlockReply *bool `json:"block_reply,omitempty"` + ChatBehavior *config.ChatBehaviorConfig `json:"chat_behavior,omitempty"` // Webhook endpoint override. Bitrix24 imbot.register requires absolute // URLs for EVENT_MESSAGE_ADD etc. GoClaw has no global GOCLAW_PUBLIC_URL diff --git a/internal/channels/channel.go b/internal/channels/channel.go index 2826d880..6f06468a 100644 --- a/internal/channels/channel.go +++ b/internal/channels/channel.go @@ -20,6 +20,7 @@ import ( "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -164,6 +165,12 @@ type BlockReplyChannel interface { BlockReplyEnabled() *bool } +// ChatBehaviorChannel is optionally implemented by channels that override +// gateway-level human-like delivery behavior. Nil means inherit the gateway default. +type ChatBehaviorChannel interface { + ChatBehaviorConfig() *config.ChatBehaviorConfig +} + // WebhookChannel extends Channel with an HTTP handler that can be mounted // on the main gateway mux instead of starting a separate HTTP server. // This allows webhook-based channels (e.g. Feishu/Lark) to share the main diff --git a/internal/channels/chat_behavior.go b/internal/channels/chat_behavior.go new file mode 100644 index 00000000..b35bff67 --- /dev/null +++ b/internal/channels/chat_behavior.go @@ -0,0 +1,193 @@ +package channels + +import ( + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/config" +) + +const ( + defaultQuickAckDelayMs = 1000 + defaultFinalSplitMin = 1200 + defaultFinalSplitMax = 3 + defaultFinalSplitDelay = 500 + defaultAckTemplate = "Got it. Working on it..." +) + +type ResolvedChatBehavior struct { + Enabled bool + QuickAck ResolvedQuickAckConfig + FinalSplit ResolvedFinalSplitConfig +} + +type ResolvedQuickAckConfig struct { + Enabled bool + MinDelayMs int + Templates []string +} + +type ResolvedFinalSplitConfig struct { + Enabled bool + MinChars int + MaxMessages int + DelayMs int +} + +type ChatBehaviorPreviewOptions struct { + Content string + IsStreaming bool + HasToolCalls bool +} + +type ChatBehaviorPreview struct { + Resolved ResolvedChatBehavior `json:"resolved"` + Ack AckPreview `json:"ack"` + Split SplitPreview `json:"split"` +} + +type AckPreview struct { + ShouldSend bool `json:"shouldSend"` + Content string `json:"content,omitempty"` +} + +type SplitPreview struct { + Parts []string `json:"parts"` +} + +func ResolveChatBehavior(global, override *config.ChatBehaviorConfig) ResolvedChatBehavior { + resolved := ResolvedChatBehavior{ + QuickAck: ResolvedQuickAckConfig{ + MinDelayMs: defaultQuickAckDelayMs, + Templates: []string{defaultAckTemplate}, + }, + FinalSplit: ResolvedFinalSplitConfig{ + MinChars: defaultFinalSplitMin, + MaxMessages: defaultFinalSplitMax, + DelayMs: defaultFinalSplitDelay, + }, + } + applyChatBehavior(&resolved, global) + applyChatBehavior(&resolved, override) + if !resolved.Enabled { + resolved.QuickAck.Enabled = false + resolved.FinalSplit.Enabled = false + } + if resolved.FinalSplit.MaxMessages < 1 { + resolved.FinalSplit.MaxMessages = 1 + } + return resolved +} + +func applyChatBehavior(dst *ResolvedChatBehavior, src *config.ChatBehaviorConfig) { + if src == nil { + return + } + if src.Enabled != nil { + dst.Enabled = *src.Enabled + } + if src.QuickAck != nil { + if src.QuickAck.Enabled != nil { + dst.QuickAck.Enabled = *src.QuickAck.Enabled + } + if src.QuickAck.MinDelayMs != nil { + dst.QuickAck.MinDelayMs = max(0, *src.QuickAck.MinDelayMs) + } + if len(src.QuickAck.Templates) > 0 { + dst.QuickAck.Templates = cleanTemplates(src.QuickAck.Templates) + } + } + if src.FinalSplit != nil { + if src.FinalSplit.Enabled != nil { + dst.FinalSplit.Enabled = *src.FinalSplit.Enabled + } + if src.FinalSplit.MinChars != nil { + dst.FinalSplit.MinChars = max(0, *src.FinalSplit.MinChars) + } + if src.FinalSplit.MaxMessages != nil { + dst.FinalSplit.MaxMessages = max(1, *src.FinalSplit.MaxMessages) + } + if src.FinalSplit.DelayMs != nil { + dst.FinalSplit.DelayMs = max(0, *src.FinalSplit.DelayMs) + } + } +} + +func cleanTemplates(values []string) []string { + out := make([]string, 0, len(values)) + for _, v := range values { + if s := strings.TrimSpace(v); s != "" { + out = append(out, s) + } + } + if len(out) == 0 { + return []string{defaultAckTemplate} + } + return out +} + +func PreviewChatBehavior(global, override *config.ChatBehaviorConfig, opts ChatBehaviorPreviewOptions) ChatBehaviorPreview { + resolved := ResolveChatBehavior(global, override) + preview := ChatBehaviorPreview{ + Resolved: resolved, + Split: SplitPreview{Parts: SplitFinalMessages(opts.Content, resolved.FinalSplit)}, + } + if ShouldSendQuickAck(resolved, opts.IsStreaming) { + preview.Ack = AckPreview{ShouldSend: true, Content: resolved.QuickAck.Templates[0]} + } + return preview +} + +func ShouldSendQuickAck(behavior ResolvedChatBehavior, streaming bool) bool { + return behavior.Enabled && behavior.QuickAck.Enabled && !streaming +} + +func SplitFinalMessages(content string, cfg ResolvedFinalSplitConfig) []string { + if content == "" { + return nil + } + if !cfg.Enabled || len(content) < cfg.MinChars || cfg.MaxMessages <= 1 || hasUnsafeSplitMarkdown(content) { + return []string{content} + } + parts := splitParagraphs(content) + if len(parts) <= 1 || len(parts) > cfg.MaxMessages { + return []string{content} + } + return parts +} + +func splitParagraphs(content string) []string { + raw := strings.Split(content, "\n\n") + parts := make([]string, 0, len(raw)) + for _, part := range raw { + p := strings.TrimSpace(part) + if p == "" { + continue + } + parts = append(parts, p) + } + return parts +} + +func hasUnsafeSplitMarkdown(content string) bool { + lines := strings.SplitSeq(content, "\n") + for line := range lines { + trimmed := strings.TrimSpace(line) + switch { + case strings.HasPrefix(trimmed, "```"): + return true + case strings.HasPrefix(trimmed, ">"): + return true + case strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* "): + return true + case len(trimmed) > 3 && trimmed[0] >= '0' && trimmed[0] <= '9' && strings.HasPrefix(trimmed[1:], ". "): + return true + case strings.Contains(trimmed, "|") && (strings.Contains(trimmed, "---") || strings.Count(trimmed, "|") >= 2): + return true + case strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "<"): + return true + case strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://"): + return true + } + } + return false +} diff --git a/internal/channels/chat_behavior_events_test.go b/internal/channels/chat_behavior_events_test.go new file mode 100644 index 00000000..6eb84449 --- /dev/null +++ b/internal/channels/chat_behavior_events_test.go @@ -0,0 +1,101 @@ +package channels + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +func TestHandleAgentEvent_QuickAckNonStreamingOnly(t *testing.T) { + behavior := ResolvedChatBehavior{ + Enabled: true, + QuickAck: ResolvedQuickAckConfig{ + Enabled: true, + MinDelayMs: 0, + Templates: []string{"On it."}, + }, + } + + mb := bus.New() + mgr := NewManager(mb) + mgr.RegisterChannel("test", &chatBehaviorTestChannel{name: "test"}) + mgr.RegisterRunWithBehavior("run-1", "test", "chat-1", "msg-1", map[string]string{"local_key": "chat-1/topic"}, uuid.Nil, false, false, true, behavior) + + mgr.HandleAgentEvent(protocol.AgentEventRunStarted, "run-1", nil) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + got, ok := mb.SubscribeOutbound(ctx) + if !ok { + t.Fatal("expected quick acknowledgement outbound message") + } + if got.Content != "On it." || got.ChatID != "chat-1" || got.Metadata["local_key"] != "chat-1/topic" { + t.Fatalf("quick ack outbound = %+v, want content and routing metadata", got) + } + + mb = bus.New() + mgr = NewManager(mb) + mgr.RegisterChannel("test", &chatBehaviorTestChannel{name: "test"}) + mgr.RegisterRunWithBehavior("run-2", "test", "chat-1", "msg-1", nil, uuid.Nil, true, false, true, behavior) + + mgr.HandleAgentEvent(protocol.AgentEventRunStarted, "run-2", nil) + + ctx, cancel = context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel() + if got, ok := mb.SubscribeOutbound(ctx); ok { + t.Fatalf("streaming run emitted quick ack: %+v", got) + } +} + +func TestUnregisterRun_CancelsPendingQuickAck(t *testing.T) { + mb := bus.New() + mgr := NewManager(mb) + mgr.RegisterChannel("test", &chatBehaviorTestChannel{name: "test"}) + mgr.RegisterRunWithBehavior("run-1", "test", "chat-1", "msg-1", nil, uuid.Nil, false, false, true, ResolvedChatBehavior{ + Enabled: true, + QuickAck: ResolvedQuickAckConfig{ + Enabled: true, + MinDelayMs: 50, + Templates: []string{"On it."}, + }, + }) + + mgr.HandleAgentEvent(protocol.AgentEventRunStarted, "run-1", nil) + mgr.UnregisterRun("run-1") + + ctx, cancel := context.WithTimeout(context.Background(), 75*time.Millisecond) + defer cancel() + if got, ok := mb.SubscribeOutbound(ctx); ok { + t.Fatalf("unregistered run emitted quick ack: %+v", got) + } +} + +func TestCancelQuickAck_BlocksInFlightSend(t *testing.T) { + mb := bus.New() + mgr := NewManager(mb) + rc := &RunContext{ + ChannelName: "test", + ChatID: "chat-1", + ChatBehavior: ResolvedChatBehavior{ + Enabled: true, + QuickAck: ResolvedQuickAckConfig{ + Enabled: true, + Templates: []string{"On it."}, + }, + }, + } + + mgr.cancelQuickAck(rc) + mgr.sendQuickAck(rc) + + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel() + if got, ok := mb.SubscribeOutbound(ctx); ok { + t.Fatalf("cancelled quick ack emitted message: %+v", got) + } +} diff --git a/internal/channels/chat_behavior_test.go b/internal/channels/chat_behavior_test.go new file mode 100644 index 00000000..faf2bd10 --- /dev/null +++ b/internal/channels/chat_behavior_test.go @@ -0,0 +1,137 @@ +package channels + +import ( + "context" + "reflect" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/config" +) + +func TestResolveChatBehavior_InheritsGlobalAndChannelOverride(t *testing.T) { + global := &config.ChatBehaviorConfig{ + Enabled: new(true), + QuickAck: &config.QuickAckConfig{ + Enabled: new(true), + MinDelayMs: new(750), + Templates: []string{"On it."}, + }, + FinalSplit: &config.FinalSplitConfig{ + Enabled: new(true), + MinChars: new(1200), + MaxMessages: new(3), + DelayMs: new(400), + }, + } + override := &config.ChatBehaviorConfig{ + QuickAck: &config.QuickAckConfig{Enabled: new(false)}, + FinalSplit: &config.FinalSplitConfig{ + MaxMessages: new(2), + }, + } + + got := ResolveChatBehavior(global, override) + + if !got.Enabled { + t.Fatal("Enabled = false, want true") + } + if got.QuickAck.Enabled { + t.Fatal("QuickAck.Enabled = true, want channel override false") + } + if got.QuickAck.MinDelayMs != 750 { + t.Fatalf("QuickAck.MinDelayMs = %d, want 750", got.QuickAck.MinDelayMs) + } + if got.FinalSplit.MaxMessages != 2 { + t.Fatalf("FinalSplit.MaxMessages = %d, want override 2", got.FinalSplit.MaxMessages) + } + if got.FinalSplit.MinChars != 1200 || got.FinalSplit.DelayMs != 400 { + t.Fatalf("FinalSplit inherited fields = %+v, want min=1200 delay=400", got.FinalSplit) + } +} + +func TestSplitFinalMessages_ConservativeParagraphSplit(t *testing.T) { + cfg := ResolvedFinalSplitConfig{Enabled: true, MinChars: 20, MaxMessages: 3} + text := "First part is useful.\n\nSecond part is also useful.\n\nThird part closes it." + + got := SplitFinalMessages(text, cfg) + want := []string{"First part is useful.", "Second part is also useful.", "Third part closes it."} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("SplitFinalMessages() = %#v, want %#v", got, want) + } +} + +func TestSplitFinalMessages_DoesNotSplitUnsafeMarkdown(t *testing.T) { + cfg := ResolvedFinalSplitConfig{Enabled: true, MinChars: 10, MaxMessages: 3} + cases := map[string]string{ + "fenced code": "Intro.\n\n```go\nfmt.Println(\"hi\")\n```\n\nDone.", + "table": "A | B\n--- | ---\n1 | 2\n\nDone.", + "list": "Intro.\n\n- one\n- two\n\nDone.", + "quote": "Intro.\n\n> quoted\n> text\n\nDone.", + "json": "Intro.\n\n{\"ok\": true}\n\nDone.", + "url paragraph": "Intro.\n\nhttps://example.com/a/b?c=d\n\nDone.", + } + + for name, text := range cases { + t.Run(name, func(t *testing.T) { + got := SplitFinalMessages(text, cfg) + if len(got) != 1 || got[0] != text { + t.Fatalf("SplitFinalMessages() = %#v, want original single message", got) + } + }) + } +} + +func TestPreviewChatBehavior_NoSideEffects(t *testing.T) { + global := &config.ChatBehaviorConfig{ + Enabled: new(true), + QuickAck: &config.QuickAckConfig{Enabled: new(true), Templates: []string{"Working."}}, + FinalSplit: &config.FinalSplitConfig{Enabled: new(true), MinChars: new(10), MaxMessages: new(2)}, + } + + got := PreviewChatBehavior(global, nil, ChatBehaviorPreviewOptions{ + Content: "Part one is long.\n\nPart two is long.", + IsStreaming: false, + HasToolCalls: true, + }) + + if !got.Ack.ShouldSend || got.Ack.Content != "Working." { + t.Fatalf("Ack preview = %+v, want send Working.", got.Ack) + } + if len(got.Split.Parts) != 2 { + t.Fatalf("Split parts = %#v, want two parts", got.Split.Parts) + } +} + +func TestManagerResolveChatBehavior_UsesChannelOverride(t *testing.T) { + global := &config.ChatBehaviorConfig{ + Enabled: new(true), + QuickAck: &config.QuickAckConfig{Enabled: new(true), Templates: []string{"global"}}, + } + override := &config.ChatBehaviorConfig{ + QuickAck: &config.QuickAckConfig{Enabled: new(true), Templates: []string{"channel"}}, + } + mgr := NewManager(bus.New()) + mgr.RegisterChannel("test", &chatBehaviorTestChannel{name: "test", behavior: override}) + + got := mgr.ResolveChatBehavior("test", global) + + if got.QuickAck.Templates[0] != "channel" { + t.Fatalf("QuickAck template = %q, want channel override", got.QuickAck.Templates[0]) + } +} + +type chatBehaviorTestChannel struct { + name string + behavior *config.ChatBehaviorConfig +} + +func (c *chatBehaviorTestChannel) Name() string { return c.name } +func (c *chatBehaviorTestChannel) Type() string { return c.name } +func (c *chatBehaviorTestChannel) Start(context.Context) error { return nil } +func (c *chatBehaviorTestChannel) Stop(context.Context) error { return nil } +func (c *chatBehaviorTestChannel) Send(context.Context, bus.OutboundMessage) error { return nil } +func (c *chatBehaviorTestChannel) IsRunning() bool { return true } +func (c *chatBehaviorTestChannel) IsAllowed(string) bool { return true } +func (c *chatBehaviorTestChannel) ChatBehaviorConfig() *config.ChatBehaviorConfig { return c.behavior } diff --git a/internal/channels/discord/discord.go b/internal/channels/discord/discord.go index 795a47be..887b12ff 100644 --- a/internal/channels/discord/discord.go +++ b/internal/channels/discord/discord.go @@ -26,12 +26,12 @@ type Channel struct { *channels.BaseChannel session *discordgo.Session config config.DiscordConfig - botUserID string // populated on start - placeholders sync.Map // placeholderKey string → messageID string - typingCtrls sync.Map // channelID string → *typing.Controller + botUserID string // populated on start + placeholders sync.Map // placeholderKey string → messageID string + typingCtrls sync.Map // channelID string → *typing.Controller agentStore store.AgentStore // for agent key lookup (nil = writer commands disabled) configPermStore store.ConfigPermissionStore // for group file writer management (nil = writer commands disabled) - audioMgr *audio.Manager // unified STT via audio.Manager (nil = no STT) + audioMgr *audio.Manager // unified STT via audio.Manager (nil = no STT) // pairingService, pairingDebounce, approvedGroups, groupHistory, historyLimit, requireMention // are inherited from channels.BaseChannel. } @@ -108,6 +108,9 @@ func (c *Channel) Start(_ context.Context) error { // BlockReplyEnabled returns the per-channel block_reply override (nil = inherit gateway default). func (c *Channel) BlockReplyEnabled() *bool { return c.config.BlockReply } +// ChatBehaviorConfig returns the per-channel chat_behavior override. +func (c *Channel) ChatBehaviorConfig() *config.ChatBehaviorConfig { return c.config.ChatBehavior } + // SetPendingCompaction configures LLM-based auto-compaction for pending messages. func (c *Channel) SetPendingCompaction(cfg *channels.CompactionConfig) { if gh := c.GroupHistory(); gh != nil { diff --git a/internal/channels/discord/factory.go b/internal/channels/discord/factory.go index 9add24c6..52efcc13 100644 --- a/internal/channels/discord/factory.go +++ b/internal/channels/discord/factory.go @@ -18,18 +18,19 @@ type discordCreds struct { // discordInstanceConfig maps the non-secret config JSONB from the channel_instances table. type discordInstanceConfig struct { - DMPolicy string `json:"dm_policy,omitempty"` - GroupPolicy string `json:"group_policy,omitempty"` - AllowFrom []string `json:"allow_from,omitempty"` - RequireMention *bool `json:"require_mention,omitempty"` - HistoryLimit int `json:"history_limit,omitempty"` - BlockReply *bool `json:"block_reply,omitempty"` - MediaMaxBytes int64 `json:"media_max_bytes,omitempty"` - STTProxyURL string `json:"stt_proxy_url,omitempty"` - STTAPIKey string `json:"stt_api_key,omitempty"` - STTTenantID string `json:"stt_tenant_id,omitempty"` - STTTimeoutSeconds int `json:"stt_timeout_seconds,omitempty"` - VoiceAgentID string `json:"voice_agent_id,omitempty"` + DMPolicy string `json:"dm_policy,omitempty"` + GroupPolicy string `json:"group_policy,omitempty"` + AllowFrom []string `json:"allow_from,omitempty"` + RequireMention *bool `json:"require_mention,omitempty"` + HistoryLimit int `json:"history_limit,omitempty"` + BlockReply *bool `json:"block_reply,omitempty"` + ChatBehavior *config.ChatBehaviorConfig `json:"chat_behavior,omitempty"` + MediaMaxBytes int64 `json:"media_max_bytes,omitempty"` + STTProxyURL string `json:"stt_proxy_url,omitempty"` + STTAPIKey string `json:"stt_api_key,omitempty"` + STTTenantID string `json:"stt_tenant_id,omitempty"` + STTTimeoutSeconds int `json:"stt_timeout_seconds,omitempty"` + VoiceAgentID string `json:"voice_agent_id,omitempty"` } // Factory creates a Discord channel from DB instance data (no extra stores). @@ -82,6 +83,7 @@ func buildChannel(name string, creds json.RawMessage, cfg json.RawMessage, RequireMention: ic.RequireMention, HistoryLimit: ic.HistoryLimit, BlockReply: ic.BlockReply, + ChatBehavior: ic.ChatBehavior, MediaMaxBytes: ic.MediaMaxBytes, STTProxyURL: ic.STTProxyURL, STTAPIKey: ic.STTAPIKey, diff --git a/internal/channels/events.go b/internal/channels/events.go index 841b0fed..e3fc7b8f 100644 --- a/internal/channels/events.go +++ b/internal/channels/events.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "strings" + "time" "github.com/google/uuid" @@ -38,6 +39,10 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) { ctx = store.WithTenantID(ctx, rc.TenantID) } + if eventType == protocol.AgentEventRunStarted { + m.scheduleQuickAck(rc) + } + // Forward to StreamingChannel (only when streaming is enabled for this run). // Without this gate, channels that implement StreamingChannel but have streaming // disabled (e.g. group_stream=false) would create stream messages AND emit @@ -284,6 +289,11 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) { return // streaming already delivered via chunks } + m.cancelQuickAck(rc) + rc.mu.Lock() + rc.blockReplySent = true + rc.mu.Unlock() + // Build outbound metadata: copy routing fields but strip reply_to_message_id // (block replies are standalone) and placeholder_key (reserve for final message). // feishu_reply_target_id MUST be preserved so intermediate block replies for @@ -354,10 +364,59 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) { // Clean up on terminal events if eventType == protocol.AgentEventRunCompleted || eventType == protocol.AgentEventRunFailed || eventType == protocol.AgentEventRunCancelled { + m.cancelQuickAck(rc) m.runs.Delete(runID) } } +func (m *Manager) scheduleQuickAck(rc *RunContext) { + if !ShouldSendQuickAck(rc.ChatBehavior, rc.Streaming) { + return + } + delay := time.Duration(rc.ChatBehavior.QuickAck.MinDelayMs) * time.Millisecond + if delay <= 0 { + m.sendQuickAck(rc) + return + } + rc.mu.Lock() + if rc.ackTimer == nil && !rc.ackSent && !rc.blockReplySent { + rc.ackTimer = time.AfterFunc(delay, func() { + m.sendQuickAck(rc) + }) + } + rc.mu.Unlock() +} + +func (m *Manager) cancelQuickAck(rc *RunContext) { + rc.mu.Lock() + rc.ackCancelled = true + if rc.ackTimer != nil { + rc.ackTimer.Stop() + rc.ackTimer = nil + } + rc.mu.Unlock() +} + +func (m *Manager) sendQuickAck(rc *RunContext) { + rc.mu.Lock() + if rc.ackCancelled || rc.ackSent || rc.blockReplySent || !ShouldSendQuickAck(rc.ChatBehavior, rc.Streaming) || len(rc.ChatBehavior.QuickAck.Templates) == 0 { + rc.mu.Unlock() + return + } + content := rc.ChatBehavior.QuickAck.Templates[0] + rc.ackSent = true + rc.ackTimer = nil + rc.mu.Unlock() + + m.bus.PublishOutbound(bus.OutboundMessage{ + Channel: rc.ChannelName, + ChatID: rc.ChatID, + Content: content, + Metadata: copyRoutingMeta(rc.Metadata), + TenantID: rc.TenantID, + }) +} + // extractPayloadString extracts a string field from a payload (map[string]string or map[string]interface{}). func extractPayloadString(payload any, key string) string { switch p := payload.(type) { @@ -371,7 +430,6 @@ func extractPayloadString(payload any, key string) string { return "" } - // toolStatusMap maps builtin tool names to user-friendly status messages. var toolStatusMap = map[string]string{ // Filesystem @@ -400,8 +458,8 @@ var toolStatusMap = map[string]string{ // Browser "browser": "🌐 Browsing...", // Delegation & teams - "spawn": "👥 Delegating task...", - "team_tasks": "📋 Managing team tasks...", + "spawn": "👥 Delegating task...", + "team_tasks": "📋 Managing team tasks...", // Sessions "sessions_list": "📋 Listing sessions...", "session_status": "📋 Checking session...", diff --git a/internal/channels/feishu/factory.go b/internal/channels/feishu/factory.go index 1f0e9785..0858cba0 100644 --- a/internal/channels/feishu/factory.go +++ b/internal/channels/feishu/factory.go @@ -21,28 +21,29 @@ type feishuCreds struct { // feishuInstanceConfig maps the non-secret config JSONB from the channel_instances table. type feishuInstanceConfig struct { - Domain string `json:"domain,omitempty"` - ConnectionMode string `json:"connection_mode,omitempty"` - WebhookPort int `json:"webhook_port,omitempty"` - WebhookPath string `json:"webhook_path,omitempty"` - AllowFrom []string `json:"allow_from,omitempty"` - DMPolicy string `json:"dm_policy,omitempty"` - GroupPolicy string `json:"group_policy,omitempty"` - GroupAllowFrom []string `json:"group_allow_from,omitempty"` - RequireMention *bool `json:"require_mention,omitempty"` - TopicSessionMode string `json:"topic_session_mode,omitempty"` - TextChunkLimit int `json:"text_chunk_limit,omitempty"` - MediaMaxMB int `json:"media_max_mb,omitempty"` - RenderMode string `json:"render_mode,omitempty"` - Streaming *bool `json:"streaming,omitempty"` - ReactionLevel string `json:"reaction_level,omitempty"` - HistoryLimit int `json:"history_limit,omitempty"` - BlockReply *bool `json:"block_reply,omitempty"` - STTProxyURL string `json:"stt_proxy_url,omitempty"` - STTAPIKey string `json:"stt_api_key,omitempty"` - STTTenantID string `json:"stt_tenant_id,omitempty"` - STTTimeoutSeconds int `json:"stt_timeout_seconds,omitempty"` - VoiceAgentID string `json:"voice_agent_id,omitempty"` + Domain string `json:"domain,omitempty"` + ConnectionMode string `json:"connection_mode,omitempty"` + WebhookPort int `json:"webhook_port,omitempty"` + WebhookPath string `json:"webhook_path,omitempty"` + AllowFrom []string `json:"allow_from,omitempty"` + DMPolicy string `json:"dm_policy,omitempty"` + GroupPolicy string `json:"group_policy,omitempty"` + GroupAllowFrom []string `json:"group_allow_from,omitempty"` + RequireMention *bool `json:"require_mention,omitempty"` + TopicSessionMode string `json:"topic_session_mode,omitempty"` + TextChunkLimit int `json:"text_chunk_limit,omitempty"` + MediaMaxMB int `json:"media_max_mb,omitempty"` + RenderMode string `json:"render_mode,omitempty"` + Streaming *bool `json:"streaming,omitempty"` + ReactionLevel string `json:"reaction_level,omitempty"` + HistoryLimit int `json:"history_limit,omitempty"` + BlockReply *bool `json:"block_reply,omitempty"` + ChatBehavior *config.ChatBehaviorConfig `json:"chat_behavior,omitempty"` + STTProxyURL string `json:"stt_proxy_url,omitempty"` + STTAPIKey string `json:"stt_api_key,omitempty"` + STTTenantID string `json:"stt_tenant_id,omitempty"` + STTTimeoutSeconds int `json:"stt_timeout_seconds,omitempty"` + VoiceAgentID string `json:"voice_agent_id,omitempty"` } // Factory creates a Feishu/Lark channel from DB instance data. @@ -89,6 +90,7 @@ func Factory(name string, creds json.RawMessage, cfg json.RawMessage, ReactionLevel: ic.ReactionLevel, HistoryLimit: ic.HistoryLimit, BlockReply: ic.BlockReply, + ChatBehavior: ic.ChatBehavior, STTProxyURL: ic.STTProxyURL, STTAPIKey: ic.STTAPIKey, STTTenantID: ic.STTTenantID, @@ -160,6 +162,7 @@ func FactoryWithPendingStoreAndAudio(pendingStore store.PendingMessageStore, aud ReactionLevel: ic.ReactionLevel, HistoryLimit: ic.HistoryLimit, BlockReply: ic.BlockReply, + ChatBehavior: ic.ChatBehavior, STTProxyURL: ic.STTProxyURL, STTAPIKey: ic.STTAPIKey, STTTenantID: ic.STTTenantID, diff --git a/internal/channels/feishu/feishu.go b/internal/channels/feishu/feishu.go index c91bc5e1..9c7d7072 100644 --- a/internal/channels/feishu/feishu.go +++ b/internal/channels/feishu/feishu.go @@ -40,10 +40,10 @@ type Channel struct { cfg config.FeishuConfig client *LarkClient botOpenID string - senderCache sync.Map // open_id → *senderCacheEntry - dedup sync.Map // message_id → struct{} - reactions sync.Map // chatID → *reactionState - docCache *docCache // LRU+TTL cache for Lark docx raw_content lookups + senderCache sync.Map // open_id → *senderCacheEntry + dedup sync.Map // message_id → struct{} + reactions sync.Map // chatID → *reactionState + docCache *docCache // LRU+TTL cache for Lark docx raw_content lookups agentStore store.AgentStore // optional — agent key → UUID lookup for writer commands configPermStore store.ConfigPermissionStore // optional — group file writer ACL for /addwriter et al. groupAllowList []string // Feishu-specific: per-group sender allowlist (separate from BaseChannel allowList) @@ -160,6 +160,9 @@ func (c *Channel) Start(ctx context.Context) error { // BlockReplyEnabled returns the per-channel block_reply override (nil = inherit gateway default). func (c *Channel) BlockReplyEnabled() *bool { return c.cfg.BlockReply } +// ChatBehaviorConfig returns the per-channel chat_behavior override. +func (c *Channel) ChatBehaviorConfig() *config.ChatBehaviorConfig { return c.cfg.ChatBehavior } + // SetPendingCompaction configures LLM-based auto-compaction for pending messages. func (c *Channel) SetPendingCompaction(cfg *channels.CompactionConfig) { if gh := c.GroupHistory(); gh != nil { diff --git a/internal/channels/manager.go b/internal/channels/manager.go index 4cc8f654..f5a325b5 100644 --- a/internal/channels/manager.go +++ b/internal/channels/manager.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "sync" + "time" "github.com/google/uuid" @@ -36,7 +37,12 @@ type RunContext struct { Streaming bool // whether run uses streaming (to avoid double-delivery of block replies) BlockReplyEnabled bool // whether block.reply delivery is enabled for this run (resolved at RegisterRun time) ToolStatusEnabled bool // whether tool name shows in streaming preview during tool execution + ChatBehavior ResolvedChatBehavior mu sync.Mutex + ackTimer *time.Timer + ackSent bool + ackCancelled bool + blockReplySent bool streamBuffer string // accumulated streaming text (chunks are deltas) inToolPhase bool // true after tool.call, reset on next chunk (new LLM iteration) stream ChannelStream // per-run stream handle (replaces per-chat sync.Map in channel impls) diff --git a/internal/channels/pancake/pancake.go b/internal/channels/pancake/pancake.go index def64d0f..9903d8da 100644 --- a/internal/channels/pancake/pancake.go +++ b/internal/channels/pancake/pancake.go @@ -11,6 +11,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -332,6 +333,9 @@ func (ch *Channel) sendPrivateReply(ctx context.Context, senderID, conversationI // BlockReplyEnabled returns the per-channel block_reply override (nil = inherit gateway default). func (ch *Channel) BlockReplyEnabled() *bool { return ch.config.BlockReply } +// ChatBehaviorConfig returns the per-channel chat_behavior override. +func (ch *Channel) ChatBehaviorConfig() *config.ChatBehaviorConfig { return ch.config.ChatBehavior } + // WebhookHandler returns the shared webhook path and global router as handler. // Only the first pancake instance mounts the route; others return ("", nil). func (ch *Channel) WebhookHandler() (string, http.Handler) { diff --git a/internal/channels/pancake/types.go b/internal/channels/pancake/types.go index 7aa7ebaf..f36af6e3 100644 --- a/internal/channels/pancake/types.go +++ b/internal/channels/pancake/types.go @@ -3,7 +3,11 @@ // A single Pancake API key gives access to all connected platforms — no per-platform OAuth needed. package pancake -import "encoding/json" +import ( + "encoding/json" + + "github.com/nextlevelbuilder/goclaw/internal/config" +) // pancakeCreds holds encrypted credentials stored in channel_instances.credentials. type pancakeCreds struct { @@ -31,11 +35,12 @@ type pancakeInstanceConfig struct { Filter string `json:"filter"` // "all" | "keyword" (default: all) Keywords []string `json:"keywords"` // required when filter = "keyword" } `json:"comment_reply_options"` - PrivateReplyMessage string `json:"private_reply_message,omitempty"` // custom DM text; defaults to built-in message. Supports {{commenter_name}} / {{post_title}} vars. - AutoReactOptions *AutoReactOptions `json:"auto_react_options,omitempty"` - PostContextCacheTTL string `json:"post_context_cache_ttl,omitempty"` // e.g. "30m"; defaults to 15m - AllowFrom []string `json:"allow_from,omitempty"` - BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) + PrivateReplyMessage string `json:"private_reply_message,omitempty"` // custom DM text; defaults to built-in message. Supports {{commenter_name}} / {{post_title}} vars. + AutoReactOptions *AutoReactOptions `json:"auto_react_options,omitempty"` + PostContextCacheTTL string `json:"post_context_cache_ttl,omitempty"` // e.g. "30m"; defaults to 15m + AllowFrom []string `json:"allow_from,omitempty"` + BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) + ChatBehavior *config.ChatBehaviorConfig `json:"chat_behavior,omitempty"` // override gateway chat behavior (nil = inherit) } // AutoReactOptions holds per-page scope filters for Facebook auto-react. diff --git a/internal/channels/routing_metadata.go b/internal/channels/routing_metadata.go index 606f368b..ce448596 100644 --- a/internal/channels/routing_metadata.go +++ b/internal/channels/routing_metadata.go @@ -38,6 +38,13 @@ func CopyFinalRoutingMeta(src map[string]string) map[string]string { return copySelectedMeta(src, finalReplyMetaKeys) } +// CopyFollowupRoutingMeta copies routing metadata for additional outbound +// messages after the first final reply. It preserves thread/topic routing but +// does not reuse placeholder or reply-to metadata reserved for the first reply. +func CopyFollowupRoutingMeta(src map[string]string) map[string]string { + return copyRoutingMeta(src) +} + // copyRoutingMeta copies only the subset safe for intermediate block replies, // retries, and placeholder updates. func copyRoutingMeta(src map[string]string) map[string]string { diff --git a/internal/channels/runs.go b/internal/channels/runs.go index 3c5a0163..2e1010d7 100644 --- a/internal/channels/runs.go +++ b/internal/channels/runs.go @@ -1,12 +1,22 @@ package channels -import "github.com/google/uuid" +import ( + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/config" +) // --- Run tracking for streaming/reaction event forwarding --- // RegisterRun associates a run ID with a channel context so agent events // (chunks, tool calls, completion) can be forwarded to the originating channel. func (m *Manager) RegisterRun(runID, channelName, chatID, messageID string, metadata map[string]string, tenantID uuid.UUID, streaming, blockReply, toolStatus bool) { + m.RegisterRunWithBehavior(runID, channelName, chatID, messageID, metadata, tenantID, streaming, blockReply, toolStatus, ResolvedChatBehavior{}) +} + +// RegisterRunWithBehavior associates a run ID with channel context and +// resolved delivery behavior so event handlers do not read mutable config mid-run. +func (m *Manager) RegisterRunWithBehavior(runID, channelName, chatID, messageID string, metadata map[string]string, tenantID uuid.UUID, streaming, blockReply, toolStatus bool, chatBehavior ResolvedChatBehavior) { m.runs.Store(runID, &RunContext{ ChannelName: channelName, ChatID: chatID, @@ -16,12 +26,17 @@ func (m *Manager) RegisterRun(runID, channelName, chatID, messageID string, meta Streaming: streaming, BlockReplyEnabled: blockReply, ToolStatusEnabled: toolStatus, + ChatBehavior: chatBehavior, }) } // UnregisterRun removes a run tracking entry. func (m *Manager) UnregisterRun(runID string) { - m.runs.Delete(runID) + if val, ok := m.runs.LoadAndDelete(runID); ok { + if rc, ok := val.(*RunContext); ok { + m.cancelQuickAck(rc) + } + } } // IsStreamingChannel checks if a named channel implements StreamingChannel @@ -56,3 +71,17 @@ func (m *Manager) ResolveBlockReply(channelName string, globalDefault *bool) boo } return globalDefault != nil && *globalDefault } + +// ResolveChatBehavior checks per-channel override, then falls back to gateway config. +func (m *Manager) ResolveChatBehavior(channelName string, globalDefault *config.ChatBehaviorConfig) ResolvedChatBehavior { + var override *config.ChatBehaviorConfig + m.mu.RLock() + ch, exists := m.channels[channelName] + m.mu.RUnlock() + if exists { + if bc, ok := ch.(ChatBehaviorChannel); ok { + override = bc.ChatBehaviorConfig() + } + } + return ResolveChatBehavior(globalDefault, override) +} diff --git a/internal/channels/slack/factory.go b/internal/channels/slack/factory.go index 53652eb4..6d68264b 100644 --- a/internal/channels/slack/factory.go +++ b/internal/channels/slack/factory.go @@ -19,18 +19,19 @@ type slackCreds struct { // slackInstanceConfig maps the non-secret config JSONB from the channel_instances table. type slackInstanceConfig struct { - DMPolicy string `json:"dm_policy,omitempty"` - GroupPolicy string `json:"group_policy,omitempty"` - AllowFrom []string `json:"allow_from,omitempty"` - RequireMention *bool `json:"require_mention,omitempty"` - HistoryLimit int `json:"history_limit,omitempty"` - DMStream *bool `json:"dm_stream,omitempty"` - GroupStream *bool `json:"group_stream,omitempty"` - NativeStream *bool `json:"native_stream,omitempty"` - ReactionLevel string `json:"reaction_level,omitempty"` - BlockReply *bool `json:"block_reply,omitempty"` - DebounceDelay *int `json:"debounce_delay,omitempty"` - ThreadTTL *int `json:"thread_ttl,omitempty"` + DMPolicy string `json:"dm_policy,omitempty"` + GroupPolicy string `json:"group_policy,omitempty"` + AllowFrom []string `json:"allow_from,omitempty"` + RequireMention *bool `json:"require_mention,omitempty"` + HistoryLimit int `json:"history_limit,omitempty"` + DMStream *bool `json:"dm_stream,omitempty"` + GroupStream *bool `json:"group_stream,omitempty"` + NativeStream *bool `json:"native_stream,omitempty"` + ReactionLevel string `json:"reaction_level,omitempty"` + BlockReply *bool `json:"block_reply,omitempty"` + ChatBehavior *config.ChatBehaviorConfig `json:"chat_behavior,omitempty"` + DebounceDelay *int `json:"debounce_delay,omitempty"` + ThreadTTL *int `json:"thread_ttl,omitempty"` } // Factory creates a Slack channel from DB instance data. @@ -72,6 +73,7 @@ func Factory(name string, creds json.RawMessage, cfg json.RawMessage, NativeStream: ic.NativeStream, ReactionLevel: ic.ReactionLevel, BlockReply: ic.BlockReply, + ChatBehavior: ic.ChatBehavior, DebounceDelay: ic.DebounceDelay, ThreadTTL: ic.ThreadTTL, } @@ -129,6 +131,7 @@ func FactoryWithPendingStore(pendingStore store.PendingMessageStore) channels.Ch NativeStream: ic.NativeStream, ReactionLevel: ic.ReactionLevel, BlockReply: ic.BlockReply, + ChatBehavior: ic.ChatBehavior, DebounceDelay: ic.DebounceDelay, ThreadTTL: ic.ThreadTTL, } diff --git a/internal/channels/slack/utils.go b/internal/channels/slack/utils.go index 780f3c88..f61320b0 100644 --- a/internal/channels/slack/utils.go +++ b/internal/channels/slack/utils.go @@ -9,6 +9,7 @@ import ( "time" "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -54,6 +55,9 @@ func (c *Channel) HandleMessage(senderID, chatID, content string, mediaPaths []s // BlockReplyEnabled returns the per-channel block_reply override. func (c *Channel) BlockReplyEnabled() *bool { return c.config.BlockReply } +// ChatBehaviorConfig returns the per-channel chat_behavior override. +func (c *Channel) ChatBehaviorConfig() *config.ChatBehaviorConfig { return c.config.ChatBehavior } + // resolveDisplayName fetches and caches the Slack display name for a user ID. func (c *Channel) resolveDisplayName(userID string) string { c.userCacheMu.RLock() diff --git a/internal/channels/telegram/channel.go b/internal/channels/telegram/channel.go index 5ba967c2..b8005e79 100644 --- a/internal/channels/telegram/channel.go +++ b/internal/channels/telegram/channel.go @@ -378,6 +378,9 @@ func (c *Channel) ReasoningStreamEnabled() bool { // BlockReplyEnabled returns the per-channel block_reply override (nil = inherit gateway default). func (c *Channel) BlockReplyEnabled() *bool { return c.config.BlockReply } +// ChatBehaviorConfig returns the per-channel chat_behavior override. +func (c *Channel) ChatBehaviorConfig() *config.ChatBehaviorConfig { return c.config.ChatBehavior } + // SetPendingCompaction configures LLM-based auto-compaction for pending messages. func (c *Channel) SetPendingCompaction(cfg *channels.CompactionConfig) { if gh := c.GroupHistory(); gh != nil { diff --git a/internal/channels/telegram/factory.go b/internal/channels/telegram/factory.go index 9152cd36..8d0078b7 100644 --- a/internal/channels/telegram/factory.go +++ b/internal/channels/telegram/factory.go @@ -20,24 +20,25 @@ type telegramCreds struct { // telegramInstanceConfig maps the non-secret config JSONB from the channel_instances table. type telegramInstanceConfig struct { - APIServer string `json:"api_server,omitempty"` - Proxy string `json:"proxy,omitempty"` - DMPolicy string `json:"dm_policy,omitempty"` - GroupPolicy string `json:"group_policy,omitempty"` - RequireMention *bool `json:"require_mention,omitempty"` - MentionMode string `json:"mention_mode,omitempty"` - HistoryLimit int `json:"history_limit,omitempty"` - DMStream *bool `json:"dm_stream,omitempty"` - GroupStream *bool `json:"group_stream,omitempty"` - DraftTransport *bool `json:"draft_transport,omitempty"` // sendMessageDraft for DM streaming (default true) - ReasoningStream *bool `json:"reasoning_stream,omitempty"` // show reasoning as separate message (default true) - ReactionLevel string `json:"reaction_level,omitempty"` - MediaMaxMB int64 `json:"media_max_mb,omitempty"` - MediaMaxBytes int64 `json:"media_max_bytes,omitempty"` // deprecated: use media_max_mb - LinkPreview *bool `json:"link_preview,omitempty"` - BlockReply *bool `json:"block_reply,omitempty"` - ForceIPv4 bool `json:"force_ipv4,omitempty"` - AllowFrom []string `json:"allow_from,omitempty"` + APIServer string `json:"api_server,omitempty"` + Proxy string `json:"proxy,omitempty"` + DMPolicy string `json:"dm_policy,omitempty"` + GroupPolicy string `json:"group_policy,omitempty"` + RequireMention *bool `json:"require_mention,omitempty"` + MentionMode string `json:"mention_mode,omitempty"` + HistoryLimit int `json:"history_limit,omitempty"` + DMStream *bool `json:"dm_stream,omitempty"` + GroupStream *bool `json:"group_stream,omitempty"` + DraftTransport *bool `json:"draft_transport,omitempty"` // sendMessageDraft for DM streaming (default true) + ReasoningStream *bool `json:"reasoning_stream,omitempty"` // show reasoning as separate message (default true) + ReactionLevel string `json:"reaction_level,omitempty"` + MediaMaxMB int64 `json:"media_max_mb,omitempty"` + MediaMaxBytes int64 `json:"media_max_bytes,omitempty"` // deprecated: use media_max_mb + LinkPreview *bool `json:"link_preview,omitempty"` + BlockReply *bool `json:"block_reply,omitempty"` + ChatBehavior *config.ChatBehaviorConfig `json:"chat_behavior,omitempty"` + ForceIPv4 bool `json:"force_ipv4,omitempty"` + AllowFrom []string `json:"allow_from,omitempty"` } // Factory creates a Telegram channel from DB instance data (no extra stores). @@ -96,25 +97,26 @@ func buildChannel(name string, creds json.RawMessage, cfg json.RawMessage, } tgCfg := config.TelegramConfig{ - Enabled: true, - Token: c.Token, - Proxy: proxy, - APIServer: apiServer, - AllowFrom: ic.AllowFrom, - DMPolicy: ic.DMPolicy, - GroupPolicy: ic.GroupPolicy, - RequireMention: ic.RequireMention, - MentionMode: ic.MentionMode, - HistoryLimit: ic.HistoryLimit, + Enabled: true, + Token: c.Token, + Proxy: proxy, + APIServer: apiServer, + AllowFrom: ic.AllowFrom, + DMPolicy: ic.DMPolicy, + GroupPolicy: ic.GroupPolicy, + RequireMention: ic.RequireMention, + MentionMode: ic.MentionMode, + HistoryLimit: ic.HistoryLimit, DMStream: ic.DMStream, GroupStream: ic.GroupStream, DraftTransport: ic.DraftTransport, ReasoningStream: ic.ReasoningStream, ReactionLevel: ic.ReactionLevel, - MediaMaxBytes: resolveMediaMaxBytes(ic), - LinkPreview: ic.LinkPreview, - BlockReply: ic.BlockReply, - ForceIPv4: ic.ForceIPv4, + MediaMaxBytes: resolveMediaMaxBytes(ic), + LinkPreview: ic.LinkPreview, + BlockReply: ic.BlockReply, + ChatBehavior: ic.ChatBehavior, + ForceIPv4: ic.ForceIPv4, } // DB instances default to "pairing" for groups (secure by default). diff --git a/internal/channels/whatsapp/factory.go b/internal/channels/whatsapp/factory.go index 78316aa4..044f8dca 100644 --- a/internal/channels/whatsapp/factory.go +++ b/internal/channels/whatsapp/factory.go @@ -14,12 +14,13 @@ import ( // whatsappInstanceConfig maps the non-secret config JSONB from the channel_instances table. type whatsappInstanceConfig struct { - DMPolicy string `json:"dm_policy,omitempty"` - GroupPolicy string `json:"group_policy,omitempty"` - RequireMention *bool `json:"require_mention,omitempty"` - HistoryLimit int `json:"history_limit,omitempty"` - AllowFrom []string `json:"allow_from,omitempty"` - BlockReply *bool `json:"block_reply,omitempty"` + DMPolicy string `json:"dm_policy,omitempty"` + GroupPolicy string `json:"group_policy,omitempty"` + RequireMention *bool `json:"require_mention,omitempty"` + HistoryLimit int `json:"history_limit,omitempty"` + AllowFrom []string `json:"allow_from,omitempty"` + BlockReply *bool `json:"block_reply,omitempty"` + ChatBehavior *config.ChatBehaviorConfig `json:"chat_behavior,omitempty"` } // FactoryWithDB returns a ChannelFactory with DB access for whatsmeow auth state. @@ -44,14 +45,18 @@ func FactoryWithDBAudio(db *sql.DB, pendingStore store.PendingMessageStore, dial // Detect old bridge_url config and give clear migration error. if len(cfg) > 0 { - var legacy struct{ BridgeURL string `json:"bridge_url"` } + var legacy struct { + BridgeURL string `json:"bridge_url"` + } if json.Unmarshal(cfg, &legacy) == nil && legacy.BridgeURL != "" { return nil, fmt.Errorf("whatsapp: bridge_url is no longer supported — " + "WhatsApp now runs natively via whatsmeow. Remove bridge_url from config") } } if len(creds) > 0 { - var legacy struct{ BridgeURL string `json:"bridge_url"` } + var legacy struct { + BridgeURL string `json:"bridge_url"` + } if json.Unmarshal(creds, &legacy) == nil && legacy.BridgeURL != "" { return nil, fmt.Errorf("whatsapp: bridge_url is no longer supported — " + "WhatsApp now runs natively via whatsmeow. Remove bridge_url from credentials") @@ -66,6 +71,7 @@ func FactoryWithDBAudio(db *sql.DB, pendingStore store.PendingMessageStore, dial RequireMention: ic.RequireMention, HistoryLimit: ic.HistoryLimit, BlockReply: ic.BlockReply, + ChatBehavior: ic.ChatBehavior, } // DB instances default to "pairing" for groups (secure by default). if waCfg.GroupPolicy == "" { diff --git a/internal/channels/whatsapp/whatsapp.go b/internal/channels/whatsapp/whatsapp.go index cbd8afd9..feebc24b 100644 --- a/internal/channels/whatsapp/whatsapp.go +++ b/internal/channels/whatsapp/whatsapp.go @@ -35,14 +35,14 @@ func init() { // Auth state is stored in PostgreSQL (standard) or SQLite (desktop). type Channel struct { *channels.BaseChannel - client *whatsmeow.Client - container *sqlstore.Container - config config.WhatsAppConfig - mu sync.Mutex - ctx context.Context - cancel context.CancelFunc - parentCtx context.Context // stored from Start() for Reauth() context chain - audioMgr *audio.Manager // unified STT via audio.Manager (nil = no STT) + client *whatsmeow.Client + container *sqlstore.Container + config config.WhatsAppConfig + mu sync.Mutex + ctx context.Context + cancel context.CancelFunc + parentCtx context.Context // stored from Start() for Reauth() context chain + audioMgr *audio.Manager // unified STT via audio.Manager (nil = no STT) builtinToolStore store.BuiltinToolStore // reads stt settings (whatsapp_enabled) per voice message; nil = opt-out // QR state @@ -146,6 +146,9 @@ func (c *Channel) Start(ctx context.Context) error { // BlockReplyEnabled returns the per-channel block_reply override (nil = inherit gateway default). func (c *Channel) BlockReplyEnabled() *bool { return c.config.BlockReply } +// ChatBehaviorConfig returns the per-channel chat_behavior override. +func (c *Channel) ChatBehaviorConfig() *config.ChatBehaviorConfig { return c.config.ChatBehavior } + // Stop gracefully shuts down the WhatsApp channel. func (c *Channel) Stop(_ context.Context) error { slog.Info("stopping whatsapp channel") diff --git a/internal/channels/zalo/factory.go b/internal/channels/zalo/factory.go index 661afd85..7de76c96 100644 --- a/internal/channels/zalo/factory.go +++ b/internal/channels/zalo/factory.go @@ -18,11 +18,12 @@ type zaloCreds struct { // zaloInstanceConfig maps the non-secret config JSONB from the channel_instances table. type zaloInstanceConfig struct { - DMPolicy string `json:"dm_policy,omitempty"` - WebhookURL string `json:"webhook_url,omitempty"` - MediaMaxMB int `json:"media_max_mb,omitempty"` - AllowFrom []string `json:"allow_from,omitempty"` - BlockReply *bool `json:"block_reply,omitempty"` + DMPolicy string `json:"dm_policy,omitempty"` + WebhookURL string `json:"webhook_url,omitempty"` + MediaMaxMB int `json:"media_max_mb,omitempty"` + AllowFrom []string `json:"allow_from,omitempty"` + BlockReply *bool `json:"block_reply,omitempty"` + ChatBehavior *config.ChatBehaviorConfig `json:"chat_behavior,omitempty"` } // Factory creates a Zalo OA channel from DB instance data. @@ -55,6 +56,7 @@ func Factory(name string, creds json.RawMessage, cfg json.RawMessage, WebhookSecret: c.WebhookSecret, MediaMaxMB: ic.MediaMaxMB, BlockReply: ic.BlockReply, + ChatBehavior: ic.ChatBehavior, } ch, err := New(zCfg, msgBus, pairingSvc) diff --git a/internal/channels/zalo/personal/channel.go b/internal/channels/zalo/personal/channel.go index c72166ac..9d71edf7 100644 --- a/internal/channels/zalo/personal/channel.go +++ b/internal/channels/zalo/personal/channel.go @@ -71,6 +71,9 @@ func New(cfg config.ZaloPersonalConfig, msgBus *bus.MessageBus, pairingSvc store // BlockReplyEnabled returns the per-channel block_reply override (nil = inherit gateway default). func (c *Channel) BlockReplyEnabled() *bool { return c.config.BlockReply } +// ChatBehaviorConfig returns the per-channel chat_behavior override. +func (c *Channel) ChatBehaviorConfig() *config.ChatBehaviorConfig { return c.config.ChatBehavior } + // session returns the current session snapshot (thread-safe). func (c *Channel) session() *protocol.Session { c.mu.RLock() diff --git a/internal/channels/zalo/personal/factory.go b/internal/channels/zalo/personal/factory.go index 85bcec6f..1d5ae083 100644 --- a/internal/channels/zalo/personal/factory.go +++ b/internal/channels/zalo/personal/factory.go @@ -13,20 +13,21 @@ import ( // zaloCreds maps the credentials JSON from the channel_instances table. type zaloCreds struct { - IMEI string `json:"imei"` + IMEI string `json:"imei"` Cookie *protocol.CookieUnion `json:"cookie"` - UserAgent string `json:"userAgent"` - Language *string `json:"language,omitempty"` + UserAgent string `json:"userAgent"` + Language *string `json:"language,omitempty"` } // zaloInstanceConfig maps the config JSONB from the channel_instances table. type zaloInstanceConfig struct { - DMPolicy string `json:"dm_policy,omitempty"` - GroupPolicy string `json:"group_policy,omitempty"` - RequireMention *bool `json:"require_mention,omitempty"` - HistoryLimit int `json:"history_limit,omitempty"` - AllowFrom []string `json:"allow_from,omitempty"` - BlockReply *bool `json:"block_reply,omitempty"` + DMPolicy string `json:"dm_policy,omitempty"` + GroupPolicy string `json:"group_policy,omitempty"` + RequireMention *bool `json:"require_mention,omitempty"` + HistoryLimit int `json:"history_limit,omitempty"` + AllowFrom []string `json:"allow_from,omitempty"` + BlockReply *bool `json:"block_reply,omitempty"` + ChatBehavior *config.ChatBehaviorConfig `json:"chat_behavior,omitempty"` } // Factory creates a Zalo Personal channel from DB instance data. @@ -62,6 +63,7 @@ func Factory(name string, creds json.RawMessage, cfg json.RawMessage, RequireMention: ic.RequireMention, HistoryLimit: ic.HistoryLimit, BlockReply: ic.BlockReply, + ChatBehavior: ic.ChatBehavior, } ch, err := New(zaloCfg, msgBus, pairingSvc, nil) @@ -112,6 +114,7 @@ func FactoryWithPendingStore(pendingStore store.PendingMessageStore) channels.Ch RequireMention: ic.RequireMention, HistoryLimit: ic.HistoryLimit, BlockReply: ic.BlockReply, + ChatBehavior: ic.ChatBehavior, } ch, err := New(zaloCfg, msgBus, pairingSvc, pendingStore) diff --git a/internal/channels/zalo/zalo.go b/internal/channels/zalo/zalo.go index 5c9fd03d..3368e394 100644 --- a/internal/channels/zalo/zalo.go +++ b/internal/channels/zalo/zalo.go @@ -39,13 +39,14 @@ var apiBase = "https://bot-api.zaloplatforms.com" // Channel connects to the Zalo OA Bot API. type Channel struct { *channels.BaseChannel - token string - dmPolicy string - mediaMaxMB int - blockReply *bool - stopCh chan struct{} - client *http.Client - pollClient *http.Client + token string + dmPolicy string + mediaMaxMB int + blockReply *bool + chatBehavior *config.ChatBehaviorConfig + stopCh chan struct{} + client *http.Client + pollClient *http.Client // pairingService, pairingDebounce are inherited from channels.BaseChannel. } @@ -69,14 +70,15 @@ func New(cfg config.ZaloConfig, msgBus *bus.MessageBus, pairingSvc store.Pairing } ch := &Channel{ - BaseChannel: base, - token: cfg.Token, - dmPolicy: dmPolicy, - mediaMaxMB: mediaMax, - blockReply: cfg.BlockReply, - stopCh: make(chan struct{}), - client: &http.Client{Timeout: 60 * time.Second}, - pollClient: &http.Client{Timeout: 0}, + BaseChannel: base, + token: cfg.Token, + dmPolicy: dmPolicy, + mediaMaxMB: mediaMax, + blockReply: cfg.BlockReply, + chatBehavior: cfg.ChatBehavior, + stopCh: make(chan struct{}), + client: &http.Client{Timeout: 60 * time.Second}, + pollClient: &http.Client{Timeout: 0}, } ch.SetPairingService(pairingSvc) return ch, nil @@ -85,6 +87,9 @@ func New(cfg config.ZaloConfig, msgBus *bus.MessageBus, pairingSvc store.Pairing // BlockReplyEnabled returns the per-channel block_reply override (nil = inherit gateway default). func (c *Channel) BlockReplyEnabled() *bool { return c.blockReply } +// ChatBehaviorConfig returns the per-channel chat_behavior override. +func (c *Channel) ChatBehaviorConfig() *config.ChatBehaviorConfig { return c.chatBehavior } + // Start begins polling for Zalo updates. func (c *Channel) Start(ctx context.Context) error { slog.Info("starting zalo bot (polling mode)") diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index e3194a3f..96741e1c 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -1,5 +1,28 @@ package config +// ChatBehaviorConfig controls optional human-like channel delivery behavior. +// Pointer fields allow per-channel overrides to inherit gateway defaults. +type ChatBehaviorConfig struct { + Enabled *bool `json:"enabled,omitempty"` + QuickAck *QuickAckConfig `json:"quick_ack,omitempty"` + FinalSplit *FinalSplitConfig `json:"final_split,omitempty"` +} + +// QuickAckConfig controls one short acknowledgement before longer non-streaming runs. +type QuickAckConfig struct { + Enabled *bool `json:"enabled,omitempty"` + MinDelayMs *int `json:"min_delay_ms,omitempty"` + Templates []string `json:"templates,omitempty"` +} + +// FinalSplitConfig controls semantic splitting of final channel replies. +type FinalSplitConfig struct { + Enabled *bool `json:"enabled,omitempty"` + MinChars *int `json:"min_chars,omitempty"` + MaxMessages *int `json:"max_messages,omitempty"` + DelayMs *int `json:"delay_ms,omitempty"` +} + // PendingCompactionConfig configures LLM-based compaction of pending group messages. // 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. @@ -42,6 +65,7 @@ type TelegramConfig struct { MediaMaxBytes int64 `json:"media_max_bytes,omitempty"` // max media download size in bytes (default 20MB) LinkPreview *bool `json:"link_preview,omitempty"` // enable URL previews in messages (default true) BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) + ChatBehavior *ChatBehaviorConfig `json:"chat_behavior,omitempty"` // override gateway chat behavior (nil = inherit) ForceIPv4 bool `json:"force_ipv4,omitempty"` // force IPv4 for all Telegram API requests (use when IPv6 routing is broken) // Optional STT (Speech-to-Text) pipeline for voice/audio inbound messages. @@ -103,6 +127,7 @@ type DiscordConfig struct { RequireMention *bool `json:"require_mention,omitempty"` // require @bot mention in groups (default true) HistoryLimit int `json:"history_limit,omitempty"` // max pending group messages for context (default 50, 0=disabled) BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) + ChatBehavior *ChatBehaviorConfig `json:"chat_behavior,omitempty"` // override gateway chat behavior (nil = inherit) MediaMaxBytes int64 `json:"media_max_bytes,omitempty"` // max media download size (default 25MB) STTProxyURL string `json:"stt_proxy_url,omitempty"` STTAPIKey string `json:"stt_api_key,omitempty"` @@ -126,6 +151,7 @@ type SlackConfig struct { NativeStream *bool `json:"native_stream,omitempty"` // use Slack ChatStreamer API if available (default false) ReactionLevel string `json:"reaction_level,omitempty"` // "off" (default), "minimal", "full" BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) + ChatBehavior *ChatBehaviorConfig `json:"chat_behavior,omitempty"` // override gateway chat behavior (nil = inherit) DebounceDelay *int `json:"debounce_delay,omitempty"` // ms delay before dispatching rapid messages (default 300, 0=disabled) ThreadTTL *int `json:"thread_ttl,omitempty"` // hours before thread participation expires (default 24, 0=disabled — always require @mention) MediaMaxBytes int64 `json:"media_max_bytes,omitempty"` // max file download size in bytes (default 20MB) @@ -140,6 +166,7 @@ type WhatsAppConfig struct { RequireMention *bool `json:"require_mention,omitempty"` // only respond in groups when bot is @mentioned (default false) HistoryLimit int `json:"history_limit,omitempty"` // max pending group messages for context (default 200, 0=disabled) BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) + ChatBehavior *ChatBehaviorConfig `json:"chat_behavior,omitempty"` // override gateway chat behavior (nil = inherit) } type ZaloConfig struct { @@ -149,8 +176,9 @@ type ZaloConfig struct { DMPolicy string `json:"dm_policy,omitempty"` // "pairing" (default), "allowlist", "open", "disabled" WebhookURL string `json:"webhook_url,omitempty"` WebhookSecret string `json:"webhook_secret,omitempty"` - MediaMaxMB int `json:"media_max_mb,omitempty"` // default 5 - BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) + MediaMaxMB int `json:"media_max_mb,omitempty"` // default 5 + BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) + ChatBehavior *ChatBehaviorConfig `json:"chat_behavior,omitempty"` // override gateway chat behavior (nil = inherit) } type ZaloPersonalConfig struct { @@ -162,6 +190,7 @@ type ZaloPersonalConfig struct { HistoryLimit int `json:"history_limit,omitempty"` // max pending group messages for context (default 50, 0=disabled) CredentialsPath string `json:"credentials_path,omitempty"` // path to saved cookies JSON BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) + ChatBehavior *ChatBehaviorConfig `json:"chat_behavior,omitempty"` // override gateway chat behavior (nil = inherit) } type FeishuConfig struct { @@ -186,7 +215,8 @@ type FeishuConfig struct { Streaming *bool `json:"streaming,omitempty"` // default true ReactionLevel string `json:"reaction_level,omitempty"` // "off" (default), "minimal", "full" — typing emoji reactions HistoryLimit int `json:"history_limit,omitempty"` - BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) + BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) + ChatBehavior *ChatBehaviorConfig `json:"chat_behavior,omitempty"` // override gateway chat behavior (nil = inherit) STTProxyURL string `json:"stt_proxy_url,omitempty"` STTAPIKey string `json:"stt_api_key,omitempty"` STTTenantID string `json:"stt_tenant_id,omitempty"` @@ -362,21 +392,22 @@ type QuotaConfig struct { // GatewayConfig controls the gateway server. type GatewayConfig struct { - Host string `json:"host"` - Port int `json:"port"` - Token string `json:"token,omitempty"` // bearer token for WS/HTTP auth - OwnerIDs []string `json:"owner_ids,omitempty"` // sender IDs considered "owner" - AllowedOrigins []string `json:"allowed_origins,omitempty"` // WebSocket CORS whitelist (empty = allow all) - MaxMessageChars int `json:"max_message_chars,omitempty"` // max user message characters (default 32000) - RateLimitRPM int `json:"rate_limit_rpm,omitempty"` // rate limit: requests per minute per user (default 20, 0 = disabled) - InjectionAction string `json:"injection_action,omitempty"` // prompt injection action: "log", "warn" (default), "block", "off" - InboundDebounceMs int `json:"inbound_debounce_ms,omitempty"` // silence-window in ms that merges rapid channel/Web Chat messages from the same sender/session; 0 disables for text but media-bearing messages still honor a built-in media floor so multi-attachment bursts (#63) coalesce into a single agent run. Agents may override via per-agent agent_config.inbound_debounce_ms. - Quota *QuotaConfig `json:"quota,omitempty"` // per-user/group request quotas - BlockReply *bool `json:"block_reply,omitempty"` // deliver intermediate text during tool iterations (default false) - ToolStatus *bool `json:"tool_status,omitempty"` // show tool name in streaming preview during tool execution (default true) - TaskRecoveryIntervalSec int `json:"task_recovery_interval_sec,omitempty"` // team task recovery ticker interval in seconds (default 300 = 5min) - BackgroundProvider string `json:"background_provider,omitempty"` // LLM provider for background workers (vault enrichment, consolidation) - BackgroundModel string `json:"background_model,omitempty"` // LLM model for background workers + Host string `json:"host"` + Port int `json:"port"` + Token string `json:"token,omitempty"` // bearer token for WS/HTTP auth + OwnerIDs []string `json:"owner_ids,omitempty"` // sender IDs considered "owner" + AllowedOrigins []string `json:"allowed_origins,omitempty"` // WebSocket CORS whitelist (empty = allow all) + MaxMessageChars int `json:"max_message_chars,omitempty"` // max user message characters (default 32000) + RateLimitRPM int `json:"rate_limit_rpm,omitempty"` // rate limit: requests per minute per user (default 20, 0 = disabled) + InjectionAction string `json:"injection_action,omitempty"` // prompt injection action: "log", "warn" (default), "block", "off" + InboundDebounceMs int `json:"inbound_debounce_ms,omitempty"` // silence-window in ms that merges rapid channel/Web Chat messages from the same sender/session; 0 disables for text but media-bearing messages still honor a built-in media floor so multi-attachment bursts (#63) coalesce into a single agent run. Agents may override via per-agent agent_config.inbound_debounce_ms. + Quota *QuotaConfig `json:"quota,omitempty"` // per-user/group request quotas + BlockReply *bool `json:"block_reply,omitempty"` // deliver intermediate text during tool iterations (default false) + ChatBehavior *ChatBehaviorConfig `json:"chat_behavior,omitempty"` // human-like channel delivery behavior (default disabled) + ToolStatus *bool `json:"tool_status,omitempty"` // show tool name in streaming preview during tool execution (default true) + TaskRecoveryIntervalSec int `json:"task_recovery_interval_sec,omitempty"` // team task recovery ticker interval in seconds (default 300 = 5min) + BackgroundProvider string `json:"background_provider,omitempty"` // LLM provider for background workers (vault enrichment, consolidation) + BackgroundModel string `json:"background_model,omitempty"` // LLM model for background workers } // ToolsConfig controls tool availability, policy, and web search. diff --git a/internal/gateway/methods/chat_behavior.go b/internal/gateway/methods/chat_behavior.go new file mode 100644 index 00000000..f87ec830 --- /dev/null +++ b/internal/gateway/methods/chat_behavior.go @@ -0,0 +1,85 @@ +package methods + +import ( + "context" + "encoding/json" + + "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/gateway" + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// ChatBehaviorMethods handles dashboard previews for channel delivery behavior. +type ChatBehaviorMethods struct { + cfg *config.Config + channelMgr *channels.Manager +} + +func NewChatBehaviorMethods(cfg *config.Config, channelMgr *channels.Manager) *ChatBehaviorMethods { + return &ChatBehaviorMethods{cfg: cfg, channelMgr: channelMgr} +} + +func (m *ChatBehaviorMethods) Register(router *gateway.MethodRouter) { + router.Register(protocol.MethodChatBehaviorPreview, m.requireMasterScope(m.requireOwner(m.handlePreview))) +} + +func (m *ChatBehaviorMethods) requireOwner(next gateway.MethodHandler) gateway.MethodHandler { + return func(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if !client.IsOwner() { + locale := store.LocaleFromContext(ctx) + client.SendResponse(protocol.NewErrorResponse( + req.ID, + protocol.ErrUnauthorized, + i18n.T(locale, i18n.MsgPermissionDenied, req.Method), + )) + return + } + next(ctx, client, req) + } +} + +func (m *ChatBehaviorMethods) requireMasterScope(next gateway.MethodHandler) gateway.MethodHandler { + return func(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if !store.IsMasterScope(ctx) { + locale := store.LocaleFromContext(ctx) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgConfigMasterScopeOnly))) + return + } + next(ctx, client, req) + } +} + +func (m *ChatBehaviorMethods) handlePreview(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + var params struct { + Channel string `json:"channel"` + Content string `json:"content"` + IsStreaming bool `json:"isStreaming"` + HasToolCalls bool `json:"hasToolCalls"` + Config *config.ChatBehaviorConfig `json:"config"` + } + if req.Params != nil { + _ = json.Unmarshal(req.Params, ¶ms) + } + + var resolved channels.ResolvedChatBehavior + if params.Config != nil { + resolved = channels.ResolveChatBehavior(params.Config, nil) + } else if m.channelMgr != nil { + resolved = m.channelMgr.ResolveChatBehavior(params.Channel, m.cfg.Gateway.ChatBehavior) + } else { + resolved = channels.ResolveChatBehavior(m.cfg.Gateway.ChatBehavior, nil) + } + preview := channels.ChatBehaviorPreview{ + Resolved: resolved, + Split: channels.SplitPreview{ + Parts: channels.SplitFinalMessages(params.Content, resolved.FinalSplit), + }, + } + if channels.ShouldSendQuickAck(resolved, params.IsStreaming) { + preview.Ack = channels.AckPreview{ShouldSend: true, Content: resolved.QuickAck.Templates[0]} + } + client.SendResponse(protocol.NewOKResponse(req.ID, preview)) +} diff --git a/internal/permissions/policy.go b/internal/permissions/policy.go index 1e651c88..67042578 100644 --- a/internal/permissions/policy.go +++ b/internal/permissions/policy.go @@ -211,6 +211,7 @@ func isAdminMethod(method string) bool { protocol.MethodConfigPatch, protocol.MethodConfigSchema, protocol.MethodConfigDefaults, + protocol.MethodChatBehaviorPreview, protocol.MethodConfigPermissionsList, protocol.MethodConfigPermissionsCheck, protocol.MethodConfigPermissionsGrant, diff --git a/pkg/protocol/methods.go b/pkg/protocol/methods.go index 6d47312c..73ad36f2 100644 --- a/pkg/protocol/methods.go +++ b/pkg/protocol/methods.go @@ -27,11 +27,12 @@ const ( MethodAgentsFileSet = "agents.files.set" // Config - MethodConfigGet = "config.get" - MethodConfigApply = "config.apply" - MethodConfigPatch = "config.patch" - MethodConfigSchema = "config.schema" - MethodConfigDefaults = "config.defaults" + MethodConfigGet = "config.get" + MethodConfigApply = "config.apply" + MethodConfigPatch = "config.patch" + MethodConfigSchema = "config.schema" + MethodConfigDefaults = "config.defaults" + MethodChatBehaviorPreview = "chat_behavior.preview" // Sessions MethodSessionsList = "sessions.list" @@ -232,8 +233,8 @@ const ( // Bitrix24 portal management (self-service onboarding for the bitrix24 channel). // See plans/260513-1648-bitrix24-portal-self-service-ux/phase-02-backend-rpc-portals.md. const ( - MethodBitrixPortalsList = "bitrix.portals.list" - MethodBitrixPortalsCreate = "bitrix.portals.create" - MethodBitrixPortalsGetInstallURL = "bitrix.portals.get_install_url" - MethodBitrixPortalsDelete = "bitrix.portals.delete" + MethodBitrixPortalsList = "bitrix.portals.list" + MethodBitrixPortalsCreate = "bitrix.portals.create" + MethodBitrixPortalsGetInstallURL = "bitrix.portals.get_install_url" + MethodBitrixPortalsDelete = "bitrix.portals.delete" ) diff --git a/plans/260529-1210-human-like-channel-chat-behavior/phase-01-contract-and-splitter-tests.md b/plans/260529-1210-human-like-channel-chat-behavior/phase-01-contract-and-splitter-tests.md new file mode 100644 index 00000000..1a95a6a2 --- /dev/null +++ b/plans/260529-1210-human-like-channel-chat-behavior/phase-01-contract-and-splitter-tests.md @@ -0,0 +1,58 @@ +--- +phase: 1 +title: "Contract and Splitter Tests" +status: pending +priority: P1 +effort: "0.5d" +dependencies: [] +--- + +# Phase 1: Contract and Splitter Tests + +## Overview + +Define the behavior contract before runtime changes. Add tests for safe final-message splitting and config structures, then implement only the minimal splitter helpers needed to make those tests pass. + +## Requirements + +- Functional: split final content only when enabled, over minimum length, and safe to split. +- Functional: return one message when splitting would damage formatting. +- Non-functional: deterministic output, no platform-specific network behavior, no sleeps in unit tests. + +## Architecture + +Create a small channel-level behavior file under `internal/channels/` so channel delivery can reuse it without importing agent or gateway internals. + +Proposed types: +- `ChatBehaviorConfig` +- `QuickAckConfig` +- `FinalSplitConfig` +- `ResolvedChatBehavior` +- `SplitFinalMessage(content string, cfg FinalSplitConfig) []string` + +The splitter may reuse `ChunkMarkdown` for max-length mechanics, but #67 splitting is semantic: max N human-like messages, not platform hard-limit chunks. + +## Related Code Files + +- Modify: `internal/channels/chunking.go` or create adjacent `internal/channels/chat_behavior.go` +- Modify/Create tests: `internal/channels/chat_behavior_test.go` +- Modify: `internal/config/config_channels.go` + +## Implementation Steps + +1. Add tests for disabled config, short content, max message cap, min chars. +2. Add tests for safe structures: fenced code blocks, markdown tables, bullet/numbered lists, block quotes, JSON/YAML/XML-looking blocks, URLs. +3. Implement conservative splitter: split on double-newline paragraphs only when all resulting parts are safe and within max count. +4. Add fallback: return original content unchanged on any unsafe structure or cap breach. +5. Add config structs in `internal/config` with JSON tags but no runtime wiring yet. +6. Run focused tests. + +## Success Criteria + +- [ ] Splitter tests prove safe split and no-split edge cases. +- [ ] Config structs compile in both PG and sqliteonly builds. +- [ ] No existing channel hard-limit chunking behavior changes. + +## Risk Assessment + +Risk: existing `ChunkMarkdown` already force-splits code for hard platform limits, while #67 wants semantic split. Mitigation: keep semantic splitter separate and use hard chunking only after semantic split if needed by adapters. diff --git a/plans/260529-1210-human-like-channel-chat-behavior/phase-02-config-resolution-and-preview-api.md b/plans/260529-1210-human-like-channel-chat-behavior/phase-02-config-resolution-and-preview-api.md new file mode 100644 index 00000000..e0cc1907 --- /dev/null +++ b/plans/260529-1210-human-like-channel-chat-behavior/phase-02-config-resolution-and-preview-api.md @@ -0,0 +1,59 @@ +--- +phase: 2 +title: "Config Resolution and Preview API" +status: pending +priority: P1 +effort: "0.5d" +dependencies: [1] +--- + +# Phase 2: Config Resolution and Preview API + +## Overview + +Wire global gateway config plus per-channel override resolution, then expose a no-side-effect preview API for ack and final split behavior. + +## Requirements + +- Functional: global config defaults apply when channel override omitted. +- Functional: per-channel override can enable/disable ack and split independently. +- Functional: preview API returns resolved config, ack decision, and split parts without dispatching. +- Non-functional: master-scope/owner gate follows existing config mutation pattern. + +## Architecture + +Use config structs with pointer fields for inheritance. Add resolver in `internal/channels` that takes gateway config, channel name/type config, and run traits. + +Preview uses a new read-only WS method: +- method name: `chat_behavior.preview` +- params: channel name/type, `isStreaming`, `isGroup`, `content`, optional `hasToolCalls`, optional `estimatedLongWork` +- response: `resolved`, `ack`, `split` + +The method is master-scoped and owner-gated because it exposes resolved config details. It never dispatches outbound messages and never mutates config. + +## Related Code Files + +- Modify: `internal/config/config_channels.go` +- Modify: `internal/config/config_system.go` +- Modify/Create: `internal/channels/chat_behavior.go` +- Modify/Create: `internal/gateway/methods/chat_behavior.go` +- Modify: `pkg/protocol/methods.go` or method constants file +- Tests: `internal/channels/chat_behavior_test.go`, `internal/gateway/methods/*chat_behavior*_test.go` + +## Implementation Steps + +1. Add resolver tests: nil global, disabled global, enabled global, channel override true/false, partial override inheritance. +2. Add preview method tests: no dispatch, validated payload, deterministic split output. +3. Register `chat_behavior.preview` as a read-only owner/master-scope method. +4. Add i18n errors only if user-facing backend errors are introduced. +5. Run focused method/config tests. + +## Success Criteria + +- [ ] Resolver is fully table-tested. +- [ ] Preview API returns safe output and does not touch message bus. +- [ ] Config patch compatibility remains unchanged. + +## Risk Assessment + +Risk: config shape becomes hard to evolve. Mitigation: nest under `chat_behavior`, use pointer fields for overrides, and avoid per-agent storage in MVP. diff --git a/plans/260529-1210-human-like-channel-chat-behavior/phase-03-runtime-acknowledgement-and-final-splitting.md b/plans/260529-1210-human-like-channel-chat-behavior/phase-03-runtime-acknowledgement-and-final-splitting.md new file mode 100644 index 00000000..b7985f59 --- /dev/null +++ b/plans/260529-1210-human-like-channel-chat-behavior/phase-03-runtime-acknowledgement-and-final-splitting.md @@ -0,0 +1,61 @@ +--- +phase: 3 +title: "Runtime Acknowledgement and Final Splitting" +status: pending +priority: P1 +effort: "1d" +dependencies: [1, 2] +--- + +# Phase 3: Runtime Acknowledgement and Final Splitting + +## Overview + +Attach resolved chat behavior to `RunContext`, send conservative quick acknowledgements for non-streaming channel runs, and split final assistant delivery after final content sanitization. + +## Requirements + +- Functional: ack only for non-streaming channel delivery and never for Web UI runs. +- Functional: ack skips when config disabled, run completes before threshold, or a `block.reply` already delivered. +- Functional: final split preserves existing `block.reply` and final dedup behavior. +- Non-functional: no goroutine leaks, no sleeps in tests, cancellable timers. + +## Architecture + +Extend `RunContext` with a resolved behavior struct and ack state. Use injected clock/timer helpers where needed for tests. + +Event handling path: +- `run.started`: begin ack timer only if non-streaming and enabled. +- `block.reply`: mark intermediate content delivered; cancel pending ack. +- `run.completed`: cancel pending ack, split final content if enabled, publish outbound parts. +- `run.failed`/`run.cancelled`: cancel pending ack. + +Final split happens in the channel manager outbound forwarding path, before `bus.PublishOutbound`. Platform adapters still enforce hard message limits afterward. + +## Related Code Files + +- Modify: `internal/channels/channel.go` +- Modify: `internal/channels/runs.go` +- Modify: `internal/channels/events.go` +- Modify: channel registration call sites that pass `blockReply`/`toolStatus` +- Tests: `internal/channels/events_test.go`, `internal/channels/runs_test.go` + +## Implementation Steps + +1. Add tests for ack gate using fake timer/clock. +2. Add tests for no ack on streaming runs, disabled behavior, `block.reply`, quick completion, run failure/cancel. +3. Add tests for final split publish count/order and metadata preservation. +4. Implement `RunContext` behavior state and cancellation. +5. Route final content through semantic splitter, then publish ordered outbound messages with configured delay. +6. Ensure existing streaming and reaction handling unchanged. + +## Success Criteria + +- [ ] Ack publish is deterministic under tests with no real-time sleeps. +- [ ] Final split preserves routing metadata and tenant ID. +- [ ] Existing `block.reply` tests still pass. +- [ ] No archive/timeline storage touched. + +## Risk Assessment + +Risk: ack can become spam. Mitigation: default disabled/conservative, non-streaming only, cancel on `block.reply`, one ack max per run. diff --git a/plans/260529-1210-human-like-channel-chat-behavior/phase-04-dashboard-controls-and-channel-overrides.md b/plans/260529-1210-human-like-channel-chat-behavior/phase-04-dashboard-controls-and-channel-overrides.md new file mode 100644 index 00000000..80d4740b --- /dev/null +++ b/plans/260529-1210-human-like-channel-chat-behavior/phase-04-dashboard-controls-and-channel-overrides.md @@ -0,0 +1,69 @@ +--- +phase: 4 +title: "Dashboard Controls and Channel Overrides" +status: pending +priority: P2 +effort: "1d" +dependencies: [2, 3] +--- + +# Phase 4: Dashboard Controls and Channel Overrides + +## Overview + +Expose global gateway controls in the Behavior config tab and per-channel overrides in existing channel forms. + +## Requirements + +- Functional: admins can enable/disable behavior, ack, and final splitting globally. +- Functional: admins can set max split messages, min chars, delay, ack threshold, and templates. +- Functional: each channel can inherit, enable, or disable the behavior fields. +- Non-functional: use existing React config patterns and i18n in en/vi/zh. + +## Architecture + +Extend existing config UI instead of adding a new page. + +Global: +- `ui/web/src/pages/config/sections/behavior-section.tsx` +- new focused sub-card if file growth risks >200 lines. + +Per-channel: +- `ui/web/src/pages/channels/channel-schemas.ts` +- add `chat_behavior.*` controls or grouped advanced fields if the form helper supports nested paths. + +Preview: +- add a small dashboard preview panel that calls `chat_behavior.preview` and displays ack decision plus split message count/content. + +## Related Code Files + +- Modify: `ui/web/src/pages/config/sections/behavior-section.tsx` +- Modify/Create: `ui/web/src/pages/config/sections/behavior-chat-card.tsx` +- Modify/Create: `ui/web/src/pages/config/sections/behavior-chat-preview.tsx` +- Modify: `ui/web/src/pages/channels/channel-schemas.ts` +- Modify: `ui/web/src/i18n/locales/en/config.json` +- Modify: `ui/web/src/i18n/locales/vi/config.json` +- Modify: `ui/web/src/i18n/locales/zh/config.json` +- Tests if existing UI test pattern exists for config sections. + +## Implementation Steps + +1. Add/extend frontend types for config values. +2. Add global behavior card with existing switch/input components. +3. Add dashboard preview panel wired to `chat_behavior.preview`. +4. Add per-channel override schema entries under Advanced behavior. +5. Add i18n keys for all labels/help text. +6. Keep components under 200 LOC or split into focused modules. +7. Run `pnpm test -- --run` and `pnpm build`. + +## Success Criteria + +- [ ] Global config patch writes the `gateway.chat_behavior` shape. +- [ ] Dashboard preview calls `chat_behavior.preview` and displays ack/split results without sending messages. +- [ ] Channel forms can write per-channel `chat_behavior` override. +- [ ] No hardcoded new user-facing English strings in JSX. +- [ ] Mobile controls use existing accessible input/select patterns. + +## Risk Assessment + +Risk: nested channel schema support may be limited. Mitigation: if nested paths are not supported, add a focused custom advanced panel rather than flattening backend config names. diff --git a/plans/260529-1210-human-like-channel-chat-behavior/phase-05-validation-and-handoff.md b/plans/260529-1210-human-like-channel-chat-behavior/phase-05-validation-and-handoff.md new file mode 100644 index 00000000..ac68b8b7 --- /dev/null +++ b/plans/260529-1210-human-like-channel-chat-behavior/phase-05-validation-and-handoff.md @@ -0,0 +1,60 @@ +--- +phase: 5 +title: "Validation and Handoff" +status: pending +priority: P1 +effort: "0.5d" +dependencies: [1, 2, 3, 4] +--- + +# Phase 5: Validation and Handoff + +## Overview + +Run focused and broad validation, update docs/changelog if warranted, then prepare beta PR and issue handoff. + +## Requirements + +- Functional: all acceptance criteria proven by tests or build artifacts. +- Non-functional: no syntax/build errors in Go PG, Go sqliteonly, or web UI. +- Non-functional: no public contract break without explicit callout. + +## Architecture + +Validation follows repo checklist, with focused package tests first and broader compile gates after. + +Docs impact expected: +- `docs/project-changelog.md` entry for feature. +- `docs/05-channels-messaging.md` update only if runtime behavior changes channel semantics enough to document. + +## Related Code Files + +- Modify: `docs/project-changelog.md` if implementation lands. +- Maybe modify: `docs/05-channels-messaging.md`. +- GitHub issue: `digitopvn/goclaw#67`. + +## Implementation Steps + +1. Run focused backend tests: + `go test ./internal/channels ./internal/config ./internal/gateway/methods` +2. Run sqliteonly focused tests: + `go test -tags sqliteonly ./internal/channels ./internal/config ./internal/gateway/methods` +3. Run compile/static gates: + `go build ./...` + `go build -tags sqliteonly ./...` + `go vet ./...` +4. Run web gates: + `cd ui/web && pnpm test -- --run` + `cd ui/web && pnpm build` +5. Run `git diff --check`. +6. Update issue #67 with implementation summary and validation. + +## Success Criteria + +- [ ] All focused and broad validation commands pass or documented blocker exists. +- [ ] PR targets `dev` for beta shipping. +- [ ] Issue #67 has final comment and `ready to ship be` label after review/fix loop. + +## Risk Assessment + +Risk: full integration race tests may require external DB. Mitigation: run repo-standard compile/unit gates locally; only run integration DB tests if environment available and relevant. diff --git a/plans/260529-1210-human-like-channel-chat-behavior/plan.md b/plans/260529-1210-human-like-channel-chat-behavior/plan.md new file mode 100644 index 00000000..a25ad0a3 --- /dev/null +++ b/plans/260529-1210-human-like-channel-chat-behavior/plan.md @@ -0,0 +1,79 @@ +--- +title: "Human-Like Channel Chat Behavior MVP" +description: "TDD plan for digitopvn/goclaw#67: quick acknowledgement and safe final multi-message splitting for non-streaming channel delivery, with global gateway plus per-channel config only." +status: pending +priority: P2 +branch: "codex/issue-67-human-like-chat-behavior" +tags: [issue-67, channels, chat-behavior, tdd, web-ui] +blockedBy: [] +blocks: [] +created: "2026-05-29T05:10:15.334Z" +createdBy: "ck:plan" +source: skill +--- + +# Human-Like Channel Chat Behavior MVP + +## Overview + +Implement the approved MVP for issue #67. + +Scope: +- runtime config for human-like channel chat behavior +- quick acknowledgement before longer/tool work +- safe final multi-message splitting for channel delivery +- dashboard controls and preview API +- global gateway defaults plus per-channel overrides only + +Explicitly out of scope: +- issue #76 archive/timeline storage or renderer +- per-agent overrides +- Web UI acknowledgement delivery +- streaming channel acknowledgement delivery +- public share/export surfaces + +Recommended architecture: resolve behavior config into `RunContext` at run registration, emit ack from channel event handling for non-streaming runs, split final assistant content in the channel outbound path after final content sanitization. + +## Phases + +| Phase | Name | Status | +|-------|------|--------| +| 1 | [Contract and Splitter Tests](./phase-01-contract-and-splitter-tests.md) | Pending | +| 2 | [Config Resolution and Preview API](./phase-02-config-resolution-and-preview-api.md) | Pending | +| 3 | [Runtime Acknowledgement and Final Splitting](./phase-03-runtime-acknowledgement-and-final-splitting.md) | Pending | +| 4 | [Dashboard Controls and Channel Overrides](./phase-04-dashboard-controls-and-channel-overrides.md) | Pending | +| 5 | [Validation and Handoff](./phase-05-validation-and-handoff.md) | Pending | + +## Dependencies + +- GitHub issue: `digitopvn/goclaw#67` +- Related non-overlap issue: `digitopvn/goclaw#76` +- Brainstorm report: `../reports/260529-1210-issue-67-human-like-chat-behavior-brainstorm.md` +- Existing event/run surfaces: `internal/channels/runs.go`, `internal/channels/events.go`, `internal/agent/loop_run.go` +- Existing block reply path: `internal/pipeline/think_stage.go`, `internal/agent/loop_pipeline_adapter.go` +- Existing channel chunking: `internal/channels/chunking.go` +- Existing config UI: `ui/web/src/pages/config/sections/behavior-section.tsx`, `ui/web/src/pages/channels/channel-schemas.ts` + +## Acceptance Criteria + +- [ ] Ack sends only for non-streaming channel runs when enabled and gate passes. +- [ ] Ack does not send for Web UI, streaming channel runs, disabled config, silent replies, or quick-complete runs. +- [ ] Final split sends at most configured max messages, with configured delay between extra messages. +- [ ] Splitter preserves fenced code, quotes, tables, lists, structured JSON/YAML/XML, links, and short messages. +- [ ] Global gateway and per-channel config override resolution is deterministic and tested. +- [ ] Preview API returns ack decision and split parts without sending messages. +- [ ] Dashboard exposes global controls and per-channel overrides with i18n in en/vi/zh. +- [ ] No schema/timeline persistence added; no issue #76 file overlap except references. + +## Validation Commands + +```bash +go test ./internal/channels ./internal/config ./internal/gateway/methods +go test -tags sqliteonly ./internal/channels ./internal/config ./internal/gateway/methods +go build ./... +go build -tags sqliteonly ./... +go vet ./... +cd ui/web && pnpm test -- --run +cd ui/web && pnpm build +git diff --check +``` diff --git a/plans/260529-1210-human-like-channel-chat-behavior/reports/260529-1210-plan-red-team.md b/plans/260529-1210-human-like-channel-chat-behavior/reports/260529-1210-plan-red-team.md new file mode 100644 index 00000000..94724acc --- /dev/null +++ b/plans/260529-1210-human-like-channel-chat-behavior/reports/260529-1210-plan-red-team.md @@ -0,0 +1,33 @@ +--- +title: "Issue 67 Plan Red Team" +date: "2026-05-29" +status: passed +source: "ck:plan red-team" +--- + +# Issue 67 Plan Red Team + +## Summary + +Adversarial review found no blocker after tightening preview scope. + +## Findings + +| Severity | Finding | Resolution | +|---|---|---| +| Important | Preview API was described as candidate/optional. | Fixed: `chat_behavior.preview` is required and read-only. | +| Important | Dashboard preview could be skipped despite approved MVP wording. | Fixed: preview panel required in Phase 4. | +| Suggestion | Ack timing can be spammy if sent immediately on `run.started`. | Plan uses cancellable threshold timer and cancels on quick completion/block reply. | +| Suggestion | Splitter can damage markdown. | Phase 1 requires tests-first conservative no-split fallback. | +| Suggestion | #76 timeline overlap risk. | Explicit no archive/timeline persistence in plan and acceptance criteria. | + +## Whole-Plan Consistency Sweep + +- No stale optional preview language remains. +- No implementation phase adds per-agent overrides. +- No phase adds archive/timeline storage. +- Validation commands cover Go, sqliteonly, vet, web tests/build, and diff whitespace. + +## Unresolved Questions + +None. diff --git a/plans/260529-1210-human-like-channel-chat-behavior/reports/260529-1210-plan-validation.md b/plans/260529-1210-human-like-channel-chat-behavior/reports/260529-1210-plan-validation.md new file mode 100644 index 00000000..ac7fa7bb --- /dev/null +++ b/plans/260529-1210-human-like-channel-chat-behavior/reports/260529-1210-plan-validation.md @@ -0,0 +1,32 @@ +--- +title: "Issue 67 Plan Validation" +date: "2026-05-29" +status: passed +source: "ck:plan validate" +--- + +# Issue 67 Plan Validation + +## Summary + +Plan syntax valid. Requirements concrete after user approval. + +## Checks + +| Check | Result | +|---|---| +| `ck plan validate --strict` | Pass, 5 phases | +| Expected output concrete | Pass | +| Acceptance criteria concrete | Pass | +| Scope boundary explicit | Pass | +| #76 conflict avoided | Pass | +| TDD structure present | Pass | + +## Corrections Made + +- Made `chat_behavior.preview` a definite WS method, not a candidate. +- Required dashboard preview panel, not optional API-only preview. + +## Unresolved Questions + +None. diff --git a/plans/reports/260529-1210-issue-67-human-like-chat-behavior-brainstorm.md b/plans/reports/260529-1210-issue-67-human-like-chat-behavior-brainstorm.md new file mode 100644 index 00000000..39c99aba --- /dev/null +++ b/plans/reports/260529-1210-issue-67-human-like-chat-behavior-brainstorm.md @@ -0,0 +1,100 @@ +--- +title: "Issue 67 Human-Like Chat Behavior Brainstorm" +date: "2026-05-29" +status: approved +issue: 67 +branch: "codex/issue-67-human-like-chat-behavior" +source: "ck:brainstorm" +--- + +# Issue 67 Human-Like Chat Behavior Brainstorm + +## Summary + +Approved MVP: runtime config, quick acknowledgement, safe final multi-message splitting for channel delivery, dashboard/API preview. + +Out of scope: archive/timeline storage, issue #76 renderer, per-agent overrides, Web UI ack delivery, streaming channel ack delivery. + +## Codebase Findings + +- Go 1.26 backend, React 19/Vite web UI, Wails desktop. Config uses JSON5 plus WS `config.patch`. +- Existing `block.reply` already emits intermediate assistant content during tool iterations from `internal/pipeline/think_stage.go`. +- Channel manager already resolves `gateway.block_reply` plus per-channel overrides in `internal/channels/runs.go`. +- Non-streaming channel delivery already uses `internal/channels/events.go` to publish `block.reply` as outbound messages. +- Existing channel chunking exists in `internal/channels/chunking.go` and platform adapters; MVP should reuse/extend this, not add per-platform duplicate splitters. +- Issue #76 plan owns durable run archive/timeline. This MVP must not add archive persistence or renderer work. + +## Requirements + +Expected output: +- Non-streaming channel deliveries can send a quick acknowledgement before longer/tool work. +- Final assistant content can be split into multiple safe outbound messages. +- Dashboard config and API/WS preview surface exist for global gateway and per-channel overrides. + +Acceptance: +- Ack does not send for Web UI, streaming channel runs, silent replies, disabled config, or trivial runs. +- Splitter preserves fenced code blocks, block quotes, markdown tables, lists, structured JSON/YAML/XML, links, and short messages. +- Config resolution supports global gateway defaults and per-channel override only. +- Preview API returns deterministic ack/split decisions without dispatching messages. +- Tests cover splitter edge cases, ack gating, config resolution, final dedup with `block.reply`, and disabled/group-safe defaults. + +Constraints: +- Do not implement per-agent override in this slice. +- Do not touch issue #76 timeline storage or archive renderer. +- Use existing config and channel patterns. +- Keep backward compatibility: default off or conservative. +- Add i18n for new UI strings in en/vi/zh. + +## Options + +| Option | Pros | Cons | Decision | +|---|---|---|---| +| Channel-layer policy only | Smallest change | Ack timing weak, too late for complexity gate | Reject | +| Run-context policy plus outbound helpers | Fits `RunContext`, clean delivery boundary, avoids #76 overlap | Needs careful dedup and tests | Choose | +| Agent pipeline behavior stage | Centralized near agent output | Over-engineered, mixes delivery behavior into reasoning runtime | Reject | + +## Final Design + +Use a channel delivery policy resolved at run registration time. + +Config: +- `gateway.chat_behavior.enabled` +- `gateway.chat_behavior.quick_ack.enabled` +- `gateway.chat_behavior.quick_ack.min_delay_ms` +- `gateway.chat_behavior.quick_ack.templates` +- `gateway.chat_behavior.final_split.enabled` +- `gateway.chat_behavior.final_split.min_chars` +- `gateway.chat_behavior.final_split.max_messages` +- `gateway.chat_behavior.final_split.delay_ms` +- per-channel `chat_behavior` override with same shape, nil fields inherit global. + +Runtime: +- Resolve global plus channel override when `RegisterRun` creates `RunContext`. +- Send ack from early run event handling only when non-streaming and enabled. +- Prefer conservative gate: tool-capable/tool-run signal, non-streaming, no prior block reply, no streaming. +- Split final content after sanitization and before outbound publish. +- Preserve `block.reply` behavior. If final equals last block reply, keep existing dedup semantics. + +API/UI: +- Add preview handler for ack and split output. No side effects. +- Add global dashboard controls under Behavior. +- Add per-channel override controls in channel schema. + +## Risks + +- Spam risk in group chats. Mitigate with default off, non-streaming only, and max message caps. +- Splitter can break markdown. Mitigate with tests-first contract and conservative "do not split" fallback. +- Ack can race with final response. Mitigate by min delay and skip when run completes quickly. +- Config shape drift. Mitigate with JSON-compatible structs and nil inheritance. + +## Next Steps + +1. Create TDD plan. +2. Validate and red-team plan. +3. Commit and push planning artifacts. +4. Comment GitHub issue #67 and label `ready to implement`. +5. Implement via TDD plan. + +## Unresolved Questions + +None. diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index 106518e2..c841c5e7 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -71,6 +71,7 @@ export const Methods = { CONFIG_PATCH: "config.patch", CONFIG_SCHEMA: "config.schema", CONFIG_DEFAULTS: "config.defaults", + CHAT_BEHAVIOR_PREVIEW: "chat_behavior.preview", // Sessions SESSIONS_LIST: "sessions.list", diff --git a/ui/web/src/i18n/locales/en/config.json b/ui/web/src/i18n/locales/en/config.json index f22f94bc..5b5c079c 100644 --- a/ui/web/src/i18n/locales/en/config.json +++ b/ui/web/src/i18n/locales/en/config.json @@ -128,6 +128,24 @@ "behavior.blockReplyInfo": "Intermediate text is delivered to users during tool execution, not just the final response.", "behavior.intentClassifyHint": "Classify user intent before routing to reduce unnecessary agent invocations.", "behavior.intentClassifyInfo": "Agent will only be invoked when the classifier detects actionable intent.", + "behavior.chatTitle": "Human-like Channel Delivery", + "behavior.chatDescription": "Quick acknowledgement and safe final reply splitting for non-streaming channels", + "behavior.chatEnabled": "Enable channel delivery behavior", + "behavior.chatEnabledHint": "Applies globally, with optional per-channel overrides.", + "behavior.quickAck": "Quick acknowledgement", + "behavior.quickAckHint": "Send one short acknowledgement before longer non-streaming runs.", + "behavior.quickAckDelay": "Delay (ms)", + "behavior.quickAckTemplates": "Templates", + "behavior.finalSplit": "Final reply splitting", + "behavior.finalSplitHint": "Split long final replies into safe paragraph messages.", + "behavior.finalSplitMinChars": "Min chars", + "behavior.finalSplitMaxMessages": "Max messages", + "behavior.finalSplitDelay": "Delay (ms)", + "behavior.preview": "Preview", + "behavior.previewAck": "Ack", + "behavior.previewNoAck": "No acknowledgement for this sample.", + "behavior.previewParts": "{{count}} final message part", + "behavior.previewParts_plural": "{{count}} final message parts", "behavior.pendingCompactionTitle": "Pending Message Compaction", "behavior.pendingCompactionDescription": "Automatically summarize old group messages using LLM when the buffer exceeds a threshold", "behavior.pendingCompactionThreshold": "Threshold", diff --git a/ui/web/src/i18n/locales/vi/config.json b/ui/web/src/i18n/locales/vi/config.json index aaace75b..4207d0ea 100644 --- a/ui/web/src/i18n/locales/vi/config.json +++ b/ui/web/src/i18n/locales/vi/config.json @@ -128,6 +128,23 @@ "behavior.blockReplyInfo": "Văn bản trung gian được gửi cho người dùng trong quá trình thực thi công cụ.", "behavior.intentClassifyHint": "Phân loại ý định người dùng trước khi định tuyến để giảm các lần gọi agent không cần thiết.", "behavior.intentClassifyInfo": "Agent chỉ được gọi khi bộ phân loại phát hiện ý định có thể thực hiện.", + "behavior.chatTitle": "Gửi tin nhắn giống người hơn", + "behavior.chatDescription": "Xác nhận nhanh và tách phản hồi cuối an toàn cho kênh không streaming", + "behavior.chatEnabled": "Bật hành vi gửi tin theo kênh", + "behavior.chatEnabledHint": "Áp dụng toàn cục, có thể ghi đè theo từng kênh.", + "behavior.quickAck": "Xác nhận nhanh", + "behavior.quickAckHint": "Gửi một tin xác nhận ngắn trước các lượt chạy không streaming lâu hơn.", + "behavior.quickAckDelay": "Độ trễ (ms)", + "behavior.quickAckTemplates": "Mẫu tin", + "behavior.finalSplit": "Tách phản hồi cuối", + "behavior.finalSplitHint": "Tách phản hồi cuối dài thành các đoạn tin nhắn an toàn.", + "behavior.finalSplitMinChars": "Ký tự tối thiểu", + "behavior.finalSplitMaxMessages": "Số tin tối đa", + "behavior.finalSplitDelay": "Độ trễ (ms)", + "behavior.preview": "Xem trước", + "behavior.previewAck": "Xác nhận", + "behavior.previewNoAck": "Mẫu này không gửi xác nhận.", + "behavior.previewParts": "{{count}} phần phản hồi cuối", "behavior.pendingCompactionTitle": "Nén tin nhắn chờ", "behavior.pendingCompactionDescription": "Tự động tóm tắt tin nhắn nhóm cũ bằng LLM khi bộ đệm vượt ngưỡng", "behavior.pendingCompactionThreshold": "Ngưỡng", diff --git a/ui/web/src/i18n/locales/zh/config.json b/ui/web/src/i18n/locales/zh/config.json index 21fe8e1d..e3749f4a 100644 --- a/ui/web/src/i18n/locales/zh/config.json +++ b/ui/web/src/i18n/locales/zh/config.json @@ -128,6 +128,23 @@ "behavior.blockReplyInfo": "工具执行期间向用户发送中间文本,而非仅发送最终响应。", "behavior.intentClassifyHint": "在路由之前对用户意图进行分类,以减少不必要的 Agent 调用。", "behavior.intentClassifyInfo": "仅当分类器检测到可执行意图时才调用 Agent。", + "behavior.chatTitle": "拟人化频道投递", + "behavior.chatDescription": "为非流式频道提供快速确认和安全的最终回复拆分", + "behavior.chatEnabled": "启用频道投递行为", + "behavior.chatEnabledHint": "全局生效,可按频道覆盖。", + "behavior.quickAck": "快速确认", + "behavior.quickAckHint": "在较长的非流式运行前发送一条简短确认。", + "behavior.quickAckDelay": "延迟(毫秒)", + "behavior.quickAckTemplates": "模板", + "behavior.finalSplit": "最终回复拆分", + "behavior.finalSplitHint": "将较长最终回复拆成安全的段落消息。", + "behavior.finalSplitMinChars": "最小字符数", + "behavior.finalSplitMaxMessages": "最大消息数", + "behavior.finalSplitDelay": "延迟(毫秒)", + "behavior.preview": "预览", + "behavior.previewAck": "确认", + "behavior.previewNoAck": "此示例不会发送确认。", + "behavior.previewParts": "{{count}} 个最终回复片段", "behavior.pendingCompactionTitle": "待处理消息压缩", "behavior.pendingCompactionDescription": "当缓冲区超过阈值时,自动使用 LLM 总结旧群组消息", "behavior.pendingCompactionThreshold": "阈值", diff --git a/ui/web/src/pages/channels/channel-schemas.ts b/ui/web/src/pages/channels/channel-schemas.ts index ab590313..8bb00243 100644 --- a/ui/web/src/pages/channels/channel-schemas.ts +++ b/ui/web/src/pages/channels/channel-schemas.ts @@ -26,6 +26,12 @@ const blockReplyOptions = [ { value: "false", label: "Disabled" }, ]; +const chatBehaviorOverrideFields: FieldDef[] = [ + { key: "chat_behavior.enabled", label: "Human-like Delivery", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Override gateway quick acknowledgement and final reply splitting." }, + { key: "chat_behavior.quick_ack.enabled", label: "Quick Acknowledgement", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Override quick acknowledgement for this channel." }, + { key: "chat_behavior.final_split.enabled", label: "Final Reply Splitting", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Override final multi-message splitting for this channel." }, +]; + const dmPolicyOptions = [ { value: "pairing", label: "Pairing (require code)" }, { value: "open", label: "Open (accept all)" }, @@ -129,6 +135,7 @@ export const configSchema: Record = { { key: "link_preview", label: "Link Preview", type: "boolean", defaultValue: true }, { key: "allow_from", label: "Allowed Users", type: "tags", help: "User IDs or @usernames, one per line or comma-separated" }, { key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations" }, + ...chatBehaviorOverrideFields, ], discord: [ { key: "dm_policy", label: "DM Policy", type: "select", options: dmPolicyOptions, defaultValue: "pairing" }, @@ -137,6 +144,7 @@ export const configSchema: Record = { { key: "history_limit", label: "Group History Limit", type: "number", defaultValue: 50, help: "Max pending group messages for context (0 = disabled)" }, { key: "allow_from", label: "Allowed Users", type: "tags", help: "Discord user IDs" }, { key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations" }, + ...chatBehaviorOverrideFields, ], slack: [ { key: "dm_policy", label: "DM Policy", type: "select", options: dmPolicyOptions, defaultValue: "pairing", help: "How to handle direct messages from unknown users" }, @@ -151,6 +159,7 @@ export const configSchema: Record = { { key: "reaction_level", label: "Reaction Level", type: "select", options: [{ value: "off", label: "Off" }, { value: "minimal", label: "Minimal (thinking + done)" }, { value: "full", label: "Full (all status emoji)" }], defaultValue: "off", help: "Show emoji reactions on user messages during agent processing" }, { key: "allow_from", label: "Allowed Users", type: "tags", help: "Slack user IDs (U...) allowed to interact; empty = no allowlist filter" }, { key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations" }, + ...chatBehaviorOverrideFields, ], feishu: [ { key: "domain", label: "Domain", type: "select", options: [{ value: "lark", label: "Lark (Global)" }, { value: "feishu", label: "Feishu (China)" }], defaultValue: "lark" }, @@ -169,6 +178,7 @@ export const configSchema: Record = { { key: "allow_from", label: "Allowed Users", type: "tags", help: "Lark open_ids (ou_...)" }, { key: "group_allow_from", label: "Group Allowed Users", type: "tags", help: "Separate allowlist for group senders" }, { key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations" }, + ...chatBehaviorOverrideFields, ], zalo_oa: [ { key: "dm_policy", label: "DM Policy", type: "select", options: dmPolicyOptions, defaultValue: "pairing" }, @@ -176,6 +186,7 @@ export const configSchema: Record = { { key: "media_max_mb", label: "Max Media Size (MB)", type: "number", defaultValue: 5 }, { key: "allow_from", label: "Allowed Users", type: "tags", help: "Zalo user IDs" }, { key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations" }, + ...chatBehaviorOverrideFields, ], zalo_personal: [ { key: "dm_policy", label: "DM Policy", type: "select", options: dmPolicyOptions, defaultValue: "allowlist" }, @@ -183,6 +194,7 @@ export const configSchema: Record = { { key: "require_mention", label: "Require @mention in groups", type: "boolean", defaultValue: true }, { key: "allow_from", label: "Allowed Users", type: "tags", help: "Zalo user IDs or group IDs" }, { key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations" }, + ...chatBehaviorOverrideFields, ], whatsapp: [ { key: "dm_policy", label: "DM Policy", type: "select", options: dmPolicyOptions, defaultValue: "pairing" }, @@ -190,6 +202,7 @@ export const configSchema: Record = { { key: "require_mention", label: "Require @Mention in Groups", type: "boolean", help: "Only respond in group chats when the bot is explicitly @mentioned" }, { key: "allow_from", label: "Allowed Users", type: "tags", help: "WhatsApp user IDs" }, { key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations" }, + ...chatBehaviorOverrideFields, ], facebook: [ { key: "page_id", label: "Page ID", type: "text", required: true, help: "Facebook Page numeric ID" }, @@ -237,6 +250,7 @@ export const configSchema: Record = { help: "Never react to comments from these user IDs." }, { key: "allow_from", label: "Allowed Users", type: "tags", help: "Sender IDs to whitelist. Empty = accept all." }, { key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit" }, + ...chatBehaviorOverrideFields, ], bitrix24: [ { key: "portal", label: "Portal", type: "text", required: true, placeholder: "my-portal", help: "Select an existing Bitrix24 portal, or click \"+ Create new portal\" to connect a new one." }, @@ -260,6 +274,7 @@ export const configSchema: Record = { { key: "allow_from", label: "Allowed Users (DM)", type: "tags", help: "Bitrix24 user IDs allowed to DM the bot. Empty = no allowlist filter." }, { key: "group_allow_from", label: "Allowed Users (Group)", type: "tags", help: "Separate allowlist for group senders." }, { key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations." }, + ...chatBehaviorOverrideFields, { key: "mcp_server_name", label: "MCP Server Name", type: "text", advanced: true, placeholder: "bitrix24-prod", help: "Optional — name from mcp_servers table. Must be set together with MCP Base URL to enable per-user MCP credential auto-onboard. Leave both empty to disable." }, { key: "mcp_base_url", label: "MCP Base URL", type: "text", advanced: true, placeholder: "https://mcp.example.com", help: "Optional — HTTPS root of the partner MCP server. Channel POSTs {mcp_base_url}/api/auto-onboard to mint per-user credentials on first-sight. The MCP server authenticates each call via the caller's Bitrix access_token, so no admin secret is required." }, ], diff --git a/ui/web/src/pages/config/sections/behavior-chat-card.tsx b/ui/web/src/pages/config/sections/behavior-chat-card.tsx new file mode 100644 index 00000000..6a5ddb47 --- /dev/null +++ b/ui/web/src/pages/config/sections/behavior-chat-card.tsx @@ -0,0 +1,169 @@ +import { useEffect, useMemo, useState } from "react"; +import { MessageSquareText, Timer } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Methods } from "@/api/protocol"; +import { useWs } from "@/hooks/use-ws"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; + +export interface ChatBehaviorValues { + enabled?: boolean; + quick_ack?: { + enabled?: boolean; + min_delay_ms?: number; + templates?: string[]; + }; + final_split?: { + enabled?: boolean; + min_chars?: number; + max_messages?: number; + delay_ms?: number; + }; +} + +interface PreviewResponse { + ack?: { shouldSend?: boolean; content?: string }; + split?: { parts?: string[] }; +} + +interface Props { + value: ChatBehaviorValues; + onChange: (v: ChatBehaviorValues) => void; +} + +const sample = [ + "I found the relevant details and will keep this concise.", + "First, the runtime sends a short acknowledgement only for non-streaming channel replies.", + "Then the final answer can be split into safe paragraph-sized messages when the text is long enough.", +].join("\n\n"); + +export function BehaviorChatCard({ value, onChange }: Props) { + const { t } = useTranslation("config"); + const ws = useWs(); + const [preview, setPreview] = useState(null); + + const templatesText = useMemo(() => (value.quick_ack?.templates ?? ["Got it. Working on it..."]).join("\n"), [value.quick_ack?.templates]); + + useEffect(() => { + const timer = window.setTimeout(async () => { + try { + const next = await ws.call(Methods.CHAT_BEHAVIOR_PREVIEW, { + content: sample, + isStreaming: false, + hasToolCalls: true, + config: value, + }); + setPreview(next); + } catch { + setPreview(null); + } + }, 250); + return () => window.clearTimeout(timer); + }, [value, ws]); + + const patch = (updates: ChatBehaviorValues) => onChange({ ...value, ...updates }); + const patchAck = (updates: NonNullable) => + patch({ quick_ack: { ...(value.quick_ack ?? {}), ...updates } }); + const patchSplit = (updates: NonNullable) => + patch({ final_split: { ...(value.final_split ?? {}), ...updates } }); + + return ( + + + + + {t("behavior.chatTitle")} + + {t("behavior.chatDescription")} + + +
+
+ +

{t("behavior.chatEnabledHint")}

+
+ patch({ enabled })} /> +
+ +
+
+
+
+ +

{t("behavior.quickAckHint")}

+
+ patchAck({ enabled })} + disabled={!value.enabled} + /> +
+
+ + patchAck({ min_delay_ms: Number(e.target.value) })} + disabled={!value.enabled} + /> +
+
+ +