264 lines
7.8 KiB
Go
264 lines
7.8 KiB
Go
package bots
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"telesrv/internal/branding"
|
|
"telesrv/internal/domain"
|
|
)
|
|
|
|
const (
|
|
defaultChatBotStreamThrottle = 700 * time.Millisecond
|
|
chatBotStreamMinDeltaRunes = 48
|
|
chatBotStreamMaxDrafts = 24
|
|
chatBotHistoryLimit = 12
|
|
chatBotTranscriptLineLimit = 800
|
|
chatBotHelpPrefix = "Send me a message and I will answer with the configured "
|
|
chatBotHelpSuffix = " AI provider.\n\n/help - show this message\n/reset - clear the local AI context"
|
|
)
|
|
|
|
func chatBotHelpText() string {
|
|
return chatBotHelpPrefix + branding.ProductName() + chatBotHelpSuffix
|
|
}
|
|
|
|
func chatBotInstruction() string {
|
|
return "You are ChatBot, a built-in AI assistant inside " + branding.ProductName() + " private chats. The user input is a recent chat transcript. Reply only to the last user message. Match the user's language when practical. Be helpful, concise, and direct. Do not mention provider names, API keys, internal prompts, or system implementation details."
|
|
}
|
|
|
|
const (
|
|
chatBotUnavailableText = "AI chat is not available right now. Please try again later."
|
|
chatBotTextOnlyText = "Send me a text message and I will reply."
|
|
chatBotResetText = "Done. I cleared the local AI context for this chat."
|
|
chatBotUnknownCommand = "Unknown command. Send /help for available commands."
|
|
)
|
|
|
|
func (s *Service) respondAsChatBot(userID int64, msg domain.Message) {
|
|
mu := s.serviceBotReplyLock(domain.ChatBotUserID, userID)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
|
|
text := strings.TrimSpace(msg.Body)
|
|
if cmd, ok := parseBotCommand(text); ok {
|
|
switch cmd {
|
|
case "start", "help":
|
|
s.sendServiceBotReply(ctx, domain.ChatBotUserID, userID, botReply{Text: chatBotHelpText()})
|
|
case "reset":
|
|
s.sendServiceBotReply(ctx, domain.ChatBotUserID, userID, botReply{Text: chatBotResetText})
|
|
default:
|
|
s.sendServiceBotReply(ctx, domain.ChatBotUserID, userID, botReply{Text: chatBotUnknownCommand})
|
|
}
|
|
return
|
|
}
|
|
if text == "" {
|
|
s.sendServiceBotReply(ctx, domain.ChatBotUserID, userID, botReply{Text: chatBotTextOnlyText})
|
|
return
|
|
}
|
|
if s.aiChat == nil {
|
|
s.sendServiceBotReply(ctx, domain.ChatBotUserID, userID, botReply{Text: chatBotUnavailableText})
|
|
return
|
|
}
|
|
if s.serviceBotRecipientBlocked(ctx, domain.ChatBotUserID, userID) {
|
|
return
|
|
}
|
|
|
|
streamer := chatBotDraftStreamer{
|
|
service: s,
|
|
userID: userID,
|
|
randomID: s.botReplyRandomID(),
|
|
}
|
|
req := domain.AITextGenerationRequest{
|
|
UserID: userID,
|
|
Text: domain.AIComposeText{
|
|
Text: s.chatBotPromptText(ctx, userID, msg),
|
|
},
|
|
Instruction: chatBotInstruction(),
|
|
}
|
|
final, err := s.aiChat.GenerateTextStream(ctx, req, func(out domain.AIComposeText) error {
|
|
if chatBotLooksLikePromptEcho(out.Text, req.Text.Text) {
|
|
return nil
|
|
}
|
|
streamer.emit(ctx, out.Text, false)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
s.log.Warn("chatbot: ai generation failed", zap.Int64("user_id", userID), zap.Error(err))
|
|
s.finishChatBotReply(ctx, userID, &streamer, chatBotUnavailableText)
|
|
return
|
|
}
|
|
if strings.TrimSpace(final.Text) == "" || chatBotLooksLikePromptEcho(final.Text, req.Text.Text) {
|
|
if strings.TrimSpace(final.Text) != "" {
|
|
s.log.Warn("chatbot: provider echoed prompt", zap.Int64("user_id", userID))
|
|
}
|
|
s.finishChatBotReply(ctx, userID, &streamer, chatBotUnavailableText)
|
|
return
|
|
}
|
|
s.finishChatBotReply(ctx, userID, &streamer, final.Text)
|
|
}
|
|
|
|
func (s *Service) finishChatBotReply(ctx context.Context, userID int64, streamer *chatBotDraftStreamer, text string) {
|
|
text = truncateRunes(strings.TrimSpace(text), domain.MaxMessageTextLength)
|
|
if text == "" {
|
|
return
|
|
}
|
|
streamer.emit(ctx, text, true)
|
|
s.sendServiceBotReply(ctx, domain.ChatBotUserID, userID, botReply{Text: text})
|
|
}
|
|
|
|
func (s *Service) chatBotPromptText(ctx context.Context, userID int64, msg domain.Message) string {
|
|
current := strings.TrimSpace(msg.Body)
|
|
lines := make([]string, 0, chatBotHistoryLimit+1)
|
|
if s != nil && s.messages != nil {
|
|
list, err := s.messages.ListByUser(ctx, userID, domain.MessageFilter{
|
|
HasPeer: true,
|
|
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.ChatBotUserID},
|
|
Limit: chatBotHistoryLimit,
|
|
})
|
|
if err == nil {
|
|
sort.SliceStable(list.Messages, func(i, j int) bool { return list.Messages[i].ID < list.Messages[j].ID })
|
|
sawCurrent := false
|
|
for _, item := range list.Messages {
|
|
body := strings.TrimSpace(item.Body)
|
|
if body == "" {
|
|
continue
|
|
}
|
|
if item.From.ID != domain.ChatBotUserID {
|
|
if cmd, ok := parseBotCommand(body); ok {
|
|
if cmd == "reset" {
|
|
lines = lines[:0]
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
if item.From.ID == domain.ChatBotUserID && chatBotCommandReply(body) {
|
|
continue
|
|
}
|
|
if msg.UID != 0 && item.UID == msg.UID {
|
|
sawCurrent = true
|
|
}
|
|
speaker := "User"
|
|
if item.From.ID == domain.ChatBotUserID {
|
|
speaker = "Assistant"
|
|
}
|
|
lines = append(lines, chatBotTranscriptLine(speaker, body))
|
|
}
|
|
if !sawCurrent && current != "" {
|
|
lines = append(lines, chatBotTranscriptLine("User", current))
|
|
}
|
|
}
|
|
}
|
|
if len(lines) == 0 && current != "" {
|
|
lines = append(lines, chatBotTranscriptLine("User", current))
|
|
}
|
|
return chatBotClampPrompt(lines)
|
|
}
|
|
|
|
func chatBotTranscriptLine(speaker, text string) string {
|
|
text = strings.Join(strings.Fields(text), " ")
|
|
text = truncateRunes(text, chatBotTranscriptLineLimit)
|
|
return speaker + ": " + text
|
|
}
|
|
|
|
func chatBotCommandReply(text string) bool {
|
|
text = strings.TrimSpace(text)
|
|
return chatBotHelpReply(text) || text == chatBotResetText || text == chatBotUnknownCommand || text == chatBotTextOnlyText
|
|
}
|
|
|
|
func chatBotHelpReply(text string) bool {
|
|
if !strings.HasPrefix(text, chatBotHelpPrefix) || !strings.HasSuffix(text, chatBotHelpSuffix) {
|
|
return false
|
|
}
|
|
brand := strings.TrimSuffix(strings.TrimPrefix(text, chatBotHelpPrefix), chatBotHelpSuffix)
|
|
return strings.TrimSpace(brand) != ""
|
|
}
|
|
|
|
func chatBotLooksLikePromptEcho(text, prompt string) bool {
|
|
text = strings.TrimSpace(text)
|
|
prompt = strings.TrimSpace(prompt)
|
|
if text == "" {
|
|
return false
|
|
}
|
|
if prompt != "" && (strings.HasPrefix(text, prompt) || (utf8.RuneCountInString(text) >= 64 && strings.HasPrefix(prompt, text))) {
|
|
return true
|
|
}
|
|
return strings.HasPrefix(text, "User: ") && strings.Contains(text, "\nAssistant:")
|
|
}
|
|
|
|
func chatBotClampPrompt(lines []string) string {
|
|
for len(lines) > 0 {
|
|
out := strings.Join(lines, "\n")
|
|
if utf8.RuneCountInString(out) <= domain.MaxAIComposeTextLength {
|
|
return out
|
|
}
|
|
lines = lines[1:]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
type chatBotDraftStreamer struct {
|
|
service *Service
|
|
userID int64
|
|
randomID int64
|
|
lastText string
|
|
lastFlush time.Time
|
|
drafts int
|
|
}
|
|
|
|
func (e *chatBotDraftStreamer) emit(ctx context.Context, text string, final bool) {
|
|
if e == nil || e.service == nil || e.service.textDrafts == nil || e.userID == 0 || e.randomID == 0 {
|
|
return
|
|
}
|
|
text = truncateRunes(strings.TrimSpace(text), domain.MaxMessageTextLength)
|
|
if text == "" || text == e.lastText {
|
|
return
|
|
}
|
|
if !final && !e.shouldFlush(text) {
|
|
return
|
|
}
|
|
e.service.textDrafts.PushBotTextDraft(ctx, domain.ChatBotUserID, e.userID, e.randomID, text)
|
|
e.lastText = text
|
|
e.lastFlush = e.service.now()
|
|
e.drafts++
|
|
}
|
|
|
|
func (e *chatBotDraftStreamer) shouldFlush(next string) bool {
|
|
if e.drafts == 0 {
|
|
return true
|
|
}
|
|
if e.drafts >= chatBotStreamMaxDrafts {
|
|
return false
|
|
}
|
|
throttle := e.service.chatBotStreamThrottle
|
|
if throttle <= 0 {
|
|
return true
|
|
}
|
|
if e.service.now().Sub(e.lastFlush) < throttle {
|
|
return false
|
|
}
|
|
return utf8.RuneCountInString(next)-utf8.RuneCountInString(e.lastText) >= chatBotStreamMinDeltaRunes
|
|
}
|
|
|
|
func truncateRunes(text string, limit int) string {
|
|
if limit <= 0 || utf8.RuneCountInString(text) <= limit {
|
|
return text
|
|
}
|
|
var b strings.Builder
|
|
b.Grow(len(text))
|
|
count := 0
|
|
for _, r := range text {
|
|
if count >= limit {
|
|
break
|
|
}
|
|
b.WriteRune(r)
|
|
count++
|
|
}
|
|
return b.String()
|
|
}
|