2026-04-09 10:01:23 +08:00
|
|
|
package runtime
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"cs-agent/internal/models"
|
|
|
|
|
"cs-agent/internal/pkg/enums"
|
|
|
|
|
svc "cs-agent/internal/services"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var AIReplyService = newAIReplyService()
|
|
|
|
|
|
|
|
|
|
func init() {
|
|
|
|
|
svc.TriggerAIReplyAsyncHook = AIReplyService.TriggerReplyAsync
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newAIReplyService() *aiReplyService {
|
2026-04-12 23:37:52 +08:00
|
|
|
return &aiReplyService{
|
|
|
|
|
eligibility: newReplyEligibility(),
|
|
|
|
|
executor: newRuntimeReplyExecutor(),
|
2026-04-13 17:17:13 +08:00
|
|
|
interrupts: newReplyInterruptService(),
|
|
|
|
|
commit: newReplyCommitService(),
|
|
|
|
|
runlog: newReplyRunLogService(),
|
2026-04-12 23:37:52 +08:00
|
|
|
}
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-12 23:37:52 +08:00
|
|
|
type aiReplyService struct {
|
|
|
|
|
eligibility *replyEligibility
|
|
|
|
|
executor *runtimeReplyExecutor
|
2026-04-13 17:17:13 +08:00
|
|
|
interrupts *replyInterruptService
|
|
|
|
|
commit *replyCommitService
|
|
|
|
|
runlog *replyRunLogService
|
2026-04-12 23:37:52 +08:00
|
|
|
}
|
2026-04-09 10:01:23 +08:00
|
|
|
|
|
|
|
|
type aiReplyTraceData struct {
|
|
|
|
|
Status string `json:"status"`
|
|
|
|
|
RuntimeLatencyMs int64 `json:"runtimeLatencyMs,omitempty"`
|
|
|
|
|
RecheckMs int64 `json:"recheckMs,omitempty"`
|
|
|
|
|
CommitMs int64 `json:"commitMs,omitempty"`
|
|
|
|
|
FinalAction string `json:"finalAction,omitempty"`
|
2026-04-10 14:43:57 +08:00
|
|
|
ResumeSource string `json:"resumeSource,omitempty"`
|
2026-04-09 10:01:23 +08:00
|
|
|
ReplySent bool `json:"replySent,omitempty"`
|
|
|
|
|
ReplyMessageID int64 `json:"replyMessageId,omitempty"`
|
|
|
|
|
Runtime json.RawMessage `json:"runtime,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
defaultAIReplyAsyncTimeoutSeconds = 180
|
|
|
|
|
maxAIReplyAsyncTimeoutSeconds = 600
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func (s *aiReplyService) resolveReplyTimeout(aiAgent models.AIAgent) time.Duration {
|
|
|
|
|
if aiAgent.ReplyTimeoutSeconds <= 0 {
|
|
|
|
|
return time.Duration(defaultAIReplyAsyncTimeoutSeconds) * time.Second
|
|
|
|
|
}
|
|
|
|
|
if aiAgent.ReplyTimeoutSeconds > maxAIReplyAsyncTimeoutSeconds {
|
|
|
|
|
return time.Duration(maxAIReplyAsyncTimeoutSeconds) * time.Second
|
|
|
|
|
}
|
|
|
|
|
return time.Duration(aiAgent.ReplyTimeoutSeconds) * time.Second
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *aiReplyService) TriggerReplyAsync(conversation models.Conversation, message models.Message) {
|
|
|
|
|
go func() {
|
|
|
|
|
aiAgent := svc.AIAgentService.Get(conversation.AIAgentID)
|
|
|
|
|
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
startedAt := time.Now()
|
|
|
|
|
timeout := s.resolveReplyTimeout(*aiAgent)
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
|
|
|
defer cancel()
|
|
|
|
|
if err := s.TriggerReply(ctx, conversation, message, *aiAgent); err != nil {
|
|
|
|
|
slog.Error("failed to trigger ai reply",
|
|
|
|
|
"message_id", message.ID,
|
|
|
|
|
"timeout_ms", timeout.Milliseconds(),
|
|
|
|
|
"elapsed_ms", time.Since(startedAt).Milliseconds(),
|
|
|
|
|
"error", err)
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *aiReplyService) TriggerReply(ctx context.Context, conversation models.Conversation, message models.Message, aiAgent models.AIAgent) (retErr error) {
|
|
|
|
|
startedAt := time.Now()
|
|
|
|
|
trace := &aiReplyTraceData{Status: "started"}
|
|
|
|
|
var summary *Summary
|
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-04-12 23:37:52 +08:00
|
|
|
if s.eligibility != nil && !s.eligibility.CanReply(conversation, message, aiAgent) {
|
2026-04-09 10:01:23 +08:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
defer func() {
|
2026-04-13 17:17:13 +08:00
|
|
|
s.runlog.Write(startedAt, message, conversation, aiAgent, message.Content, retErr, trace, summary)
|
2026-04-09 10:01:23 +08:00
|
|
|
}()
|
|
|
|
|
if pendingInterrupt := svc.ConversationInterruptService.FindLatestPendingByConversationID(conversation.ID); pendingInterrupt != nil {
|
2026-04-13 17:17:13 +08:00
|
|
|
return s.interrupts.ResumePendingInterrupt(ctx, s, conversation, message, aiAgent, pendingInterrupt, trace, &summary)
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
var err error
|
2026-04-12 23:37:52 +08:00
|
|
|
summary, err = s.executor.Run(ctx, conversation, message, aiAgent, trace)
|
2026-04-09 10:01:23 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if summary != nil && summary.Interrupted {
|
2026-04-13 17:17:13 +08:00
|
|
|
return s.interrupts.HandleInterruptedSummary(s, conversation, message, aiAgent, summary, trace)
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
if summary != nil && strings.TrimSpace(summary.ReplyText) != "" {
|
2026-04-13 17:17:13 +08:00
|
|
|
replyMessage, err := s.commit.SendAIReply(conversation, message, aiAgent, summary.ReplyText, trace, "ai_reply")
|
2026-04-09 10:01:23 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-04-13 17:17:13 +08:00
|
|
|
if err := s.commit.IncrementAIReplyRounds(conversation.ID, conversation.AIReplyRounds+1, aiAgent.Name); err != nil {
|
2026-04-09 10:01:23 +08:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
trace.ReplySent = replyMessage != nil
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func firstInvokedToolCode(summary *Summary) string {
|
|
|
|
|
if summary == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
if len(summary.InvokedToolCodes) > 0 {
|
|
|
|
|
return strings.TrimSpace(summary.InvokedToolCodes[0])
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|