feat(channels): add human-like chat behavior

Squash merge PR #99 after resolving conflicts with current dev. PR CI run 26703381807 passed release-versioning, go, and web.
This commit is contained in:
Duy /zuey/
2026-05-31 11:44:56 +07:00
committed by GitHub
parent 3569f8d681
commit f0f39ce31f
51 changed files with 1786 additions and 205 deletions
+1 -1
View File
@@ -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:
+2 -1
View File
@@ -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
+43 -24
View File
@@ -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
+11
View File
@@ -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.
+21
View File
@@ -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
+12 -5
View File
@@ -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.
+7 -5
View File
@@ -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
+7
View File
@@ -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
+193
View File
@@ -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
}
@@ -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)
}
}
+137
View File
@@ -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 }
+7 -4
View File
@@ -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 {
+14 -12
View File
@@ -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,
+61 -3
View File
@@ -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...",
+25 -22
View File
@@ -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,
+7 -4
View File
@@ -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 {
+6
View File
@@ -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)
+4
View File
@@ -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) {
+11 -6
View File
@@ -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.
+7
View File
@@ -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 {
+31 -2
View File
@@ -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)
}
+15 -12
View File
@@ -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,
}
+4
View File
@@ -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()
+3
View File
@@ -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 {
+34 -32
View File
@@ -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).
+14 -8
View File
@@ -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 == "" {
+11 -8
View File
@@ -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")
+7 -5
View File
@@ -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)
@@ -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()
+12 -9
View File
@@ -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)
+20 -15
View File
@@ -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)")
+49 -18
View File
@@ -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.
+85
View File
@@ -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, &params)
}
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))
}
+1
View File
@@ -211,6 +211,7 @@ func isAdminMethod(method string) bool {
protocol.MethodConfigPatch,
protocol.MethodConfigSchema,
protocol.MethodConfigDefaults,
protocol.MethodChatBehaviorPreview,
protocol.MethodConfigPermissionsList,
protocol.MethodConfigPermissionsCheck,
protocol.MethodConfigPermissionsGrant,
+10 -9
View File
@@ -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"
)
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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
```
@@ -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.
@@ -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.
@@ -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.
+1
View File
@@ -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",
+18
View File
@@ -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",
+17
View File
@@ -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",
+17
View File
@@ -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": "阈值",
@@ -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<string, FieldDef[]> = {
{ 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<string, FieldDef[]> = {
{ 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<string, FieldDef[]> = {
{ 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<string, FieldDef[]> = {
{ 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<string, FieldDef[]> = {
{ 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<string, FieldDef[]> = {
{ 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<string, FieldDef[]> = {
{ 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<string, FieldDef[]> = {
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<string, FieldDef[]> = {
{ 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." },
],
@@ -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<PreviewResponse | null>(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<PreviewResponse>(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<ChatBehaviorValues["quick_ack"]>) =>
patch({ quick_ack: { ...(value.quick_ack ?? {}), ...updates } });
const patchSplit = (updates: NonNullable<ChatBehaviorValues["final_split"]>) =>
patch({ final_split: { ...(value.final_split ?? {}), ...updates } });
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<MessageSquareText className="h-4 w-4 text-emerald-500" />
{t("behavior.chatTitle")}
</CardTitle>
<CardDescription>{t("behavior.chatDescription")}</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label>{t("behavior.chatEnabled")}</Label>
<p className="text-xs text-muted-foreground">{t("behavior.chatEnabledHint")}</p>
</div>
<Switch checked={value.enabled ?? false} onCheckedChange={(enabled) => patch({ enabled })} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-3 rounded-md border p-3">
<div className="flex items-start justify-between gap-4">
<div>
<Label>{t("behavior.quickAck")}</Label>
<p className="text-xs text-muted-foreground">{t("behavior.quickAckHint")}</p>
</div>
<Switch
checked={value.quick_ack?.enabled ?? true}
onCheckedChange={(enabled) => patchAck({ enabled })}
disabled={!value.enabled}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="chat-behavior-ack-delay">{t("behavior.quickAckDelay")}</Label>
<Input
id="chat-behavior-ack-delay"
type="number"
min={0}
value={value.quick_ack?.min_delay_ms ?? 1000}
onChange={(e) => patchAck({ min_delay_ms: Number(e.target.value) })}
disabled={!value.enabled}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="chat-behavior-ack-templates">{t("behavior.quickAckTemplates")}</Label>
<Textarea
id="chat-behavior-ack-templates"
rows={3}
value={templatesText}
onChange={(e) => patchAck({ templates: e.target.value.split("\n").map((v) => v.trim()).filter(Boolean) })}
disabled={!value.enabled}
/>
</div>
</div>
<div className="space-y-3 rounded-md border p-3">
<div className="flex items-start justify-between gap-4">
<div>
<Label>{t("behavior.finalSplit")}</Label>
<p className="text-xs text-muted-foreground">{t("behavior.finalSplitHint")}</p>
</div>
<Switch
checked={value.final_split?.enabled ?? true}
onCheckedChange={(enabled) => patchSplit({ enabled })}
disabled={!value.enabled}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<NumberField label={t("behavior.finalSplitMinChars")} value={value.final_split?.min_chars ?? 1200} disabled={!value.enabled} onChange={(min_chars) => patchSplit({ min_chars })} />
<NumberField label={t("behavior.finalSplitMaxMessages")} value={value.final_split?.max_messages ?? 3} disabled={!value.enabled} onChange={(max_messages) => patchSplit({ max_messages })} />
<NumberField label={t("behavior.finalSplitDelay")} value={value.final_split?.delay_ms ?? 500} disabled={!value.enabled} onChange={(delay_ms) => patchSplit({ delay_ms })} />
</div>
</div>
</div>
<div className="rounded-md border bg-muted/30 p-3 text-xs">
<div className="mb-2 flex items-center gap-2 font-medium">
<Timer className="h-3.5 w-3.5" />
{t("behavior.preview")}
</div>
<div className="space-y-2 text-muted-foreground">
<p>{preview?.ack?.shouldSend ? `${t("behavior.previewAck")}: ${preview.ack.content}` : t("behavior.previewNoAck")}</p>
<p>{t("behavior.previewParts", { count: preview?.split?.parts?.length ?? 1 })}</p>
</div>
</div>
</CardContent>
</Card>
);
}
function NumberField({ label, value, disabled, onChange }: { label: string; value: number; disabled: boolean; onChange: (v: number) => void }) {
return (
<div className="grid gap-1.5">
<Label>{label}</Label>
<Input type="number" min={0} value={value} disabled={disabled} onChange={(e) => onChange(Number(e.target.value))} />
</div>
);
}
@@ -7,6 +7,7 @@ import { BehaviorRateCard } from "./behavior-rate-card";
import { BehaviorSessionsCard } from "./behavior-sessions-card";
import { BehaviorSecurityCard } from "./behavior-security-card";
import { BehaviorPendingCompactionCard, type PendingCompactionValues } from "./behavior-pending-compaction-card";
import { BehaviorChatCard, type ChatBehaviorValues } from "./behavior-chat-card";
@@ -55,6 +56,7 @@ export function BehaviorSection({ config, onPatch, saving }: Props) {
const [pendingCompaction, setPendingCompaction] = useState<PendingCompactionValues>(
ch.pending_compaction ?? {},
);
const [chatBehavior, setChatBehavior] = useState<ChatBehaviorValues>(normalizeChatBehavior(gw.chat_behavior));
const [dirty, setDirty] = useState(false);
@@ -76,6 +78,7 @@ export function BehaviorSection({ config, onPatch, saving }: Props) {
scrub_credentials: tl.scrub_credentials,
});
setPendingCompaction(ch.pending_compaction ?? {});
setChatBehavior(normalizeChatBehavior(gw.chat_behavior));
setDirty(false);
}, [config]);
@@ -91,6 +94,7 @@ export function BehaviorSection({ config, onPatch, saving }: Props) {
rate_limit_rpm: rate.rate_limit_rpm,
inbound_debounce_ms: rate.inbound_debounce_ms,
injection_action: security.injection_action,
chat_behavior: chatBehavior,
},
agents: {
defaults: { intent_classify: ux.intent_classify },
@@ -104,6 +108,7 @@ export function BehaviorSection({ config, onPatch, saving }: Props) {
return (
<div className="space-y-4">
<BehaviorUxCard value={ux} onChange={markDirty(setUx)} />
<BehaviorChatCard value={chatBehavior} onChange={markDirty(setChatBehavior)} />
<BehaviorRateCard value={rate} onChange={markDirty(setRate)} />
<BehaviorSessionsCard value={sessions} onChange={markDirty(setSessions)} />
<BehaviorSecurityCard value={security} onChange={markDirty(setSecurity)} />
@@ -119,3 +124,20 @@ export function BehaviorSection({ config, onPatch, saving }: Props) {
</div>
);
}
function normalizeChatBehavior(value: any): ChatBehaviorValues {
return {
enabled: value?.enabled ?? false,
quick_ack: {
enabled: value?.quick_ack?.enabled ?? true,
min_delay_ms: value?.quick_ack?.min_delay_ms ?? 1000,
templates: value?.quick_ack?.templates ?? ["Got it. Working on it..."],
},
final_split: {
enabled: value?.final_split?.enabled ?? true,
min_chars: value?.final_split?.min_chars ?? 1200,
max_messages: value?.final_split?.max_messages ?? 3,
delay_ms: value?.final_split?.delay_ms ?? 500,
},
};
}