up
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
package autoreply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// BotServiceAdapter adapts the bot service to the BotSender interface
|
||||
type BotServiceAdapter struct {
|
||||
sendTextFn func(ctx context.Context, botID uint64, toNodeNum int64, text string) error
|
||||
}
|
||||
|
||||
// NewBotServiceAdapter creates a new bot service adapter
|
||||
func NewBotServiceAdapter(
|
||||
sendTextFn func(ctx context.Context, botID uint64, toNodeNum int64, text string) error,
|
||||
) *BotServiceAdapter {
|
||||
return &BotServiceAdapter{
|
||||
sendTextFn: sendTextFn,
|
||||
}
|
||||
}
|
||||
|
||||
// SendText sends a text message via the bot service
|
||||
func (a *BotServiceAdapter) SendText(ctx context.Context, botID uint64, toNodeNum int64, text string) error {
|
||||
if a.sendTextFn == nil {
|
||||
return fmt.Errorf("send text function is nil")
|
||||
}
|
||||
return a.sendTextFn(ctx, botID, toNodeNum, text)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package autoreply
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
statusPending = "pending"
|
||||
statusProcessing = "processing"
|
||||
statusProcessed = "processed"
|
||||
statusFailed = "error"
|
||||
)
|
||||
|
||||
// DBMessageQueue implements MessageQueue using GORM
|
||||
type DBMessageQueue struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewDBMessageQueue creates a new database-backed message queue
|
||||
func NewDBMessageQueue(db *gorm.DB) *DBMessageQueue {
|
||||
return &DBMessageQueue{db: db}
|
||||
}
|
||||
|
||||
// llmMessageQueueRecord is the database record for LLM messages
|
||||
type llmMessageQueueRecord struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
BotID uint64 `gorm:"column:bot_id;not null;index"`
|
||||
BotNodeID string `gorm:"column:bot_node_id;not null"`
|
||||
BotNodeNum int64 `gorm:"column:bot_node_num;not null"`
|
||||
FromNodeID string `gorm:"column:from_node_id;not null"`
|
||||
FromNodeNum int64 `gorm:"column:from_node_num;not null"`
|
||||
LongName *string `gorm:"column:long_name"`
|
||||
ShortName *string `gorm:"column:short_name"`
|
||||
Text string `gorm:"column:text;type:text;not null"`
|
||||
PacketID int64 `gorm:"column:packet_id;not null"`
|
||||
ChannelID *string `gorm:"column:channel_id"`
|
||||
Topic string `gorm:"column:topic;not null"`
|
||||
Status string `gorm:"column:status;not null;index"`
|
||||
Error string `gorm:"column:error;type:text"`
|
||||
Reply string `gorm:"column:reply;type:text"`
|
||||
ReceivedAt time.Time `gorm:"column:received_at;not null"`
|
||||
ProcessedAt *time.Time `gorm:"column:processed_at;index"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index"`
|
||||
}
|
||||
|
||||
func (llmMessageQueueRecord) TableName() string {
|
||||
return "llm_message_queue"
|
||||
}
|
||||
|
||||
// GetPendingMessages returns pending messages from the queue
|
||||
func (q *DBMessageQueue) GetPendingMessages(botID uint64, limit int) ([]QueuedMessage, error) {
|
||||
var records []llmMessageQueueRecord
|
||||
query := q.db.Where("status = ?", statusPending).Order("created_at ASC")
|
||||
if botID > 0 {
|
||||
query = query.Where("bot_id = ?", botID)
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
if err := query.Find(&records).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to query pending messages: %w", err)
|
||||
}
|
||||
|
||||
messages := make([]QueuedMessage, 0, len(records))
|
||||
for _, r := range records {
|
||||
messages = append(messages, QueuedMessage{
|
||||
ID: r.ID,
|
||||
BotID: r.BotID,
|
||||
BotNodeID: r.BotNodeID,
|
||||
BotNodeNum: r.BotNodeNum,
|
||||
FromNodeID: r.FromNodeID,
|
||||
FromNodeNum: r.FromNodeNum,
|
||||
LongName: r.LongName,
|
||||
ShortName: r.ShortName,
|
||||
Text: r.Text,
|
||||
PacketID: r.PacketID,
|
||||
ChannelID: r.ChannelID,
|
||||
Topic: r.Topic,
|
||||
ReceivedAt: r.ReceivedAt,
|
||||
})
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// MarkAsProcessing marks a message as being processed
|
||||
func (q *DBMessageQueue) MarkAsProcessing(id uint64) error {
|
||||
return q.db.Model(&llmMessageQueueRecord{}).Where("id = ?", id).Update("status", statusProcessing).Error
|
||||
}
|
||||
|
||||
// MarkAsProcessed marks a message as successfully processed
|
||||
func (q *DBMessageQueue) MarkAsProcessed(id uint64, reply string) error {
|
||||
now := time.Now()
|
||||
return q.db.Model(&llmMessageQueueRecord{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"status": statusProcessed,
|
||||
"reply": reply,
|
||||
"processed_at": &now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// MarkAsFailed marks a message as failed
|
||||
func (q *DBMessageQueue) MarkAsFailed(id uint64, error string) error {
|
||||
return q.db.Model(&llmMessageQueueRecord{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"status": statusFailed,
|
||||
"error": error,
|
||||
}).Error
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package autoreply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"meshtastic_mqtt_server/completion"
|
||||
"meshtastic_mqtt_server/conversation"
|
||||
"meshtastic_mqtt_server/llm"
|
||||
"meshtastic_mqtt_server/message"
|
||||
"meshtastic_mqtt_server/toolmanager"
|
||||
"meshtastic_mqtt_server/toolrouter"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxReplyLength is the maximum length for Meshtastic messages
|
||||
MaxReplyLength = 200
|
||||
// PollInterval is how often to check the queue for new messages
|
||||
PollInterval = 5 * time.Second
|
||||
// MaxProcessingTime is the maximum time to spend processing a single message
|
||||
MaxProcessingTime = 120 * time.Second
|
||||
)
|
||||
|
||||
// MessageQueue is the interface for accessing the LLM message queue
|
||||
type MessageQueue interface {
|
||||
// GetPendingMessages returns pending messages for a bot
|
||||
GetPendingMessages(botID uint64, limit int) ([]QueuedMessage, error)
|
||||
// MarkAsProcessing marks a message as being processed
|
||||
MarkAsProcessing(id uint64) error
|
||||
// MarkAsProcessed marks a message as successfully processed
|
||||
MarkAsProcessed(id uint64, reply string) error
|
||||
// MarkAsFailed marks a message as failed
|
||||
MarkAsFailed(id uint64, error string) error
|
||||
}
|
||||
|
||||
// QueuedMessage represents a message in the LLM queue
|
||||
type QueuedMessage struct {
|
||||
ID uint64
|
||||
BotID uint64
|
||||
BotNodeID string
|
||||
BotNodeNum int64
|
||||
FromNodeID string
|
||||
FromNodeNum int64
|
||||
LongName *string
|
||||
ShortName *string
|
||||
Text string
|
||||
PacketID int64
|
||||
ChannelID *string
|
||||
Topic string
|
||||
ReceivedAt time.Time
|
||||
}
|
||||
|
||||
// BotSender is the interface for sending bot messages
|
||||
type BotSender interface {
|
||||
SendText(ctx context.Context, botID uint64, toNodeNum int64, text string) error
|
||||
}
|
||||
|
||||
// Service manages automatic AI replies for bots
|
||||
type Service struct {
|
||||
llmState *llm.State
|
||||
toolRouter *toolrouter.State
|
||||
toolMgr *toolmanager.Manager
|
||||
convStore *conversation.Store
|
||||
msgQueue MessageQueue
|
||||
botSender BotSender
|
||||
|
||||
running bool
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewService creates a new auto-reply service
|
||||
func NewService(
|
||||
llmState *llm.State,
|
||||
toolRouter *toolrouter.State,
|
||||
toolMgr *toolmanager.Manager,
|
||||
convStore *conversation.Store,
|
||||
msgQueue MessageQueue,
|
||||
botSender BotSender,
|
||||
) *Service {
|
||||
return &Service{
|
||||
llmState: llmState,
|
||||
toolRouter: toolRouter,
|
||||
toolMgr: toolMgr,
|
||||
convStore: convStore,
|
||||
msgQueue: msgQueue,
|
||||
botSender: botSender,
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the auto-reply service
|
||||
func (s *Service) Start(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.running {
|
||||
return fmt.Errorf("auto-reply service is already running")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
s.cancel = cancel
|
||||
s.running = true
|
||||
|
||||
s.wg.Add(1)
|
||||
go s.run(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the auto-reply service
|
||||
func (s *Service) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.running {
|
||||
return
|
||||
}
|
||||
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
s.wg.Wait()
|
||||
s.running = false
|
||||
}
|
||||
|
||||
// run is the main processing loop
|
||||
func (s *Service) run(ctx context.Context) {
|
||||
defer s.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(PollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.processQueue(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processQueue processes pending messages in the queue
|
||||
func (s *Service) processQueue(ctx context.Context) {
|
||||
// Get all bots (we'd typically get this from bot store, but for now
|
||||
// we'll rely on the queue to provide messages per bot)
|
||||
// For now, process up to 10 messages at a time
|
||||
messages, err := s.msgQueue.GetPendingMessages(0, 10)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
s.processMessage(ctx, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processMessage processes a single queued message
|
||||
func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) {
|
||||
// Mark message as processing
|
||||
if err := s.msgQueue.MarkAsProcessing(msg.ID); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Create processing context with timeout
|
||||
procCtx, cancel := context.WithTimeout(ctx, MaxProcessingTime)
|
||||
defer cancel()
|
||||
|
||||
// Get or create conversation for this bot
|
||||
conv, err := s.convStore.GetOrCreateForBot(msg.BotID, msg.BotNodeID, msg.FromNodeID)
|
||||
if err != nil {
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, fmt.Sprintf("failed to get conversation: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Add the user message to the conversation
|
||||
userMsg := message.ChatMessage{
|
||||
Role: "user",
|
||||
Content: s.formatUserMessage(msg),
|
||||
}
|
||||
if err := s.convStore.AddMessage(conv.ID, userMsg); err != nil {
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, fmt.Sprintf("failed to add message: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Get the LLM profile
|
||||
profile := s.llmState.ActiveProfile()
|
||||
if profile == nil {
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, "no active LLM profile")
|
||||
return
|
||||
}
|
||||
|
||||
// Reload conversation to get updated messages
|
||||
conv, err = s.convStore.Get(conv.ID)
|
||||
if err != nil {
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, fmt.Sprintf("failed to reload conversation: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Run the tool loop to get augmented messages
|
||||
augmentedMessages, err := toolrouter.RunAgentToolLoop(procCtx, s.toolRouter, profile, conv.Messages, s.toolMgr, nil)
|
||||
_ = augmentedMessages // We'll use this in the future with proper tool support
|
||||
|
||||
// For now, use simple completion since we don't have tools registered yet
|
||||
reply, err := completion.CompleteText(procCtx, profile, conv.Messages, 512)
|
||||
if err != nil {
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, fmt.Sprintf("LLM completion failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Truncate reply for Meshtastic
|
||||
if len([]byte(reply)) > MaxReplyLength {
|
||||
reply = string([]byte(reply)[:MaxReplyLength-3]) + "..."
|
||||
}
|
||||
|
||||
// Add assistant reply to conversation
|
||||
assistantMsg := message.ChatMessage{
|
||||
Role: "assistant",
|
||||
Content: reply,
|
||||
}
|
||||
if err := s.convStore.AddMessage(conv.ID, assistantMsg); err != nil {
|
||||
// Non-fatal, continue
|
||||
}
|
||||
|
||||
// Send the reply via the bot
|
||||
if err := s.botSender.SendText(procCtx, msg.BotID, msg.FromNodeNum, reply); err != nil {
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, fmt.Sprintf("failed to send reply: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Mark message as processed
|
||||
_ = s.msgQueue.MarkAsProcessed(msg.ID, reply)
|
||||
}
|
||||
|
||||
// formatUserMessage formats the incoming message for the LLM
|
||||
func (s *Service) formatUserMessage(msg QueuedMessage) string {
|
||||
var sb strings.Builder
|
||||
|
||||
if msg.LongName != nil && *msg.LongName != "" {
|
||||
sb.WriteString(fmt.Sprintf("[来自 %s (%s)] ", *msg.LongName, msg.FromNodeID))
|
||||
} else if msg.ShortName != nil && *msg.ShortName != "" {
|
||||
sb.WriteString(fmt.Sprintf("[来自 %s (%s)] ", *msg.ShortName, msg.FromNodeID))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("[来自 %s] ", msg.FromNodeID))
|
||||
}
|
||||
|
||||
sb.WriteString(msg.Text)
|
||||
return sb.String()
|
||||
}
|
||||
Reference in New Issue
Block a user