mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-05 08:17:29 +00:00
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:
+1
-1
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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 == "" {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package methods
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
// ChatBehaviorMethods handles dashboard previews for channel delivery behavior.
|
||||
type ChatBehaviorMethods struct {
|
||||
cfg *config.Config
|
||||
channelMgr *channels.Manager
|
||||
}
|
||||
|
||||
func NewChatBehaviorMethods(cfg *config.Config, channelMgr *channels.Manager) *ChatBehaviorMethods {
|
||||
return &ChatBehaviorMethods{cfg: cfg, channelMgr: channelMgr}
|
||||
}
|
||||
|
||||
func (m *ChatBehaviorMethods) Register(router *gateway.MethodRouter) {
|
||||
router.Register(protocol.MethodChatBehaviorPreview, m.requireMasterScope(m.requireOwner(m.handlePreview)))
|
||||
}
|
||||
|
||||
func (m *ChatBehaviorMethods) requireOwner(next gateway.MethodHandler) gateway.MethodHandler {
|
||||
return func(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
if !client.IsOwner() {
|
||||
locale := store.LocaleFromContext(ctx)
|
||||
client.SendResponse(protocol.NewErrorResponse(
|
||||
req.ID,
|
||||
protocol.ErrUnauthorized,
|
||||
i18n.T(locale, i18n.MsgPermissionDenied, req.Method),
|
||||
))
|
||||
return
|
||||
}
|
||||
next(ctx, client, req)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ChatBehaviorMethods) requireMasterScope(next gateway.MethodHandler) gateway.MethodHandler {
|
||||
return func(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
if !store.IsMasterScope(ctx) {
|
||||
locale := store.LocaleFromContext(ctx)
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgConfigMasterScopeOnly)))
|
||||
return
|
||||
}
|
||||
next(ctx, client, req)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ChatBehaviorMethods) handlePreview(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
var params struct {
|
||||
Channel string `json:"channel"`
|
||||
Content string `json:"content"`
|
||||
IsStreaming bool `json:"isStreaming"`
|
||||
HasToolCalls bool `json:"hasToolCalls"`
|
||||
Config *config.ChatBehaviorConfig `json:"config"`
|
||||
}
|
||||
if req.Params != nil {
|
||||
_ = json.Unmarshal(req.Params, ¶ms)
|
||||
}
|
||||
|
||||
var resolved channels.ResolvedChatBehavior
|
||||
if params.Config != nil {
|
||||
resolved = channels.ResolveChatBehavior(params.Config, nil)
|
||||
} else if m.channelMgr != nil {
|
||||
resolved = m.channelMgr.ResolveChatBehavior(params.Channel, m.cfg.Gateway.ChatBehavior)
|
||||
} else {
|
||||
resolved = channels.ResolveChatBehavior(m.cfg.Gateway.ChatBehavior, nil)
|
||||
}
|
||||
preview := channels.ChatBehaviorPreview{
|
||||
Resolved: resolved,
|
||||
Split: channels.SplitPreview{
|
||||
Parts: channels.SplitFinalMessages(params.Content, resolved.FinalSplit),
|
||||
},
|
||||
}
|
||||
if channels.ShouldSendQuickAck(resolved, params.IsStreaming) {
|
||||
preview.Ack = channels.AckPreview{ShouldSend: true, Content: resolved.QuickAck.Templates[0]}
|
||||
}
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, preview))
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
)
|
||||
|
||||
+58
@@ -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.
|
||||
+59
@@ -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.
|
||||
+61
@@ -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.
|
||||
+69
@@ -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
|
||||
```
|
||||
+33
@@ -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.
|
||||
+32
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user