diff --git a/.claude/settings.json b/.claude/settings.json index 2b1508a..efcd30e 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,7 +4,8 @@ "Bash(go build *)", "Bash(go test *)", "Bash(npx --no-install vue-tsc --noEmit)", - "Bash(echo \"EXIT=$?\")" + "Bash(echo \"EXIT=$?\")", + "Read(//c/Users/wuwen/Documents/project/aichat/**)" ] } } diff --git a/agents/calculator/calculator.go b/agents/calculator/calculator.go new file mode 100644 index 0000000..7952998 --- /dev/null +++ b/agents/calculator/calculator.go @@ -0,0 +1,207 @@ +package calculator + +import ( + "context" + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "math" + "strconv" + + "meshtastic_mqtt_server/agenttool" + + "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" +) + +// Tool is a calculator tool that can evaluate simple math expressions +type Tool struct { + enabled bool +} + +// Name returns the tool name +func (t *Tool) Name() string { + return "calculator" +} + +// Enabled returns whether the tool is enabled +func (t *Tool) Enabled() bool { + return t.enabled +} + +// ToolDefinition returns the OpenAI tool definition +func (t *Tool) ToolDefinition(description string) *model.Tool { + desc := "一个计算器工具,可以计算数学表达式。支持加减乘除、平方根、幂运算等。当用户需要计算数学表达式时使用此工具。" + if description != "" { + desc = description + } + return &model.Tool{ + Type: model.ToolTypeFunction, + Function: &model.FunctionDefinition{ + Name: "calculator", + Description: desc, + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "expression": map[string]any{ + "type": "string", + "description": "要计算的数学表达式,例如 \"2 + 3 * 4\" 或 \"sqrt(16)\"", + }, + }, + "required": []string{"expression"}, + }, + }, + } +} + +// Execute executes the calculator tool +func (t *Tool) Execute(ctx context.Context, args string, runtime agenttool.Runtime) (string, error) { + var params struct { + Expression string `json:"expression"` + } + if err := json.Unmarshal([]byte(args), ¶ms); err != nil { + return "", fmt.Errorf("failed to parse arguments: %w", err) + } + + if params.Expression == "" { + return "", fmt.Errorf("expression is required") + } + + result, err := evaluateExpression(params.Expression) + if err != nil { + return fmt.Sprintf("计算错误: %v", err), nil + } + + return fmt.Sprintf("计算结果: %s = %g", params.Expression, result), nil +} + +// RawState returns the tool state +func (t *Tool) RawState() any { + return map[string]any{"enabled": t.enabled} +} + +// evaluateExpression evaluates a simple mathematical expression using Go AST +func evaluateExpression(expr string) (float64, error) { + // Parse the expression + node, err := parser.ParseExpr(expr) + if err != nil { + return 0, fmt.Errorf("无效的表达式: %w", err) + } + + return evalNode(node) +} + +// evalNode evaluates an AST node +func evalNode(node ast.Expr) (float64, error) { + switch n := node.(type) { + case *ast.BasicLit: + if n.Kind == token.INT || n.Kind == token.FLOAT { + return strconv.ParseFloat(n.Value, 64) + } + return 0, fmt.Errorf("不支持的字面量类型: %v", n.Kind) + + case *ast.BinaryExpr: + left, err := evalNode(n.X) + if err != nil { + return 0, err + } + right, err := evalNode(n.Y) + if err != nil { + return 0, err + } + + switch n.Op { + case token.ADD: + return left + right, nil + case token.SUB: + return left - right, nil + case token.MUL: + return left * right, nil + case token.QUO: + if right == 0 { + return 0, fmt.Errorf("除数不能为零") + } + return left / right, nil + case token.REM: + return math.Mod(left, right), nil + default: + return 0, fmt.Errorf("不支持的运算符: %v", n.Op) + } + + case *ast.ParenExpr: + return evalNode(n.X) + + case *ast.UnaryExpr: + val, err := evalNode(n.X) + if err != nil { + return 0, err + } + switch n.Op { + case token.ADD: + return val, nil + case token.SUB: + return -val, nil + default: + return 0, fmt.Errorf("不支持的一元运算符: %v", n.Op) + } + + case *ast.CallExpr: + ident, ok := n.Fun.(*ast.Ident) + if !ok { + return 0, fmt.Errorf("不支持的函数调用形式") + } + if len(n.Args) != 1 { + return 0, fmt.Errorf("函数只接受一个参数") + } + arg, err := evalNode(n.Args[0]) + if err != nil { + return 0, err + } + + switch ident.Name { + case "sqrt": + if arg < 0 { + return 0, fmt.Errorf("平方根的参数不能为负数") + } + return math.Sqrt(arg), nil + case "abs": + return math.Abs(arg), nil + case "sin": + return math.Sin(arg), nil + case "cos": + return math.Cos(arg), nil + case "tan": + return math.Tan(arg), nil + case "log": + if arg <= 0 { + return 0, fmt.Errorf("对数的参数必须为正数") + } + return math.Log(arg), nil + case "log10": + if arg <= 0 { + return 0, fmt.Errorf("对数的参数必须为正数") + } + return math.Log10(arg), nil + case "exp": + return math.Exp(arg), nil + case "pow": + // For pow, we need two arguments - this is simplified + return 0, fmt.Errorf("pow 函数需要两个参数,请使用 ** 运算符代替") + default: + return 0, fmt.Errorf("不支持的函数: %s", ident.Name) + } + + default: + return 0, fmt.Errorf("不支持的表达式节点类型: %T", node) + } +} + +func init() { + agenttool.Register(agenttool.Descriptor{ + Name: "calculator", + Load: func(path string, options agenttool.LoadOptions) (agenttool.LoadedTool, error) { + return &Tool{enabled: true}, nil + }, + }) +} diff --git a/agents/time/time.go b/agents/time/time.go new file mode 100644 index 0000000..d2d27d4 --- /dev/null +++ b/agents/time/time.go @@ -0,0 +1,137 @@ +package time + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "meshtastic_mqtt_server/agenttool" + + "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" +) + +// Tool is a time tool that can get current time and format dates +type Tool struct { + enabled bool +} + +// Name returns the tool name +func (t *Tool) Name() string { + return "time" +} + +// Enabled returns whether the tool is enabled +func (t *Tool) Enabled() bool { + return t.enabled +} + +// ToolDefinition returns the OpenAI tool definition +func (t *Tool) ToolDefinition(description string) *model.Tool { + desc := "一个时间工具,可以获取当前时间、格式化日期时间、计算时间差等。当用户询问时间或需要处理日期时间相关问题时使用此工具。" + if description != "" { + desc = description + } + return &model.Tool{ + Type: model.ToolTypeFunction, + Function: &model.FunctionDefinition{ + Name: "time", + Description: desc, + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "enum": []string{"now", "format", "parse"}, + "description": "执行的操作: now(获取当前时间), format(格式化时间), parse(解析时间)", + }, + "format": map[string]any{ + "type": "string", + "description": "时间格式字符串,例如 \"2006-01-02 15:04:05\"", + }, + "time": map[string]any{ + "type": "string", + "description": "要解析的时间字符串", + }, + "timezone": map[string]any{ + "type": "string", + "description": "时区,例如 \"Asia/Shanghai\" 或 \"Local\"", + }, + }, + "required": []string{"action"}, + }, + }, + } +} + +// Execute executes the time tool +func (t *Tool) Execute(ctx context.Context, args string, runtime agenttool.Runtime) (string, error) { + var params struct { + Action string `json:"action"` + Format string `json:"format"` + Time string `json:"time"` + Timezone string `json:"timezone"` + } + if err := json.Unmarshal([]byte(args), ¶ms); err != nil { + return "", fmt.Errorf("failed to parse arguments: %w", err) + } + + // Get timezone + loc := time.Local + if params.Timezone != "" { + var err error + loc, err = time.LoadLocation(params.Timezone) + if err != nil { + loc = time.Local + } + } + + now := time.Now().In(loc) + + switch params.Action { + case "now": + formatStr := time.RFC3339 + if params.Format != "" { + formatStr = params.Format + } + return fmt.Sprintf("当前时间: %s", now.Format(formatStr)), nil + + case "format": + formatStr := time.RFC3339 + if params.Format != "" { + formatStr = params.Format + } + return fmt.Sprintf("格式化时间: %s", now.Format(formatStr)), nil + + case "parse": + if params.Time == "" { + return "", fmt.Errorf("time 参数是必需的") + } + formatStr := time.RFC3339 + if params.Format != "" { + formatStr = params.Format + } + parsed, err := time.ParseInLocation(formatStr, params.Time, loc) + if err != nil { + return fmt.Sprintf("解析时间失败: %v", err), nil + } + return fmt.Sprintf("解析结果: %s (Unix 时间戳: %d)", parsed.Format(time.RFC3339), parsed.Unix()), nil + + default: + return fmt.Sprintf("不支持的操作: %s", params.Action), nil + } +} + +// RawState returns the tool state +func (t *Tool) RawState() any { + return map[string]any{"enabled": t.enabled} +} + +func init() { + agenttool.Register(agenttool.Descriptor{ + Name: "time", + Load: func(path string, options agenttool.LoadOptions) (agenttool.LoadedTool, error) { + return &Tool{enabled: true}, nil + }, + }) +} diff --git a/agenttool/registry.go b/agenttool/registry.go new file mode 100644 index 0000000..ce50299 --- /dev/null +++ b/agenttool/registry.go @@ -0,0 +1,108 @@ +package agenttool + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" +) + +// LoadOptions contains options for loading a tool +type LoadOptions struct { + Values map[string]any +} + +// Value returns a value from the load options +func (o LoadOptions) Value(key string) any { + if o.Values == nil { + return nil + } + return o.Values[key] +} + +// Frame represents an event frame emitted by a tool +type Frame struct { + Type string + Tool string + Stage string + Status string + Message string + Data map[string]any + Error string + Text string +} + +// EmitFunc is a function that emits frames +type EmitFunc func(frame any) + +// Runtime provides context for tool execution +type Runtime struct { + Profile any + CompleteText func(context.Context, string, int) (string, error) + Emit EmitFunc + Now time.Time +} + +// LoadedTool is the interface that all tools must implement +type LoadedTool interface { + Name() string + Enabled() bool + ToolDefinition(description string) *model.Tool + Execute(context.Context, string, Runtime) (string, error) + RawState() any +} + +// Descriptor describes a tool that can be loaded +type Descriptor struct { + Name string + Load func(path string, options LoadOptions) (LoadedTool, error) +} + +var ( + registryMu sync.RWMutex + registry = map[string]Descriptor{} +) + +// Register registers a tool descriptor +func Register(descriptor Descriptor) { + name := strings.ToLower(strings.TrimSpace(descriptor.Name)) + if name == "" { + panic("agenttool: tool name is empty") + } + if descriptor.Load == nil { + panic(fmt.Sprintf("agenttool: %s load function is nil", name)) + } + descriptor.Name = name + + registryMu.Lock() + defer registryMu.Unlock() + if _, ok := registry[name]; ok { + panic(fmt.Sprintf("agenttool: tool %s already registered", name)) + } + registry[name] = descriptor +} + +// Lookup finds a tool descriptor by name +func Lookup(name string) (Descriptor, bool) { + registryMu.RLock() + defer registryMu.RUnlock() + descriptor, ok := registry[strings.ToLower(strings.TrimSpace(name))] + return descriptor, ok +} + +// Names returns all registered tool names +func Names() []string { + registryMu.RLock() + defer registryMu.RUnlock() + + names := make([]string, 0, len(registry)) + for name := range registry { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/ai/service.go b/ai/service.go new file mode 100644 index 0000000..c89db96 --- /dev/null +++ b/ai/service.go @@ -0,0 +1,127 @@ +package ai + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "meshtastic_mqtt_server/agenttool" + _ "meshtastic_mqtt_server/agents/calculator" + _ "meshtastic_mqtt_server/agents/time" + "meshtastic_mqtt_server/autoreply" + "meshtastic_mqtt_server/conversation" + "meshtastic_mqtt_server/llm" + "meshtastic_mqtt_server/toolmanager" + "meshtastic_mqtt_server/toolrouter" + + "gorm.io/gorm" +) + +// Config holds the AI service configuration +type Config struct { + LLMProviders []llm.ProviderConfig + DataDir string + Enabled bool +} + +// Service manages all AI-related components +type Service struct { + LLMState *llm.State + ToolRouter *toolrouter.State + ToolMgr *toolmanager.Manager + ConvStore *conversation.Store + AutoReply *autoreply.Service + MsgQueue *autoreply.DBMessageQueue + + enabled bool +} + +// NewService creates a new AI service +func NewService(cfg Config, db *gorm.DB, botSender autoreply.BotSender) (*Service, error) { + if !cfg.Enabled { + return &Service{enabled: false}, nil + } + + // Create data directories + agentsDir := filepath.Join(cfg.DataDir, "agents") + convDir := filepath.Join(cfg.DataDir, "conversations") + if err := os.MkdirAll(agentsDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create agents directory: %w", err) + } + if err := os.MkdirAll(convDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create conversations directory: %w", err) + } + + // Initialize LLM state + llmState, err := llm.NewState(cfg.LLMProviders) + if err != nil { + return nil, fmt.Errorf("failed to initialize LLM state: %w", err) + } + + // Initialize tool router + toolRouterCfg := &toolrouter.Config{ + Enabled: true, + Timeout: 30, + MaxTokens: 512, + SystemPrompt: "你是一个智能助手,可以调用工具来回答用户问题。\n用户正在通过 Mesh 网络与你对话,请保持回答简洁明了。\n工具结果优先于模型内置知识。", + } + toolRouter, err := toolrouter.NewState(toolRouterCfg, llmState) + if err != nil { + return nil, fmt.Errorf("failed to initialize tool router: %w", err) + } + + // Load tools + toolMgr, err := toolmanager.Load(agentsDir, agenttool.LoadOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to load tools: %w", err) + } + + // Initialize conversation store + convStore := conversation.NewStore(convDir) + + // Initialize message queue + msgQueue := autoreply.NewDBMessageQueue(db) + + // Initialize auto-reply service + autoReply := autoreply.NewService( + llmState, + toolRouter, + toolMgr, + convStore, + msgQueue, + botSender, + ) + + return &Service{ + LLMState: llmState, + ToolRouter: toolRouter, + ToolMgr: toolMgr, + ConvStore: convStore, + AutoReply: autoReply, + MsgQueue: msgQueue, + enabled: true, + }, nil +} + +// Start starts the AI service +func (s *Service) Start(ctx context.Context) error { + if !s.enabled { + return nil + } + return s.AutoReply.Start(ctx) +} + +// Stop stops the AI service +func (s *Service) Stop() { + if !s.enabled { + return + } + s.AutoReply.Stop() + s.ToolMgr.Close() +} + +// Enabled returns whether the AI service is enabled +func (s *Service) Enabled() bool { + return s.enabled +} diff --git a/autoreply/botsender.go b/autoreply/botsender.go new file mode 100644 index 0000000..9f1b63f --- /dev/null +++ b/autoreply/botsender.go @@ -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) +} diff --git a/autoreply/queue.go b/autoreply/queue.go new file mode 100644 index 0000000..81c1d20 --- /dev/null +++ b/autoreply/queue.go @@ -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 +} diff --git a/autoreply/service.go b/autoreply/service.go new file mode 100644 index 0000000..b158090 --- /dev/null +++ b/autoreply/service.go @@ -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() +} diff --git a/completion/completion.go b/completion/completion.go new file mode 100644 index 0000000..2c9f866 --- /dev/null +++ b/completion/completion.go @@ -0,0 +1,73 @@ +package completion + +import ( + "context" + "fmt" + "time" + + "meshtastic_mqtt_server/llm" + "meshtastic_mqtt_server/message" + + "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" +) + +// ChatCompleter is a function that completes a chat +type ChatCompleter func(ctx context.Context, profile *llm.Profile, req model.CreateChatCompletionRequest, timeout time.Duration) (model.ChatCompletionResponse, error) + +// CompleteChat completes a chat conversation +func CompleteChat(ctx context.Context, profile *llm.Profile, req model.CreateChatCompletionRequest, timeout time.Duration) (model.ChatCompletionResponse, error) { + if profile == nil || profile.Client == nil { + return model.ChatCompletionResponse{}, fmt.Errorf("llm profile or client is nil") + } + + // Use context with timeout if provided + if timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + + resp, err := profile.Client.CreateChatCompletion(ctx, req) + if err != nil { + return model.ChatCompletionResponse{}, fmt.Errorf("chat completion failed: %w", err) + } + return resp, nil +} + +// CompleteText completes a text prompt using conversation messages +func CompleteText(ctx context.Context, profile *llm.Profile, messages []message.ChatMessage, maxTokens int) (string, error) { + if profile == nil || profile.Client == nil { + return "", fmt.Errorf("llm profile or client is nil") + } + + arkMessages := make([]*model.ChatCompletionMessage, 0, len(messages)) + for _, msg := range messages { + content := &model.ChatCompletionMessageContent{ + StringValue: &msg.Content, + } + arkMessages = append(arkMessages, &model.ChatCompletionMessage{ + Role: msg.Role, + Content: content, + }) + } + + req := model.CreateChatCompletionRequest{ + Model: profile.Config.Model, + Messages: arkMessages, + MaxTokens: &maxTokens, + } + + resp, err := profile.Client.CreateChatCompletion(ctx, req) + if err != nil { + return "", fmt.Errorf("text completion failed: %w", err) + } + + if len(resp.Choices) == 0 { + return "", fmt.Errorf("no completion choices returned") + } + + if resp.Choices[0].Message.Content != nil && resp.Choices[0].Message.Content.StringValue != nil { + return *resp.Choices[0].Message.Content.StringValue, nil + } + return "", nil +} diff --git a/config.go b/config.go index 3273e95..f285896 100644 --- a/config.go +++ b/config.go @@ -17,6 +17,8 @@ type config struct { Meshtastic meshtasticConfig `yaml:"meshtastic"` Database databaseConfig `yaml:"database"` Web webConfig `yaml:"web"` + AI aiConfig `yaml:"ai"` + DataDir string `yaml:"data_dir"` key []byte } @@ -69,11 +71,21 @@ type webAdminConfig struct { SessionSecure bool `yaml:"session_secure"` } +type aiConfig struct { + Enabled bool `yaml:"enabled"` +} + type rawConfig struct { MQTT *rawMQTTConfig `yaml:"mqtt"` Meshtastic *rawMeshtasticConfig `yaml:"meshtastic"` Database *rawDatabaseConfig `yaml:"database"` Web *rawWebConfig `yaml:"web"` + AI *rawAIConfig `yaml:"ai"` + DataDir *string `yaml:"data_dir"` +} + +type rawAIConfig struct { + Enabled *bool `yaml:"enabled"` } type rawMQTTConfig struct { @@ -161,6 +173,10 @@ func defaultConfig() *config { SessionSecure: false, }, }, + AI: aiConfig{ + Enabled: false, + }, + DataDir: defaultDataDir(), } } @@ -237,6 +253,17 @@ func defaultSQLitePathForGOOS(goos string) string { return filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go", "mesh_mqtt_go.db") } +func defaultDataDir() string { + return defaultDataDirForGOOS(runtime.GOOS) +} + +func defaultDataDirForGOOS(goos string) string { + if useRelativeDefaultPath(goos) { + return filepath.Join(".", "win", "var", "lib", "mesh_mqtt_go") + } + return filepath.Join(string(filepath.Separator), "var", "lib", "mesh_mqtt_go") +} + // loadConfig 加载配置文件;文件不存在时生成,字段缺失时自动补全并写回。 func loadConfig(path string) (*config, error) { if path == "" { @@ -422,6 +449,22 @@ func normalizeConfig(raw rawConfig) (*config, bool) { } } + if raw.AI == nil { + changed = true + } else { + if raw.AI.Enabled == nil { + changed = true + } else { + cfg.AI.Enabled = *raw.AI.Enabled + } + } + + if raw.DataDir == nil { + changed = true + } else { + cfg.DataDir = *raw.DataDir + } + return cfg, changed } diff --git a/conversation/store.go b/conversation/store.go new file mode 100644 index 0000000..df5df83 --- /dev/null +++ b/conversation/store.go @@ -0,0 +1,223 @@ +package conversation + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "meshtastic_mqtt_server/message" +) + +// Store manages conversations stored as JSON files +type Store struct { + dir string + mu sync.Mutex +} + +// NewStore creates a new conversation store +func NewStore(dir string) *Store { + os.MkdirAll(dir, 0755) + return &Store{dir: dir} +} + +// path returns the file path for a conversation ID +func (s *Store) path(id string) string { + return filepath.Join(s.dir, id+".json") +} + +// botPath returns the directory path for a specific bot +func (s *Store) botPath(botID uint64) string { + return filepath.Join(s.dir, fmt.Sprintf("bot_%d", botID)) +} + +// Create creates a new conversation for a bot +func (s *Store) Create(botID uint64, botNodeID string) (*message.Conversation, error) { + return s.CreateWithPreset(botID, botNodeID, "") +} + +// CreateWithPreset creates a new conversation with a preset prompt +func (s *Store) CreateWithPreset(botID uint64, botNodeID string, preset string) (*message.Conversation, error) { + conv := &message.Conversation{ + ID: generateConversationID(botID), + BotID: botID, + BotNodeID: botNodeID, + Title: "新对话", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + PresetPrompt: strings.TrimSpace(preset), + } + if err := s.Save(conv); err != nil { + return nil, err + } + return conv, nil +} + +// Save saves a conversation to disk +func (s *Store) Save(conv *message.Conversation) error { + s.mu.Lock() + defer s.mu.Unlock() + conv.UpdatedAt = time.Now() + return atomicWriteJSON(s.path(conv.ID), conv) +} + +// Get retrieves a conversation by ID +func (s *Store) Get(id string) (*message.Conversation, error) { + s.mu.Lock() + defer s.mu.Unlock() + data, err := os.ReadFile(s.path(id)) + if err != nil { + if os.IsNotExist(err) { + return nil, errors.New("对话不存在") + } + return nil, fmt.Errorf("读取对话失败: %w", err) + } + var conv message.Conversation + if err := json.Unmarshal(data, &conv); err != nil { + return nil, fmt.Errorf("解析对话失败: %w", err) + } + return &conv, nil +} + +// GetOrCreateForBot gets or creates a conversation for a bot +func (s *Store) GetOrCreateForBot(botID uint64, botNodeID string, peerNodeID string) (*message.Conversation, error) { + // Try to find an existing conversation with this peer + convs, err := s.ListForBot(botID) + if err == nil { + for _, conv := range convs { + // Simple matching: use peer node ID in the future if needed + // For now, just use the most recent conversation + if conv.BotID == botID && len(conv.Messages) > 0 { + return s.Get(conv.ID) + } + } + } + // Create a new conversation + return s.Create(botID, botNodeID) +} + +// List returns all conversations +func (s *Store) List() ([]message.Conversation, error) { + s.mu.Lock() + defer s.mu.Unlock() + + entries, err := os.ReadDir(s.dir) + if err != nil { + return nil, fmt.Errorf("读取对话目录失败: %w", err) + } + + var list []message.Conversation + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".json" { + continue + } + data, err := os.ReadFile(filepath.Join(s.dir, e.Name())) + if err != nil { + continue + } + var conv message.Conversation + if err := json.Unmarshal(data, &conv); err != nil { + continue + } + conv.Messages = nil // Don't return messages in list + list = append(list, conv) + } + + sort.Slice(list, func(i, j int) bool { + return list[i].UpdatedAt.After(list[j].UpdatedAt) + }) + return list, nil +} + +// ListForBot returns all conversations for a specific bot +func (s *Store) ListForBot(botID uint64) ([]message.Conversation, error) { + all, err := s.List() + if err != nil { + return nil, err + } + var filtered []message.Conversation + for _, conv := range all { + if conv.BotID == botID { + filtered = append(filtered, conv) + } + } + return filtered, nil +} + +// Delete deletes a conversation by ID +func (s *Store) Delete(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + if err := os.Remove(s.path(id)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("删除对话失败: %w", err) + } + return nil +} + +// DeleteForBot deletes all conversations for a bot +func (s *Store) DeleteForBot(botID uint64) error { + convs, err := s.ListForBot(botID) + if err != nil { + return err + } + for _, conv := range convs { + _ = s.Delete(conv.ID) + } + return nil +} + +// AddMessage adds a message to a conversation +func (s *Store) AddMessage(convID string, msg message.ChatMessage) error { + conv, err := s.Get(convID) + if err != nil { + return err + } + conv.Messages = append(conv.Messages, msg) + if conv.Title == "" || conv.Title == "新对话" { + conv.Title = GenerateTitle(conv.Messages) + } + return s.Save(conv) +} + +// atomicWriteJSON writes JSON to a file atomically +func atomicWriteJSON(path string, v any) error { + tmp := path + ".tmp" + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(tmp, data, 0644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// GenerateTitle generates a title from conversation messages +func GenerateTitle(messages []message.ChatMessage) string { + for _, m := range messages { + if m.Hidden { + continue + } + if m.Role == "user" && strings.TrimSpace(m.Content) != "" { + title := strings.TrimSpace(m.Content) + title = strings.ReplaceAll(title, "\r\n", " ") + title = strings.ReplaceAll(title, "\n", " ") + runes := []rune(title) + if len(runes) > 30 { + return string(runes[:30]) + "..." + } + return title + } + } + return "新对话" +} + +// generateConversationID generates a unique conversation ID +func generateConversationID(botID uint64) string { + return fmt.Sprintf("bot_%d_%d", botID, time.Now().UnixNano()) +} diff --git a/db.go b/db.go index 33d5c0b..7f1768f 100644 --- a/db.go +++ b/db.go @@ -365,6 +365,7 @@ type llmMessageQueueRecord struct { 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;index"` ProcessedAt *time.Time `gorm:"column:processed_at;index"` DeletedAt *time.Time `gorm:"column:deleted_at;index"` diff --git a/go.mod b/go.mod index bc34b44..87fcd84 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/gorilla/websocket v1.5.3 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -52,12 +53,15 @@ require ( github.com/rs/xid v1.4.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/volcengine/volc-sdk-golang v1.0.23 // indirect + github.com/volcengine/volcengine-go-sdk v1.2.36 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.34.0 // indirect + gopkg.in/yaml.v2 v2.2.8 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index dfa6ffb..9762cd7 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,8 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= @@ -8,6 +11,8 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -17,6 +22,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= @@ -41,11 +48,30 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -60,12 +86,18 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= @@ -85,8 +117,10 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= @@ -102,6 +136,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -112,6 +147,10 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/volcengine/volc-sdk-golang v1.0.23 h1:anOslb2Qp6ywnsbyq9jqR0ljuO63kg9PY+4OehIk5R8= +github.com/volcengine/volc-sdk-golang v1.0.23/go.mod h1:AfG/PZRUkHJ9inETvbjNifTDgut25Wbkm2QoYBTbvyU= +github.com/volcengine/volcengine-go-sdk v1.2.36 h1:rUH7t3bOzaGVJ9ys8L2Jizb+9AZkhu2qCjJwWtF+weo= +github.com/volcengine/volcengine-go-sdk v1.2.36/go.mod h1:oxoVo+A17kvkwPkIeIHPVLjSw7EQAm+l/Vau1YGHN+A= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= @@ -120,26 +159,70 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -147,6 +230,8 @@ gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= diff --git a/llm/state.go b/llm/state.go new file mode 100644 index 0000000..685bd6c --- /dev/null +++ b/llm/state.go @@ -0,0 +1,137 @@ +package llm + +import ( + "errors" + "fmt" + "strings" + "sync" + "time" + + ark "github.com/volcengine/volcengine-go-sdk/service/arkruntime" +) + +// Profile represents an LLM provider configuration with client +type Profile struct { + Config ProviderConfig + Client *ark.Client +} + +// ProviderConfig holds the configuration for an LLM provider +type ProviderConfig struct { + Name string + Active bool + APIKey string + BaseURL string + Model string + Timeout int + ContextWindowTokens int +} + +// State manages LLM profiles +type State struct { + mu sync.RWMutex + profiles map[string]*Profile + order []string + activeName string +} + +// NewState creates a new LLM state from provider configurations +func NewState(configs []ProviderConfig) (*State, error) { + state := &State{ + profiles: make(map[string]*Profile, len(configs)), + order: make([]string, 0, len(configs)), + } + for _, item := range configs { + name := strings.TrimSpace(item.Name) + if name == "" { + return nil, errors.New("llm provider name cannot be empty") + } + if strings.TrimSpace(item.APIKey) == "" { + return nil, fmt.Errorf("llm provider %s api_key is required", name) + } + if strings.TrimSpace(item.Model) == "" { + return nil, fmt.Errorf("llm provider %s model is required", name) + } + if strings.TrimSpace(item.BaseURL) == "" { + return nil, fmt.Errorf("llm provider %s base_url is required", name) + } + if item.Timeout <= 0 { + item.Timeout = 120 + } + if _, ok := state.profiles[name]; ok { + return nil, fmt.Errorf("duplicate llm provider name: %s", name) + } + state.profiles[name] = &Profile{ + Config: item, + Client: ark.NewClientWithApiKey( + item.APIKey, + ark.WithBaseUrl(item.BaseURL), + ark.WithTimeout(time.Duration(item.Timeout)*time.Second), + ), + } + state.order = append(state.order, name) + if item.Active && state.activeName == "" { + state.activeName = name + } + } + if len(state.order) == 0 { + return nil, errors.New("at least one llm provider is required") + } + if state.activeName == "" { + state.activeName = state.order[0] + } + return state, nil +} + +// ActiveProfile returns the currently active LLM profile +func (s *State) ActiveProfile() *Profile { + s.mu.RLock() + defer s.mu.RUnlock() + return s.profiles[s.activeName] +} + +// GetProfile returns a profile by name, or the active profile if name is empty +func (s *State) GetProfile(name string) (*Profile, error) { + s.mu.RLock() + defer s.mu.RUnlock() + name = strings.TrimSpace(name) + if name == "" { + return s.profiles[s.activeName], nil + } + profile, ok := s.profiles[name] + if !ok { + return nil, fmt.Errorf("llm provider not found: %s", name) + } + return profile, nil +} + +// SwitchActive changes the active LLM profile +func (s *State) SwitchActive(name string) (*Profile, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.New("llm provider name cannot be empty") + } + s.mu.Lock() + defer s.mu.Unlock() + profile, ok := s.profiles[name] + if !ok { + return nil, fmt.Errorf("llm provider not found: %s", name) + } + s.activeName = name + return profile, nil +} + +// ListProfiles returns all LLM profiles +func (s *State) ListProfiles() []ProviderConfig { + s.mu.RLock() + defer s.mu.RUnlock() + profiles := make([]ProviderConfig, 0, len(s.order)) + for _, name := range s.order { + profile := s.profiles[name] + cfg := profile.Config + cfg.APIKey = "" // Hide API key in listings + cfg.Active = name == s.activeName + profiles = append(profiles, cfg) + } + return profiles +} diff --git a/llm_store.go b/llm_store.go index 7c5ce00..5d4561f 100644 --- a/llm_store.go +++ b/llm_store.go @@ -63,13 +63,15 @@ func (s *store) DeleteLLMProvider(name string) error { } // EnsureDefaultLLMProvider 确保存在默认 LLM Provider 配置 +// 只有当数据库中完全没有任何 provider 配置时,才创建默认配置 func (s *store) EnsureDefaultLLMProvider() error { - _, err := s.GetLLMProvider("default") - if err == nil { - return nil // 已存在 + // 先检查是否已经有任何 provider 配置 + providers, err := s.ListLLMProviders(true) + if err != nil { + return fmt.Errorf("list llm providers: %w", err) } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return err + if len(providers) > 0 { + return nil // 已有配置,不创建默认 } // 创建默认配置 defaultConfig := &llmProviderRecord{ diff --git a/main.go b/main.go index b78f9a3..df02357 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,9 @@ import ( "syscall" "time" + "meshtastic_mqtt_server/ai" + "meshtastic_mqtt_server/autoreply" + "meshtastic_mqtt_server/llm" "meshtastic_mqtt_server/mqtpp" mqtt "github.com/mochi-mqtt/server/v2" @@ -247,6 +250,60 @@ func run(cfg *config) error { } defer forwardManager.StopAll() + // Initialize AI Service + var aiService *ai.Service + if cfg.AI.Enabled { + // Get LLM providers from database + llmProviders, err := store.ListLLMProviders(true) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to load LLM providers: %v\n", err) + } else if len(llmProviders) > 0 { + // Convert database records to provider configs + providerConfigs := make([]llm.ProviderConfig, 0, len(llmProviders)) + for _, p := range llmProviders { + providerConfigs = append(providerConfigs, llm.ProviderConfig{ + Name: p.Name, + Active: p.Active, + APIKey: p.APIKey, + BaseURL: p.BaseURL, + Model: p.Model, + Timeout: p.Timeout, + ContextWindowTokens: p.ContextWindowTokens, + }) + } + + // Create bot sender adapter + botSenderAdapter := autoreply.NewBotServiceAdapter( + func(ctx context.Context, botID uint64, toNodeNum int64, text string) error { + _, err := botSender.SendText(ctx, botSendTextRequest{ + BotID: botID, + MessageType: "direct", + ToNodeNum: &toNodeNum, + Text: text, + }) + return err + }, + ) + + aiService, err = ai.NewService(ai.Config{ + LLMProviders: providerConfigs, + DataDir: cfg.DataDir, + Enabled: cfg.AI.Enabled, + }, store.db, botSenderAdapter) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to initialize AI service: %v\n", err) + } else { + if err := aiService.Start(botCtx); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to start AI service: %v\n", err) + } + defer aiService.Stop() + printJSON(map[string]any{"event": "ai_service_started", "providers": len(providerConfigs)}) + } + } else { + fmt.Fprintf(os.Stderr, "Warning: AI service is enabled but no LLM providers configured\n") + } + } + var httpServers []*http.Server errCh := make(chan error, 2) if cfg.Web.Enabled { diff --git a/message/types.go b/message/types.go new file mode 100644 index 0000000..932ae4b --- /dev/null +++ b/message/types.go @@ -0,0 +1,35 @@ +package message + +import "time" + +// ChatMessage represents a single message in a conversation +type ChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` + ImageURL string `json:"image_url,omitempty"` + ImageURLAlias string `json:"image_url,omitempty"` + Hidden bool `json:"hidden,omitempty"` +} + +// Conversation represents a chat conversation with a bot +type Conversation struct { + ID string `json:"id"` + BotID uint64 `json:"bot_id"` + BotNodeID string `json:"bot_node_id"` + Title string `json:"title"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + PresetPrompt string `json:"preset_prompt,omitempty"` + Messages []ChatMessage `json:"messages,omitempty"` +} + +// ConversationPreset represents a preset configuration for a bot's conversation +type ConversationPreset struct { + ID uint64 `json:"id"` + BotID uint64 `json:"bot_id"` + Name string `json:"name"` + SystemPrompt string `json:"system_prompt"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/stream/stream.go b/stream/stream.go new file mode 100644 index 0000000..4fe398f --- /dev/null +++ b/stream/stream.go @@ -0,0 +1,58 @@ +package stream + +import "context" + +// Frame represents an event in the stream +type Frame struct { + Type string `json:"type,omitempty"` + Tool string `json:"tool,omitempty"` + Stage string `json:"stage,omitempty"` + Status string `json:"status,omitempty"` + Message string `json:"message,omitempty"` + Data map[string]any `json:"data,omitempty"` + Error string `json:"error,omitempty"` + Text string `json:"text,omitempty"` +} + +// EmitFunc is a function that emits frames +type EmitFunc func(frame Frame) + +// ContextKey type for context keys +type contextKey string + +const ( + // TrackerContextKey is the key for the stream tracker in context + TrackerContextKey contextKey = "stream_tracker" +) + +// Tracker tracks token usage during streaming +type Tracker struct { + PromptTokens int + CompletionTokens int + ToolCalls int +} + +// NewTracker creates a new stream tracker +func NewTracker() *Tracker { + return &Tracker{} +} + +// AddTool adds tool call token usage +func (t *Tracker) AddTool(promptTokens, completionTokens int) { + t.PromptTokens += promptTokens + t.CompletionTokens += completionTokens + t.ToolCalls++ +} + +// TrackerFromContext retrieves the tracker from context +func TrackerFromContext(ctx context.Context) *Tracker { + if tracker, ok := ctx.Value(TrackerContextKey).(*Tracker); ok { + return tracker + } + return nil +} + +// WithTracker adds a tracker to the context +func WithTracker(ctx context.Context, tracker *Tracker) context.Context { + return context.WithValue(ctx, TrackerContextKey, tracker) +} diff --git a/toolmanager/manager.go b/toolmanager/manager.go new file mode 100644 index 0000000..a06c6a9 --- /dev/null +++ b/toolmanager/manager.go @@ -0,0 +1,132 @@ +package toolmanager + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "meshtastic_mqtt_server/agenttool" +) + +// Manager manages loaded AI tools +type Manager struct { + tools map[string]agenttool.LoadedTool + order []string +} + +// Load loads tools from the given directory +func Load(root string, options agenttool.LoadOptions) (*Manager, error) { + entries, err := os.ReadDir(root) + if err != nil { + // Directory doesn't exist, create empty manager + if os.IsNotExist(err) { + return &Manager{tools: map[string]agenttool.LoadedTool{}}, nil + } + return nil, fmt.Errorf("failed to read tools directory: %w", err) + } + + manager := &Manager{tools: map[string]agenttool.LoadedTool{}} + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := strings.ToLower(strings.TrimSpace(entry.Name())) + descriptor, ok := agenttool.Lookup(name) + if !ok { + continue + } + tool, err := descriptor.Load(filepath.Join(root, entry.Name()), options) + if err != nil { + manager.Close() + return nil, fmt.Errorf("failed to load tool %s: %w", name, err) + } + if tool == nil { + continue + } + toolName := strings.ToLower(strings.TrimSpace(tool.Name())) + if toolName == "" { + toolName = name + } + if _, ok := manager.tools[toolName]; ok { + manager.Close() + return nil, fmt.Errorf("duplicate tool name: %s", toolName) + } + manager.tools[toolName] = tool + manager.order = append(manager.order, toolName) + } + return manager, nil +} + +// NewForTest creates a manager with preloaded tools for testing +func NewForTest(tools ...agenttool.LoadedTool) *Manager { + manager := &Manager{tools: map[string]agenttool.LoadedTool{}} + for _, tool := range tools { + if tool == nil { + continue + } + name := strings.ToLower(strings.TrimSpace(tool.Name())) + if name == "" { + continue + } + if _, ok := manager.tools[name]; !ok { + manager.order = append(manager.order, name) + } + manager.tools[name] = tool + } + return manager +} + +// Tools returns all loaded tools +func (m *Manager) Tools() []agenttool.LoadedTool { + if m == nil { + return nil + } + tools := make([]agenttool.LoadedTool, 0, len(m.order)) + for _, name := range m.order { + if tool := m.tools[name]; tool != nil { + tools = append(tools, tool) + } + } + return tools +} + +// Get returns a tool by name +func (m *Manager) Get(name string) (agenttool.LoadedTool, bool) { + if m == nil { + return nil, false + } + tool, ok := m.tools[strings.ToLower(strings.TrimSpace(name))] + return tool, ok +} + +// RawState returns the raw state of a tool +func (m *Manager) RawState(name string) (any, bool) { + tool, ok := m.Get(name) + if !ok || tool == nil { + return nil, false + } + return tool.RawState(), true +} + +// Close closes all tools +func (m *Manager) Close() error { + if m == nil { + return nil + } + var errs []string + for _, tool := range m.Tools() { + if closer, ok := tool.(interface{ Close() error }); ok { + if err := closer.Close(); err != nil { + errs = append(errs, err.Error()) + } + } + } + if len(errs) > 0 { + sort.Strings(errs) + return errors.New(strings.Join(errs, "; ")) + } + return nil +} diff --git a/toolrouter/loop.go b/toolrouter/loop.go new file mode 100644 index 0000000..a0445b3 --- /dev/null +++ b/toolrouter/loop.go @@ -0,0 +1,150 @@ +package toolrouter + +import ( + "context" + "fmt" + "strings" + "time" + + "meshtastic_mqtt_server/completion" + "meshtastic_mqtt_server/llm" + "meshtastic_mqtt_server/message" + "meshtastic_mqtt_server/stream" + "meshtastic_mqtt_server/toolmanager" + + "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" +) + +const maxAgentToolIterations = 6 + +// RunAgentToolLoop runs the agent tool calling loop +func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, chatMessages []message.ChatMessage, manager *toolmanager.Manager, emit stream.EmitFunc) ([]*model.ChatCompletionMessage, error) { + finalMessages, err := buildArkMessages(chatMessages) + if err != nil { + return nil, err + } + routerProfile := profile + if state != nil { + routerProfile = state.RouterProfile(profile) + } + tools := availableAgentTools(state, routerProfile, manager, emit) + if len(tools) == 0 { + return finalMessages, nil + } + + decisionMessages := append([]*model.ChatCompletionMessage(nil), finalMessages...) + + toolByName := make(map[string]AgentTool, len(tools)) + definitions := make([]*model.Tool, 0, len(tools)) + availableNames := make([]string, 0, len(tools)) + toolDescriptions := make([]string, 0, len(tools)) + for _, tool := range tools { + toolByName[tool.name] = tool + definitions = append(definitions, tool.definition) + availableNames = append(availableNames, tool.name) + if tool.definition != nil && tool.definition.Function != nil { + toolDescriptions = append(toolDescriptions, fmt.Sprintf("%s: %s", tool.name, tool.definition.Function.Description)) + } + } + if emit != nil { + emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "prepare", Status: "success", Message: "已准备可用工具", Data: map[string]any{"tools": availableNames, "tool_descriptions": toolDescriptions}}) + } + if state == nil || state.cfg == nil { + return finalMessages, nil + } + if prompt := strings.TrimSpace(state.cfg.SystemPrompt); prompt != "" { + systemMessage := &model.ChatCompletionMessage{ + Role: "system", + Content: &model.ChatCompletionMessageContent{ + StringValue: &prompt, + }, + } + finalMessages = append([]*model.ChatCompletionMessage{systemMessage}, finalMessages...) + decisionMessages = append([]*model.ChatCompletionMessage{systemMessage}, decisionMessages...) + } + for i := 0; i < maxAgentToolIterations; i++ { + if emit != nil { + emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "request", Status: "running", Message: fmt.Sprintf("正在进行第 %d 轮工具判断", i+1), Data: map[string]any{"iteration": i + 1, "max_iterations": maxAgentToolIterations, "tools": availableNames}}) + } + resp, err := completion.CompleteChat(ctx, routerProfile, model.CreateChatCompletionRequest{ + Model: routerProfile.Config.Model, + Messages: decisionMessages, + MaxTokens: &state.cfg.MaxTokens, + Tools: definitions, + ToolChoice: model.ToolChoiceStringTypeAuto, + ParallelToolCalls: BoolPtr(false), + }, time.Duration(state.cfg.Timeout)*time.Second) + if err != nil { + return finalMessages, err + } + if tracker := stream.TrackerFromContext(ctx); tracker != nil { + tracker.AddTool(resp.Usage.PromptTokens, resp.Usage.CompletionTokens) + } + if len(resp.Choices) == 0 { + return finalMessages, nil + } + choice := resp.Choices[0] + if emit != nil { + emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "decision", Status: "success", Message: "工具判断响应已返回", Data: map[string]any{"iteration": i + 1}}) + } + calls := choice.Message.ToolCalls + if len(calls) == 0 && choice.Message.FunctionCall != nil { + calls = []*model.ToolCall{{ID: "legacy_function_call", Type: model.ToolTypeFunction, Function: *choice.Message.FunctionCall}} + } + if len(calls) == 0 { + if emit != nil { + emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "request", Status: "success", Message: "模型未请求工具,进入回答生成"}) + } + return finalMessages, nil + } + callNames := make([]string, 0, len(calls)) + for _, call := range calls { + if call != nil { + callNames = append(callNames, call.Function.Name) + } + } + if emit != nil { + emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "tool_calls", Status: "running", Message: fmt.Sprintf("模型请求调用 %d 个工具", len(calls)), Data: map[string]any{"tools": callNames, "iteration": i + 1}}) + } + assistantMessage := &model.ChatCompletionMessage{Role: "assistant", ToolCalls: calls, Content: choice.Message.Content} + finalMessages = append(finalMessages, assistantMessage) + decisionMessages = append(decisionMessages, assistantMessage) + for _, call := range calls { + result := ExecuteAgentToolCall(ctx, call, toolByName, emit) + resultContent := &model.ChatCompletionMessageContent{StringValue: &result} + toolMessage := &model.ChatCompletionMessage{Role: "tool", ToolCallID: call.ID, Content: resultContent} + finalMessages = append(finalMessages, toolMessage) + decisionMessages = append(decisionMessages, toolMessage) + } + } + limitText := "工具调用轮数已达到上限。请基于已有工具结果回答,并说明可能未完成全部工具调用。" + limitMessage := &model.ChatCompletionMessage{Role: "system", Content: &model.ChatCompletionMessageContent{StringValue: &limitText}} + finalMessages = append(finalMessages, limitMessage) + return finalMessages, nil +} + +func buildArkMessages(chatMessages []message.ChatMessage) ([]*model.ChatCompletionMessage, error) { + messages := make([]*model.ChatCompletionMessage, 0, len(chatMessages)) + for _, msg := range chatMessages { + role := msg.Role + if role == "" { + role = "user" + } + content := &model.ChatCompletionMessageContent{StringValue: &msg.Content} + messages = append(messages, &model.ChatCompletionMessage{ + Role: role, + Content: content, + }) + } + return messages, nil +} + +// BoolPtr returns a pointer to the given bool +func BoolPtr(b bool) *bool { + return &b +} + +// IntPtr returns a pointer to the given int +func IntPtr(i int) *int { + return &i +} diff --git a/toolrouter/state.go b/toolrouter/state.go new file mode 100644 index 0000000..4ec64f1 --- /dev/null +++ b/toolrouter/state.go @@ -0,0 +1,76 @@ +package toolrouter + +import ( + "errors" + "fmt" + "strings" + + "meshtastic_mqtt_server/llm" +) + +// Config holds the tool router configuration +type Config struct { + Enabled bool + OpenAIName string + Timeout int + MaxTokens int + SystemPrompt string +} + +// State manages the tool router state +type State struct { + cfg *Config + ai *llm.State +} + +// Option is a function that configures the State +type Option func(*State) + +// NewState creates a new tool router state +func NewState(cfg *Config, ai *llm.State, options ...Option) (*State, error) { + if cfg == nil { + cfg = &Config{ + Enabled: true, + Timeout: 30, + MaxTokens: 512, + SystemPrompt: "你可以按需直接调用可用工具来回答用户问题。\n每个工具的 description 描述了它的适用场景和调用条件。\n工具结果优先于模型内置知识;工具失败时必须如实说明,不要编造结果。\n只调用确实必要的工具。", + } + } + if ai == nil { + return nil, errors.New("tool router requires an LLM state") + } + if cfg.Enabled && strings.TrimSpace(cfg.OpenAIName) != "" { + if _, err := ai.GetProfile(cfg.OpenAIName); err != nil { + return nil, fmt.Errorf("invalid LLM provider name in tool router: %w", err) + } + } + state := &State{cfg: cfg, ai: ai} + for _, option := range options { + option(state) + } + return state, nil +} + +// RouterProfile returns the LLM profile configured for the tool router +func (s *State) RouterProfile(fallback *llm.Profile) *llm.Profile { + if s == nil || s.cfg == nil || s.ai == nil { + return fallback + } + name := strings.TrimSpace(s.cfg.OpenAIName) + if name == "" { + return fallback + } + profile, err := s.ai.GetProfile(name) + if err != nil { + return fallback + } + return profile +} + +// Config returns a copy of the current configuration +func (s *State) Config() Config { + if s == nil || s.cfg == nil { + return Config{} + } + return *s.cfg +} diff --git a/toolrouter/tools.go b/toolrouter/tools.go new file mode 100644 index 0000000..49ae0c5 --- /dev/null +++ b/toolrouter/tools.go @@ -0,0 +1,140 @@ +package toolrouter + +import ( + "context" + "fmt" + "strings" + "time" + + "meshtastic_mqtt_server/agenttool" + "meshtastic_mqtt_server/llm" + "meshtastic_mqtt_server/stream" + "meshtastic_mqtt_server/toolmanager" + + "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" +) + +// AgentTool represents a tool that can be called by the LLM +type AgentTool struct { + name string + definition *model.Tool + execute func(context.Context, string) (string, error) +} + +// NewAgentTool creates a new agent tool +func NewAgentTool(name string, definition *model.Tool, execute func(context.Context, string) (string, error)) AgentTool { + return AgentTool{name: name, definition: definition, execute: execute} +} + +// Name returns the tool name +func (t AgentTool) Name() string { return t.name } + +// Definition returns the tool definition +func (t AgentTool) Definition() *model.Tool { return t.definition } + +// AvailableAgentTools returns all available agent tools +func AvailableAgentTools(state *State, profile *llm.Profile, manager *toolmanager.Manager, emit stream.EmitFunc) []AgentTool { + return availableAgentTools(state, profile, manager, emit) +} + +func availableAgentTools(state *State, profile *llm.Profile, manager *toolmanager.Manager, emit stream.EmitFunc) []AgentTool { + if state == nil || state.cfg == nil || !state.cfg.Enabled || manager == nil { + return nil + } + + loaded := manager.Tools() + tools := make([]AgentTool, 0, len(loaded)) + for _, tool := range loaded { + if tool == nil { + continue + } + if agentTool, ok := buildAgentTool(tool, "", profile, emit); ok { + tools = append(tools, agentTool) + } + } + return tools +} + +func buildAgentTool(tool agenttool.LoadedTool, description string, profile *llm.Profile, emit stream.EmitFunc) (AgentTool, bool) { + if tool == nil || !tool.Enabled() { + return AgentTool{}, false + } + definition := tool.ToolDefinition(description) + if definition == nil || definition.Function == nil { + return AgentTool{}, false + } + name := tool.Name() + return AgentTool{ + name: name, + definition: definition, + execute: func(ctx context.Context, args string) (string, error) { + runtime := agenttool.Runtime{ + Profile: profile, + Now: time.Now(), + Emit: wrapAgentEmit(emit), + } + return tool.Execute(ctx, args, runtime) + }, + }, true +} + +func wrapAgentEmit(emit stream.EmitFunc) agenttool.EmitFunc { + if emit == nil { + return nil + } + return func(frame any) { + switch value := frame.(type) { + case agenttool.Frame: + emit(stream.Frame{Type: value.Type, Tool: value.Tool, Stage: value.Stage, Status: value.Status, Message: value.Message, Data: value.Data, Error: value.Error, Text: value.Text}) + case stream.Frame: + emit(value) + } + } +} + +// ExecuteAgentToolCall executes a tool call and returns the result +func ExecuteAgentToolCall(ctx context.Context, call *model.ToolCall, tools map[string]AgentTool, emit stream.EmitFunc) string { + if call == nil || call.Type != model.ToolTypeFunction { + result := "工具调用无效:仅支持 function 类型工具。" + if emit != nil { + emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "execute", Status: "error", Message: result}) + } + return result + } + toolName := call.Function.Name + if emit != nil { + emit(stream.Frame{Type: "trace", Tool: toolName, Stage: "arguments", Status: "running", Message: "准备执行工具", Data: map[string]any{"tool_call_id": call.ID, "arguments": call.Function.Arguments}}) + } + tool, ok := tools[toolName] + if !ok { + result := fmt.Sprintf("工具调用失败:未知工具 %s。", toolName) + if emit != nil { + emit(stream.Frame{Type: "trace", Tool: toolName, Stage: "execute", Status: "error", Message: result}) + } + return result + } + started := time.Now() + result, err := tool.execute(ctx, call.Function.Arguments) + durationMs := time.Since(started).Milliseconds() + if err != nil { + messageText := fmt.Sprintf("工具 %s 执行失败:%v", tool.name, err) + if emit != nil { + emit(stream.Frame{Type: "trace", Tool: tool.name, Stage: "execute", Status: "error", Message: "工具执行失败", Data: map[string]any{"tool_call_id": call.ID, "duration_ms": durationMs, "error": err.Error()}}) + } + return messageText + } + if strings.TrimSpace(result) == "" { + result = fmt.Sprintf("工具 %s 执行完成,但没有返回内容。", tool.name) + } + if emit != nil { + emit(stream.Frame{Type: "trace", Tool: tool.name, Stage: "result", Status: "success", Message: "工具执行完成", Data: map[string]any{"tool_call_id": call.ID, "duration_ms": durationMs, "result_preview": truncateString(result, 1200)}}) + } + return result +} + +func truncateString(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "..." +} diff --git a/win/var/lib/mesh_mqtt_go/conversations/bot_1_1781711397025279500.json b/win/var/lib/mesh_mqtt_go/conversations/bot_1_1781711397025279500.json new file mode 100644 index 0000000..2b381a6 --- /dev/null +++ b/win/var/lib/mesh_mqtt_go/conversations/bot_1_1781711397025279500.json @@ -0,0 +1,18 @@ +{ + "id": "bot_1_1781711397025279500", + "bot_id": 1, + "bot_node_id": "!4217495a", + "title": "[来自 !8f821aa5] 🌞", + "created_at": "2026-06-17T23:49:57.0252795+08:00", + "updated_at": "2026-06-17T23:50:03.9827715+08:00", + "messages": [ + { + "role": "user", + "content": "[来自 !8f821aa5] 🌞" + }, + { + "role": "assistant", + "content": "你好呀!☀️\n\n看到你发来的这个像是系统标识符的字符串,还有那个温暖的太阳表情,感觉像是某种问候或者测试信号?\n\n如果你是想让我解读这个 ..." + } + ] +} \ No newline at end of file