up
This commit is contained in:
@@ -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/**)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+127
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
+137
@@ -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
|
||||
}
|
||||
+7
-5
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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] + "..."
|
||||
}
|
||||
@@ -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如果你是想让我解读这个 ..."
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user