Files
Duy /zuey/andGitHub 50c8519f01 fix(channels): require generated delivery replies
Quick Ack and Intermediate Replies now require generated delivery text in generated modes, while fixed-template mode remains explicit. Includes provider/model resolution coverage and UI copy updates.
2026-06-10 16:29:39 +07:00

430 lines
12 KiB
Go

package channels
import (
"encoding/json"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
const (
defaultQuickAckDelayMs = 1000
defaultDeliveryTimeout = 2500
defaultQuickAckTokens = 40
defaultQuickAckChars = 120
defaultProgressTokens = 60
defaultProgressChars = 180
defaultFinalSplitMin = 1200
defaultFinalSplitMax = 3
defaultFinalSplitDelay = 500
defaultAckTemplate = "Got it. Working on it..."
QuickAckModeLLMGenerated = "llm_generated"
QuickAckModeSidecar = "sidecar_generated"
QuickAckModeFixedTemplate = "fixed_template"
QuickAckModeOff = "off"
IntermediateModeSidecar = "sidecar_generated"
IntermediateModeOff = "off"
QuickAckSourceGenerated = "generated"
QuickAckSourceTemplate = "template"
QuickAckSourceOff = "off"
)
type ResolvedChatBehavior struct {
Enabled bool
IntermediateReplies ResolvedIntermediateRepliesConfig
QuickAck ResolvedQuickAckConfig
FinalSplit ResolvedFinalSplitConfig
}
type ResolvedQuickAckConfig struct {
Enabled bool
Mode string
MinDelayMs int
Provider string
Model string
Timeout time.Duration
MaxTokens int
MaxChars int
Templates []string
}
type ResolvedIntermediateRepliesConfig struct {
Enabled bool
Mode string
Provider string
Model string
Timeout time.Duration
MaxTokens int
MaxChars int
}
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"`
Mode string `json:"mode,omitempty"`
Source string `json:"source,omitempty"`
}
type SplitPreview struct {
Parts []string `json:"parts"`
}
func ResolveChatBehavior(global, override *config.ChatBehaviorConfig) ResolvedChatBehavior {
return ResolveChatBehaviorWithAgent(global, nil, override)
}
func ResolveChatBehaviorWithAgent(global, agentOverride, channelOverride *config.ChatBehaviorConfig) ResolvedChatBehavior {
resolved := ResolvedChatBehavior{
IntermediateReplies: ResolvedIntermediateRepliesConfig{
Mode: IntermediateModeSidecar,
Timeout: time.Duration(defaultDeliveryTimeout) * time.Millisecond,
MaxTokens: defaultProgressTokens,
MaxChars: defaultProgressChars,
},
QuickAck: ResolvedQuickAckConfig{
Mode: QuickAckModeLLMGenerated,
MinDelayMs: defaultQuickAckDelayMs,
Timeout: time.Duration(defaultDeliveryTimeout) * time.Millisecond,
MaxTokens: defaultQuickAckTokens,
MaxChars: defaultQuickAckChars,
Templates: []string{defaultAckTemplate},
},
FinalSplit: ResolvedFinalSplitConfig{
MinChars: defaultFinalSplitMin,
MaxMessages: defaultFinalSplitMax,
DelayMs: defaultFinalSplitDelay,
},
}
applyChatBehavior(&resolved, global)
applyChatBehavior(&resolved, agentOverride)
applyChatBehavior(&resolved, channelOverride)
if !resolved.Enabled {
resolved.IntermediateReplies.Enabled = false
resolved.QuickAck.Enabled = false
resolved.FinalSplit.Enabled = false
}
if resolved.FinalSplit.MaxMessages < 1 {
resolved.FinalSplit.MaxMessages = 1
}
return resolved
}
func ParseAgentDeliveryBehaviorConfig(raw json.RawMessage) *config.ChatBehaviorConfig {
if len(raw) == 0 {
return nil
}
var envelope struct {
DeliveryBehavior *config.ChatBehaviorConfig `json:"delivery_behavior"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil
}
return envelope.DeliveryBehavior
}
func ChatBehaviorConfigWithIntermediateDefault(src *config.ChatBehaviorConfig, enabled *bool) *config.ChatBehaviorConfig {
if enabled == nil {
return src
}
clone := cloneChatBehaviorConfig(src)
if clone == nil {
clone = &config.ChatBehaviorConfig{}
}
if *enabled && clone.Enabled == nil {
clone.Enabled = enabled
}
if clone.IntermediateReplies == nil {
clone.IntermediateReplies = &config.IntermediateRepliesConfig{}
}
if clone.IntermediateReplies.Enabled == nil {
clone.IntermediateReplies.Enabled = enabled
}
return clone
}
func cloneChatBehaviorConfig(src *config.ChatBehaviorConfig) *config.ChatBehaviorConfig {
if src == nil {
return nil
}
clone := *src
if src.IntermediateReplies != nil {
v := *src.IntermediateReplies
clone.IntermediateReplies = &v
}
if src.QuickAck != nil {
v := *src.QuickAck
v.Templates = append([]string(nil), src.QuickAck.Templates...)
clone.QuickAck = &v
}
if src.FinalSplit != nil {
v := *src.FinalSplit
clone.FinalSplit = &v
}
return &clone
}
func applyChatBehavior(dst *ResolvedChatBehavior, src *config.ChatBehaviorConfig) {
if src == nil {
return
}
if src.Enabled != nil {
dst.Enabled = *src.Enabled
}
if src.IntermediateReplies != nil {
if src.IntermediateReplies.Enabled != nil {
dst.IntermediateReplies.Enabled = *src.IntermediateReplies.Enabled
}
if src.IntermediateReplies.Mode != nil {
dst.IntermediateReplies.Mode = normalizeIntermediateMode(*src.IntermediateReplies.Mode)
}
if src.IntermediateReplies.Provider != "" {
dst.IntermediateReplies.Provider = strings.TrimSpace(src.IntermediateReplies.Provider)
}
if src.IntermediateReplies.Model != "" {
dst.IntermediateReplies.Model = strings.TrimSpace(src.IntermediateReplies.Model)
}
if src.IntermediateReplies.TimeoutMs != nil {
dst.IntermediateReplies.Timeout = time.Duration(max(0, *src.IntermediateReplies.TimeoutMs)) * time.Millisecond
}
if src.IntermediateReplies.MaxTokens != nil {
dst.IntermediateReplies.MaxTokens = max(1, *src.IntermediateReplies.MaxTokens)
}
if src.IntermediateReplies.MaxChars != nil {
dst.IntermediateReplies.MaxChars = max(1, *src.IntermediateReplies.MaxChars)
}
}
if src.QuickAck != nil {
if src.QuickAck.Enabled != nil {
dst.QuickAck.Enabled = *src.QuickAck.Enabled
}
if src.QuickAck.Mode != nil {
dst.QuickAck.Mode = normalizeQuickAckMode(*src.QuickAck.Mode)
}
if src.QuickAck.MinDelayMs != nil {
dst.QuickAck.MinDelayMs = max(0, *src.QuickAck.MinDelayMs)
}
if src.QuickAck.Provider != "" {
dst.QuickAck.Provider = strings.TrimSpace(src.QuickAck.Provider)
}
if src.QuickAck.Model != "" {
dst.QuickAck.Model = strings.TrimSpace(src.QuickAck.Model)
}
if src.QuickAck.TimeoutMs != nil {
dst.QuickAck.Timeout = time.Duration(max(0, *src.QuickAck.TimeoutMs)) * time.Millisecond
}
if src.QuickAck.MaxTokens != nil {
dst.QuickAck.MaxTokens = max(1, *src.QuickAck.MaxTokens)
}
if src.QuickAck.MaxChars != nil {
dst.QuickAck.MaxChars = max(1, *src.QuickAck.MaxChars)
}
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)
return PreviewResolvedChatBehavior(resolved, opts)
}
func PreviewResolvedChatBehavior(resolved ResolvedChatBehavior, opts ChatBehaviorPreviewOptions) ChatBehaviorPreview {
preview := ChatBehaviorPreview{
Resolved: resolved,
Split: SplitPreview{Parts: SplitFinalMessages(opts.Content, resolved.FinalSplit)},
}
preview.Ack = buildAckPreview(resolved, opts.IsStreaming, opts.HasToolCalls)
return preview
}
func ShouldSendQuickAck(behavior ResolvedChatBehavior, streaming bool) bool {
if !behavior.Enabled || !behavior.QuickAck.Enabled || streaming {
return false
}
switch effectiveQuickAckMode(behavior.QuickAck.Mode) {
case QuickAckModeLLMGenerated, QuickAckModeSidecar, QuickAckModeFixedTemplate:
return true
default:
return false
}
}
func ShouldDeliverGeneratedProgress(behavior ResolvedChatBehavior, streaming bool) bool {
return behavior.Enabled &&
behavior.IntermediateReplies.Enabled &&
!streaming &&
effectiveIntermediateMode(behavior.IntermediateReplies.Mode) == IntermediateModeSidecar
}
func ShouldSuppressInitialBlockReply(behavior ResolvedChatBehavior, streaming bool) bool {
if streaming || !behavior.Enabled {
return false
}
return !behavior.QuickAck.Enabled || effectiveQuickAckMode(behavior.QuickAck.Mode) == QuickAckModeOff
}
func normalizeQuickAckMode(mode string) string {
switch strings.TrimSpace(mode) {
case QuickAckModeLLMGenerated, QuickAckModeSidecar:
return strings.TrimSpace(mode)
case QuickAckModeFixedTemplate:
return QuickAckModeFixedTemplate
case QuickAckModeOff:
return QuickAckModeOff
default:
return QuickAckModeLLMGenerated
}
}
func normalizeIntermediateMode(mode string) string {
switch strings.TrimSpace(mode) {
case QuickAckModeLLMGenerated, IntermediateModeSidecar:
return IntermediateModeSidecar
case IntermediateModeOff:
return IntermediateModeOff
default:
return IntermediateModeSidecar
}
}
func effectiveQuickAckMode(mode string) string {
if strings.TrimSpace(mode) == "" {
return QuickAckModeLLMGenerated
}
return normalizeQuickAckMode(mode)
}
func effectiveIntermediateMode(mode string) string {
if strings.TrimSpace(mode) == "" {
return IntermediateModeSidecar
}
return normalizeIntermediateMode(mode)
}
func buildAckPreview(behavior ResolvedChatBehavior, streaming, _ bool) AckPreview {
mode := effectiveQuickAckMode(behavior.QuickAck.Mode)
preview := AckPreview{Mode: mode}
if !behavior.Enabled || !behavior.QuickAck.Enabled || streaming {
preview.Source = QuickAckSourceOff
return preview
}
if mode == QuickAckModeOff {
preview.Source = QuickAckSourceOff
return preview
}
if mode == QuickAckModeFixedTemplate {
if len(behavior.QuickAck.Templates) == 0 {
return preview
}
preview.ShouldSend = true
preview.Source = QuickAckSourceTemplate
preview.Content = behavior.QuickAck.Templates[0]
return preview
}
preview.ShouldSend = true
preview.Source = QuickAckSourceGenerated
return preview
}
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
}