Compare commits
3
Commits
94835a5f1d
...
dc5d9bf9a6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc5d9bf9a6 | ||
|
|
54df706ca7 | ||
|
|
fd766be731 |
@@ -0,0 +1,250 @@
|
||||
// Package sign 提供签到工具。当用户想签到时,把签到信息写入 signs 表,
|
||||
// 每个节点每天只能签到一次。
|
||||
//
|
||||
// 节点身份(node_id / long_name / short_name)由 autoreply 在处理队列消息时
|
||||
// 通过 ctx 注入(见 agenttool.NodeContext);text_message 包本身不含名字,
|
||||
// 队列记录里的 long_name/short_name 经常为空,因此签到工具会在名字缺失时
|
||||
// 用 node_id 查 nodeinfo 表补全。签到正文里的地区、名字、设备等字段则由
|
||||
// LLM 从用户消息中提取后作为工具参数传入。
|
||||
package sign
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"meshtastic_mqtt_server/internal/agenttool"
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
)
|
||||
|
||||
// SignStore 定义签到工具所需的持久化能力,通常由 *store.Store 实现。
|
||||
type SignStore interface {
|
||||
CreateSign(nodeID string, longName, shortName *string, signText string, signTime time.Time) (*storepkg.SignRecord, error)
|
||||
HasSignedOnDay(nodeID string, day time.Time) (bool, error)
|
||||
GetNodeInfo(nodeID string) (*storepkg.NodeInfoRecord, error)
|
||||
}
|
||||
|
||||
// Tool 是签到工具。
|
||||
type Tool struct {
|
||||
enabled bool
|
||||
store SignStore
|
||||
}
|
||||
|
||||
// Name returns the tool name
|
||||
func (t *Tool) Name() string { return "sign" }
|
||||
|
||||
// Enabled returns whether the tool is enabled
|
||||
func (t *Tool) Enabled() bool { return t.enabled && t.store != nil }
|
||||
|
||||
// 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: "sign",
|
||||
Description: desc,
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"region": map[string]any{
|
||||
"type": "string",
|
||||
"description": "地区,例如 \"上海闵行\"、\"安徽\"、\"广东深圳\"",
|
||||
},
|
||||
"name": map[string]any{
|
||||
"type": "string",
|
||||
"description": "签到用户的名字/呼号,例如 \"Kevin\"、\"TaoEngine\"",
|
||||
},
|
||||
"device": map[string]any{
|
||||
"type": "string",
|
||||
"description": "使用的设备型号,例如 \"GAT562\"、\"EBYTE_EoRa_S3\"",
|
||||
},
|
||||
"tx_power": map[string]any{
|
||||
"type": "string",
|
||||
"description": "可选:发射功率,例如 \"25mW\"、\"100mW\"",
|
||||
},
|
||||
"antenna_length": map[string]any{
|
||||
"type": "string",
|
||||
"description": "可选:天线长度,例如 \"5dBi\"、\"1.2m\"",
|
||||
},
|
||||
"altitude": map[string]any{
|
||||
"type": "string",
|
||||
"description": "可选:身处高度,例如 \"30m\"、\"海拔500m\"",
|
||||
},
|
||||
"raw_text": map[string]any{
|
||||
"type": "string",
|
||||
"description": "可选:用户原始签到文本。当无法准确拆分地区/名字/设备时,传入用户原文作为签到正文",
|
||||
},
|
||||
},
|
||||
"required": []string{"region", "name", "device"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Execute executes the sign tool
|
||||
func (t *Tool) Execute(ctx context.Context, args string, runtime agenttool.Runtime) (string, error) {
|
||||
if t.store == nil {
|
||||
return "", fmt.Errorf("sign store is not configured")
|
||||
}
|
||||
|
||||
var params signParams
|
||||
if err := json.Unmarshal([]byte(args), ¶ms); err != nil {
|
||||
return "", fmt.Errorf("failed to parse arguments: %w", err)
|
||||
}
|
||||
|
||||
params.Region = strings.TrimSpace(params.Region)
|
||||
params.Name = strings.TrimSpace(params.Name)
|
||||
params.Device = strings.TrimSpace(params.Device)
|
||||
params.RawText = strings.TrimSpace(params.RawText)
|
||||
if params.Region == "" || params.Name == "" || params.Device == "" {
|
||||
if params.RawText == "" {
|
||||
return "", fmt.Errorf("region, name, device 都是必填参数")
|
||||
}
|
||||
}
|
||||
|
||||
// 节点身份来自消息上下文,而非 LLM 回填,保证「每节点每天一次」判定可靠。
|
||||
node, ok := agenttool.NodeContextFromContext(ctx)
|
||||
if !ok || strings.TrimSpace(node.NodeID) == "" {
|
||||
return "", fmt.Errorf("缺少发送节点上下文,无法签到")
|
||||
}
|
||||
|
||||
now := runtime.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
|
||||
// 每个节点每天只能签到一次
|
||||
signed, err := t.store.HasSignedOnDay(node.NodeID, now)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("签到失败:检查今日签到状态时出错:%v", err), nil
|
||||
}
|
||||
if signed {
|
||||
return fmt.Sprintf("%s 今天已经签到过了,每个节点每天只能签到一次。", displayName(node)), nil
|
||||
}
|
||||
|
||||
signText := buildSignText(params)
|
||||
if signText == "" {
|
||||
// 结构化字段缺失时回退到用户原始文本
|
||||
signText = params.RawText
|
||||
}
|
||||
longName, shortName := resolveNodeNames(t, node)
|
||||
|
||||
record, err := t.store.CreateSign(node.NodeID, longName, shortName, signText, now)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("签到失败:%v", err), nil
|
||||
}
|
||||
|
||||
return fmt.Sprintf("签到成功!%s\n签到内容:%s", displayName(node), record.SignText), nil
|
||||
}
|
||||
|
||||
// resolveNodeNames 取出节点的 long_name / short_name。
|
||||
// text_message 包本身不含名字,队列记录里的 long_name/short_name 经常为空;
|
||||
// 此时用 node_id 查 nodeinfo 表补全,保证签到记录里能看到节点名。
|
||||
// 仍查不到则返回两个 nil,签到照常进行(仅名字字段为空)。
|
||||
func resolveNodeNames(t *Tool, node agenttool.NodeContext) (*string, *string) {
|
||||
var longName, shortName *string
|
||||
if ln := strings.TrimSpace(node.LongName); ln != "" {
|
||||
longName = &ln
|
||||
}
|
||||
if sn := strings.TrimSpace(node.ShortName); sn != "" {
|
||||
shortName = &sn
|
||||
}
|
||||
// 队列上下文已有名字就直接用,无需查库
|
||||
if longName != nil && shortName != nil {
|
||||
return longName, shortName
|
||||
}
|
||||
if t.store == nil {
|
||||
return longName, shortName
|
||||
}
|
||||
info, err := t.store.GetNodeInfo(node.NodeID)
|
||||
if err != nil {
|
||||
return longName, shortName
|
||||
}
|
||||
if info == nil {
|
||||
return longName, shortName
|
||||
}
|
||||
if longName == nil && info.LongName != nil {
|
||||
if v := strings.TrimSpace(*info.LongName); v != "" {
|
||||
longName = &v
|
||||
}
|
||||
}
|
||||
if shortName == nil && info.ShortName != nil {
|
||||
if v := strings.TrimSpace(*info.ShortName); v != "" {
|
||||
shortName = &v
|
||||
}
|
||||
}
|
||||
return longName, shortName
|
||||
}
|
||||
|
||||
// signParams 是签到工具的入参。
|
||||
type signParams struct {
|
||||
Region string `json:"region"`
|
||||
Name string `json:"name"`
|
||||
Device string `json:"device"`
|
||||
TxPower string `json:"tx_power"`
|
||||
AntennaLength string `json:"antenna_length"`
|
||||
Altitude string `json:"altitude"`
|
||||
// RawText 是用户原始签到文本,结构化字段缺失时作为签到正文回退使用。
|
||||
RawText string `json:"raw_text"`
|
||||
}
|
||||
|
||||
// buildSignText 按参考格式拼装签到正文:地区-名字-设备签到,可选信息附在括号内。
|
||||
// 当 region/name/device 任一缺失时返回空串,由调用方回退到 RawText。
|
||||
func buildSignText(p signParams) string {
|
||||
if strings.TrimSpace(p.Region) == "" || strings.TrimSpace(p.Name) == "" || strings.TrimSpace(p.Device) == "" {
|
||||
return ""
|
||||
}
|
||||
text := fmt.Sprintf("%s-%s-%s签到", p.Region, p.Name, p.Device)
|
||||
|
||||
var extras []string
|
||||
if v := strings.TrimSpace(p.TxPower); v != "" {
|
||||
extras = append(extras, "发射功率 "+v)
|
||||
}
|
||||
if v := strings.TrimSpace(p.AntennaLength); v != "" {
|
||||
extras = append(extras, "天线 "+v)
|
||||
}
|
||||
if v := strings.TrimSpace(p.Altitude); v != "" {
|
||||
extras = append(extras, "高度 "+v)
|
||||
}
|
||||
if len(extras) > 0 {
|
||||
text += "(" + strings.Join(extras, ",") + ")"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func displayName(node agenttool.NodeContext) string {
|
||||
if node.LongName != "" {
|
||||
return node.LongName
|
||||
}
|
||||
if node.ShortName != "" {
|
||||
return node.ShortName
|
||||
}
|
||||
return node.NodeID
|
||||
}
|
||||
|
||||
// RawState returns the tool state
|
||||
func (t *Tool) RawState() any {
|
||||
return map[string]any{"enabled": t.enabled, "has_store": t.store != nil}
|
||||
}
|
||||
|
||||
func init() {
|
||||
agenttool.Register(agenttool.Descriptor{
|
||||
Name: "sign",
|
||||
Load: func(path string, options agenttool.LoadOptions) (agenttool.LoadedTool, error) {
|
||||
tool := &Tool{enabled: true}
|
||||
if store, ok := options.Value("store").(SignStore); ok && store != nil {
|
||||
tool.store = store
|
||||
}
|
||||
return tool, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -47,6 +47,28 @@ type Runtime struct {
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
// NodeContext carries the originating node identity for a tool execution.
|
||||
// 它由 autoreply 在处理一条队列消息时注入到 ctx 中,供需要识别发送节点的
|
||||
// 工具(如签到工具)使用,避免依赖 LLM 从文本里回填节点 ID。
|
||||
type NodeContext struct {
|
||||
NodeID string
|
||||
LongName string
|
||||
ShortName string
|
||||
}
|
||||
|
||||
type nodeContextKey struct{}
|
||||
|
||||
// WithNodeContext 把节点身份信息挂到 ctx 上。
|
||||
func WithNodeContext(ctx context.Context, nc NodeContext) context.Context {
|
||||
return context.WithValue(ctx, nodeContextKey{}, nc)
|
||||
}
|
||||
|
||||
// NodeContextFromContext 从 ctx 中取出节点身份信息;不存在时第二个返回值为 false。
|
||||
func NodeContextFromContext(ctx context.Context) (NodeContext, bool) {
|
||||
nc, ok := ctx.Value(nodeContextKey{}).(NodeContext)
|
||||
return nc, ok
|
||||
}
|
||||
|
||||
// LoadedTool is the interface that all tools must implement
|
||||
type LoadedTool interface {
|
||||
Name() string
|
||||
|
||||
+91
-21
@@ -7,15 +7,17 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"meshtastic_mqtt_server/internal/agenttool"
|
||||
_ "meshtastic_mqtt_server/internal/agents/calculator"
|
||||
_ "meshtastic_mqtt_server/internal/agents/sign"
|
||||
_ "meshtastic_mqtt_server/internal/agents/time"
|
||||
"meshtastic_mqtt_server/internal/agenttool"
|
||||
"meshtastic_mqtt_server/internal/autoreply"
|
||||
"meshtastic_mqtt_server/internal/conversation"
|
||||
"meshtastic_mqtt_server/internal/llm"
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
"meshtastic_mqtt_server/internal/toolmanager"
|
||||
"meshtastic_mqtt_server/internal/toolrouter"
|
||||
"meshtastic_mqtt_server/internal/topicrouter"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -34,24 +36,34 @@ type ToolRouterStore interface {
|
||||
GetLLMToolRouter() (*storepkg.LLMToolRouterRecord, error)
|
||||
}
|
||||
|
||||
// TopicRouterStore 是 ai 服务依赖的话题选择持久化接口;
|
||||
// 通常由 *store.Store 实现(GetLLMTopicConfig)。
|
||||
type TopicRouterStore interface {
|
||||
GetLLMTopicConfig() (*storepkg.LLMTopicConfigRecord, error)
|
||||
}
|
||||
|
||||
// Config holds the AI service configuration
|
||||
type Config struct {
|
||||
LLMProviders []llm.ProviderConfig
|
||||
DataDir string
|
||||
Enabled bool
|
||||
ConsoleLog bool
|
||||
ToolConfigStore ToolConfigStore
|
||||
ToolRouterStore ToolRouterStore
|
||||
LLMProviders []llm.ProviderConfig
|
||||
DataDir string
|
||||
Enabled bool
|
||||
ConsoleLog bool
|
||||
ToolConfigStore ToolConfigStore
|
||||
ToolRouterStore ToolRouterStore
|
||||
TopicRouterStore TopicRouterStore
|
||||
// Store 注入持久化层,供需要 DB 访问的 agent 工具(如签到工具)使用。
|
||||
Store *storepkg.Store
|
||||
}
|
||||
|
||||
// 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
|
||||
LLMState *llm.State
|
||||
ToolRouter *toolrouter.State
|
||||
TopicRouter *topicrouter.State
|
||||
ToolMgr *toolmanager.Manager
|
||||
ConvStore *conversation.Store
|
||||
AutoReply *autoreply.Service
|
||||
MsgQueue *autoreply.DBMessageQueue
|
||||
|
||||
enabled bool
|
||||
}
|
||||
@@ -91,6 +103,41 @@ func toolRouterConfigFromRecord(r *storepkg.LLMToolRouterRecord) *toolrouter.Con
|
||||
}
|
||||
}
|
||||
|
||||
// topicRouterConfigAdapter 把 TopicRouterStore 适配成 topicrouter.ConfigStore,
|
||||
// 每次 LoadTopicConfig 都从 DB 拉取最新一行 llm_topic_config。
|
||||
type topicRouterConfigAdapter struct {
|
||||
store TopicRouterStore
|
||||
}
|
||||
|
||||
// LoadTopicConfig 实现 topicrouter.ConfigStore。
|
||||
// 当 DB 没有记录时返回 nil + nil,由 topicrouter 内部回退到内存默认值。
|
||||
func (a *topicRouterConfigAdapter) LoadTopicConfig() (*topicrouter.Config, error) {
|
||||
if a == nil || a.store == nil {
|
||||
return nil, errors.New("topic router store is not configured")
|
||||
}
|
||||
record, err := a.store.GetLLMTopicConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return topicRouterConfigFromRecord(record), nil
|
||||
}
|
||||
|
||||
func topicRouterConfigFromRecord(r *storepkg.LLMTopicConfigRecord) *topicrouter.Config {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return &topicrouter.Config{
|
||||
Enabled: r.Enabled,
|
||||
OpenAIName: r.OpenAIName,
|
||||
Timeout: r.Timeout,
|
||||
MaxTokens: r.MaxTokens,
|
||||
SystemPrompt: r.SystemPrompt,
|
||||
}
|
||||
}
|
||||
|
||||
// NewService creates a new AI service
|
||||
func NewService(cfg Config, db *gorm.DB, botSender autoreply.BotSender) (*Service, error) {
|
||||
if !cfg.Enabled {
|
||||
@@ -132,8 +179,29 @@ func NewService(cfg Config, db *gorm.DB, botSender autoreply.BotSender) (*Servic
|
||||
return nil, fmt.Errorf("failed to initialize tool router: %w", err)
|
||||
}
|
||||
|
||||
// 初始化话题选择 router:同样优先从 DB 读取已保存配置,支持保存即生效。
|
||||
var (
|
||||
topicRouterCfg *topicrouter.Config
|
||||
topicRouterOptions []topicrouter.Option
|
||||
)
|
||||
if cfg.TopicRouterStore != nil {
|
||||
topicAdapter := &topicRouterConfigAdapter{store: cfg.TopicRouterStore}
|
||||
if loaded, loadErr := topicAdapter.LoadTopicConfig(); loadErr == nil && loaded != nil {
|
||||
topicRouterCfg = loaded
|
||||
}
|
||||
topicRouterOptions = append(topicRouterOptions, topicrouter.WithConfigStore(topicAdapter))
|
||||
}
|
||||
topicRouter, err := topicrouter.NewState(topicRouterCfg, llmState, topicRouterOptions...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize topic router: %w", err)
|
||||
}
|
||||
|
||||
// Load tools
|
||||
toolMgr, err := toolmanager.Load(agentsDir, agenttool.LoadOptions{})
|
||||
loadOptions := agenttool.LoadOptions{Values: map[string]any{}}
|
||||
if cfg.Store != nil {
|
||||
loadOptions.Values["store"] = cfg.Store
|
||||
}
|
||||
toolMgr, err := toolmanager.Load(agentsDir, loadOptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tools: %w", err)
|
||||
}
|
||||
@@ -148,6 +216,7 @@ func NewService(cfg Config, db *gorm.DB, botSender autoreply.BotSender) (*Servic
|
||||
autoReply := autoreply.NewService(
|
||||
llmState,
|
||||
toolRouter,
|
||||
topicRouter,
|
||||
toolMgr,
|
||||
convStore,
|
||||
msgQueue,
|
||||
@@ -157,13 +226,14 @@ func NewService(cfg Config, db *gorm.DB, botSender autoreply.BotSender) (*Servic
|
||||
)
|
||||
|
||||
return &Service{
|
||||
LLMState: llmState,
|
||||
ToolRouter: toolRouter,
|
||||
ToolMgr: toolMgr,
|
||||
ConvStore: convStore,
|
||||
AutoReply: autoReply,
|
||||
MsgQueue: msgQueue,
|
||||
enabled: true,
|
||||
LLMState: llmState,
|
||||
ToolRouter: toolRouter,
|
||||
TopicRouter: topicRouter,
|
||||
ToolMgr: toolMgr,
|
||||
ConvStore: convStore,
|
||||
AutoReply: autoReply,
|
||||
MsgQueue: msgQueue,
|
||||
enabled: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"meshtastic_mqtt_server/internal/agenttool"
|
||||
"meshtastic_mqtt_server/internal/completion"
|
||||
"meshtastic_mqtt_server/internal/conversation"
|
||||
"meshtastic_mqtt_server/internal/llm"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"meshtastic_mqtt_server/internal/stream"
|
||||
"meshtastic_mqtt_server/internal/toolmanager"
|
||||
"meshtastic_mqtt_server/internal/toolrouter"
|
||||
"meshtastic_mqtt_server/internal/topicrouter"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
)
|
||||
@@ -77,6 +79,7 @@ type ToolConfigStore interface {
|
||||
type Service struct {
|
||||
llmState *llm.State
|
||||
toolRouter *toolrouter.State
|
||||
topicRouter *topicrouter.State
|
||||
toolMgr *toolmanager.Manager
|
||||
convStore *conversation.Store
|
||||
msgQueue MessageQueue
|
||||
@@ -94,6 +97,7 @@ type Service struct {
|
||||
func NewService(
|
||||
llmState *llm.State,
|
||||
toolRouter *toolrouter.State,
|
||||
topicRouter *topicrouter.State,
|
||||
toolMgr *toolmanager.Manager,
|
||||
convStore *conversation.Store,
|
||||
msgQueue MessageQueue,
|
||||
@@ -104,6 +108,7 @@ func NewService(
|
||||
return &Service{
|
||||
llmState: llmState,
|
||||
toolRouter: toolRouter,
|
||||
topicRouter: topicRouter,
|
||||
toolMgr: toolMgr,
|
||||
convStore: convStore,
|
||||
msgQueue: msgQueue,
|
||||
@@ -243,6 +248,14 @@ func truncate(s string, n int) string {
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
||||
// ptrStringValue 安全取出 *string 的值,nil 返回空串。
|
||||
func ptrStringValue(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// processMessage processes a single queued message
|
||||
func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) {
|
||||
// Mark message as processing
|
||||
@@ -327,6 +340,7 @@ func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) {
|
||||
// Run the tool loop to get augmented messages - pass system prompt to tool router
|
||||
// Tool loop will handle system prompt and tool calling
|
||||
var augmentedMessages []*model.ChatCompletionMessage
|
||||
toolUsed := false
|
||||
if enableTool && toolCount > 0 {
|
||||
routerProfile := s.toolRouter.RouterProfile(profile)
|
||||
routerModel := profile.Config.Model
|
||||
@@ -334,7 +348,13 @@ func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) {
|
||||
routerModel = routerProfile.Config.Model
|
||||
}
|
||||
s.logf("msg=%d router_model=%s tool_loop start", msg.ID, routerModel)
|
||||
augmentedMessages, err = toolrouter.RunAgentToolLoop(procCtx, s.toolRouter, profile, systemPrompt, conv.Messages, s.toolMgr, s.emit(msg.ID, routerModel))
|
||||
// 把发送节点身份注入 ctx,供需要识别节点的工具(如签到)使用
|
||||
nodeCtx := agenttool.WithNodeContext(procCtx, agenttool.NodeContext{
|
||||
NodeID: msg.FromNodeID,
|
||||
LongName: ptrStringValue(msg.LongName),
|
||||
ShortName: ptrStringValue(msg.ShortName),
|
||||
})
|
||||
augmentedMessages, toolUsed, err = toolrouter.RunAgentToolLoop(nodeCtx, s.toolRouter, profile, systemPrompt, conv.Messages, s.toolMgr, s.emit(msg.ID, routerModel))
|
||||
if err != nil {
|
||||
s.logf("msg=%d WARN tool_loop err=%v", msg.ID, err)
|
||||
// Continue with original messages if tool loop fails
|
||||
@@ -343,6 +363,27 @@ func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) {
|
||||
|
||||
s.logf("msg=%d completion start has_system_prompt=%t augmented=%d", msg.ID, systemPrompt != "", len(augmentedMessages))
|
||||
|
||||
// 若工具路由未实际调用任何工具,则进入话题选择判定:
|
||||
// 命中(REPLY/放行)才进入主回复,未命中则丢弃不回复。
|
||||
if !toolUsed {
|
||||
shouldReply, judgeErr := topicrouter.Judge(procCtx, s.topicRouter, profile, conv.Messages)
|
||||
if judgeErr != nil {
|
||||
s.logf("msg=%d WARN topic_judge err=%v (放行)", msg.ID, judgeErr)
|
||||
}
|
||||
if !shouldReply {
|
||||
s.logf("msg=%d topic_judge=IGNORE → 丢弃不回复", msg.ID)
|
||||
// 把刚加入会话的用户消息弹出,避免它残留在上下文里被下一次回复附带回答。
|
||||
if popped, popErr := s.convStore.PopLastMessage(conv.ID); popErr != nil {
|
||||
s.logf("msg=%d WARN pop_discarded_message err=%v", msg.ID, popErr)
|
||||
} else if popped.Content != "" {
|
||||
s.logf("msg=%d pop_discarded_message content=%q", msg.ID, truncate(popped.Content, 200))
|
||||
}
|
||||
_ = s.msgQueue.MarkAsProcessed(msg.ID, "")
|
||||
return
|
||||
}
|
||||
s.logf("msg=%d topic_judge=REPLY → 进入主回复", msg.ID)
|
||||
}
|
||||
|
||||
// Use augmented messages from tool loop (already includes system prompt and tool results)
|
||||
// If augmented messages is empty or nil, fallback to original messages with system prompt
|
||||
var reply string
|
||||
|
||||
@@ -180,6 +180,25 @@ func (s *Store) AddMessage(convID string, msg message.ChatMessage) error {
|
||||
return s.Save(conv)
|
||||
}
|
||||
|
||||
// PopLastMessage 移除会话中最后一条消息(例如被话题选择丢弃、不应保留在上下文里的用户消息)。
|
||||
// 若会话没有消息则不做任何改动。返回移除的消息内容,便于调用方记录日志。
|
||||
func (s *Store) PopLastMessage(convID string) (message.ChatMessage, error) {
|
||||
conv, err := s.Get(convID)
|
||||
if err != nil {
|
||||
return message.ChatMessage{}, err
|
||||
}
|
||||
if len(conv.Messages) == 0 {
|
||||
return message.ChatMessage{}, nil
|
||||
}
|
||||
last := conv.Messages[len(conv.Messages)-1]
|
||||
conv.Messages = conv.Messages[:len(conv.Messages)-1]
|
||||
// 若弹出后没有消息了,重置标题,避免残留被丢弃消息的文本。
|
||||
if len(conv.Messages) == 0 {
|
||||
conv.Title = "新对话"
|
||||
}
|
||||
return last, s.Save(conv)
|
||||
}
|
||||
|
||||
// atomicWriteJSON writes JSON to a file atomically
|
||||
func atomicWriteJSON(path string, v any) error {
|
||||
tmp := path + ".tmp"
|
||||
|
||||
@@ -35,6 +35,10 @@ func RegisterRoutes(r *gin.RouterGroup, store *storepkg.Store) {
|
||||
group.GET("/tool-router", handleGetLLMToolRouter(store))
|
||||
group.PUT("/tool-router", handleUpdateLLMToolRouter(store))
|
||||
|
||||
// LLM Topic Config - 话题选择配置
|
||||
group.GET("/topic-config", handleGetLLMTopicConfig(store))
|
||||
group.PUT("/topic-config", handleUpdateLLMTopicConfig(store))
|
||||
|
||||
// LLM Primary Config - 主 AI 回复配置
|
||||
group.GET("/primary-config", handleGetLLMPrimaryConfig(store))
|
||||
group.PUT("/primary-config", handleUpdateLLMPrimaryConfig(store))
|
||||
@@ -503,6 +507,124 @@ func llmToolRouterDTO(row storepkg.LLMToolRouterRecord) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LLM Topic Config Handlers - 话题选择配置
|
||||
// ============================================
|
||||
|
||||
func handleGetLLMTopicConfig(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
record, err := store.GetLLMTopicConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "topic config not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"item": llmTopicConfigDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpdateLLMTopicConfig(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
record, err := store.GetLLMTopicConfig()
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
OpenAIName *string `json:"openai_name"`
|
||||
Timeout *int `json:"timeout"`
|
||||
MaxTokens *int `json:"max_tokens"`
|
||||
SystemPrompt *string `json:"system_prompt"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
updates := make(map[string]any)
|
||||
if req.Enabled != nil {
|
||||
updates["enabled"] = *req.Enabled
|
||||
}
|
||||
if req.OpenAIName != nil {
|
||||
updates["openai_name"] = *req.OpenAIName
|
||||
}
|
||||
if req.Timeout != nil {
|
||||
updates["timeout"] = *req.Timeout
|
||||
}
|
||||
if req.MaxTokens != nil {
|
||||
updates["max_tokens"] = *req.MaxTokens
|
||||
}
|
||||
if req.SystemPrompt != nil {
|
||||
updates["system_prompt"] = *req.SystemPrompt
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
if record == nil {
|
||||
// 创建新配置
|
||||
newRecord := &storepkg.LLMTopicConfigRecord{
|
||||
Enabled: req.Enabled != nil && *req.Enabled,
|
||||
OpenAIName: "",
|
||||
Timeout: 30,
|
||||
MaxTokens: 512,
|
||||
SystemPrompt: "",
|
||||
}
|
||||
if req.OpenAIName != nil {
|
||||
newRecord.OpenAIName = *req.OpenAIName
|
||||
}
|
||||
if req.Timeout != nil {
|
||||
newRecord.Timeout = *req.Timeout
|
||||
}
|
||||
if req.MaxTokens != nil {
|
||||
newRecord.MaxTokens = *req.MaxTokens
|
||||
}
|
||||
if req.SystemPrompt != nil {
|
||||
newRecord.SystemPrompt = *req.SystemPrompt
|
||||
}
|
||||
if err := store.CreateLLMTopicConfig(newRecord); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
record = newRecord
|
||||
} else {
|
||||
// 更新现有配置
|
||||
if err := store.UpdateLLMTopicConfig(record.ID, updates); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
record, err = store.GetLLMTopicConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "item": llmTopicConfigDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func llmTopicConfigDTO(row storepkg.LLMTopicConfigRecord) map[string]any {
|
||||
return map[string]any{
|
||||
"id": row.ID,
|
||||
"enabled": row.Enabled,
|
||||
"openai_name": row.OpenAIName,
|
||||
"timeout": row.Timeout,
|
||||
"max_tokens": row.MaxTokens,
|
||||
"system_prompt": row.SystemPrompt,
|
||||
"created_at": row.CreatedAt,
|
||||
"updated_at": row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LLM Primary Config Handlers
|
||||
// ============================================
|
||||
|
||||
@@ -494,6 +494,22 @@ func (LLMToolRouterRecord) TableName() string {
|
||||
return "llm_tool_router"
|
||||
}
|
||||
|
||||
// LLMTopicConfigRecord 保存话题选择的配置
|
||||
type LLMTopicConfigRecord struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
Enabled bool `gorm:"column:enabled;not null;index"` // 是否启用话题选择
|
||||
OpenAIName string `gorm:"column:openai_name;size:64;not null"` // 使用的 LLM 提供商名称(关联 llm_providers.name)
|
||||
Timeout int `gorm:"column:timeout;not null;default:30"` // 话题判定超时时间(秒)
|
||||
MaxTokens int `gorm:"column:max_tokens;not null;default:512"` // 话题判定最大 token 数
|
||||
SystemPrompt string `gorm:"column:system_prompt;type:text;not null"` // 系统提示词
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"`
|
||||
}
|
||||
|
||||
func (LLMTopicConfigRecord) TableName() string {
|
||||
return "llm_topic_config"
|
||||
}
|
||||
|
||||
// LLMPrimaryConfigRecord 保存主 AI 回复的配置
|
||||
type LLMPrimaryConfigRecord struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
@@ -666,6 +682,7 @@ func (s *Store) migrate() error {
|
||||
{label: "llm_message_queue", model: &LLMMessageQueueRecord{}},
|
||||
{label: "llm_providers", model: &LLMProviderRecord{}},
|
||||
{label: "llm_tool_router", model: &LLMToolRouterRecord{}},
|
||||
{label: "llm_topic_config", model: &LLMTopicConfigRecord{}},
|
||||
{label: "llm_primary_config", model: &LLMPrimaryConfigRecord{}},
|
||||
{label: "nodeinfo", model: &NodeInfoRecord{}},
|
||||
{label: "map_report", model: &MapReportRecord{}},
|
||||
@@ -713,6 +730,9 @@ func (s *Store) migrate() error {
|
||||
if err := txStore.EnsureDefaultLLMToolRouter(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := txStore.EnsureDefaultLLMTopicConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := txStore.EnsureDefaultLLMPrimaryConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -139,6 +139,59 @@ func (s *Store) EnsureDefaultLLMToolRouter() error {
|
||||
return s.CreateLLMToolRouter(defaultConfig)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LLM Topic Config (llm_topic_config) - 话题选择配置
|
||||
// ============================================
|
||||
|
||||
// GetLLMTopicConfig 获取当前激活的话题选择配置
|
||||
func (s *Store) GetLLMTopicConfig() (*LLMTopicConfigRecord, error) {
|
||||
var record LLMTopicConfigRecord
|
||||
// 默认取第一条记录(ID 最小的),因为通常只需要一个配置
|
||||
if err := s.db.Order("id ASC").First(&record).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("get llm topic config: %w", err)
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// CreateLLMTopicConfig 创建话题选择配置
|
||||
func (s *Store) CreateLLMTopicConfig(record *LLMTopicConfigRecord) error {
|
||||
if err := s.db.Create(record).Error; err != nil {
|
||||
return fmt.Errorf("create llm topic config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateLLMTopicConfig 更新话题选择配置
|
||||
func (s *Store) UpdateLLMTopicConfig(id uint64, updates map[string]any) error {
|
||||
if err := s.db.Model(&LLMTopicConfigRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return fmt.Errorf("update llm topic config %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureDefaultLLMTopicConfig 确保存在默认话题选择配置
|
||||
func (s *Store) EnsureDefaultLLMTopicConfig() error {
|
||||
_, err := s.GetLLMTopicConfig()
|
||||
if err == nil {
|
||||
return nil // 已存在
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
// 创建默认配置(默认未启用)
|
||||
defaultConfig := &LLMTopicConfigRecord{
|
||||
Enabled: false,
|
||||
OpenAIName: "",
|
||||
Timeout: 30,
|
||||
MaxTokens: 512,
|
||||
SystemPrompt: "你是一个话题过滤器,判断用户最新消息是否属于应当回复的话题范围。\n如果应当回复,请输出 REPLY;如果不应当回复,请输出 IGNORE。\n只输出 REPLY 或 IGNORE,不要输出任何其他内容。",
|
||||
}
|
||||
return s.CreateLLMTopicConfig(defaultConfig)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LLM Primary Config (llm_primary_config) - 主 AI 回复配置
|
||||
// ============================================
|
||||
|
||||
@@ -45,6 +45,28 @@ func (s *Store) CountSignsByDay(opts ListOptions) ([]SignDayCount, error) {
|
||||
return rows, q.Scan(&rows).Error
|
||||
}
|
||||
|
||||
// HasSignedOnDay 判断指定节点在 day 所属的自然日(按 day 的时区)是否已有签到记录。
|
||||
// 用 Go 端计算当日起止时间再查询,避免依赖 SQLite/MySQL 各自的日期函数。
|
||||
func (s *Store) HasSignedOnDay(nodeID string, day time.Time) (bool, error) {
|
||||
nodeID = strings.TrimSpace(nodeID)
|
||||
if nodeID == "" {
|
||||
return false, fmt.Errorf("node id is required")
|
||||
}
|
||||
loc := day.Location()
|
||||
if loc == nil {
|
||||
loc = time.Local
|
||||
}
|
||||
start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc)
|
||||
end := start.AddDate(0, 0, 1)
|
||||
var count int64
|
||||
if err := s.db.Model(&SignRecord{}).
|
||||
Where("node_id = ? AND sign_time >= ? AND sign_time < ?", nodeID, start, end).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, fmt.Errorf("check sign on day: %w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetSignByID(id uint64) (*SignRecord, error) {
|
||||
var row SignRecord
|
||||
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||
|
||||
+117
-10
@@ -2,6 +2,7 @@ package toolrouter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -19,10 +20,13 @@ const maxAgentToolIterations = 6
|
||||
|
||||
// RunAgentToolLoop runs the agent tool calling loop
|
||||
// systemPrompt is the primary system prompt from LLM config
|
||||
func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, systemPrompt string, chatMessages []message.ChatMessage, manager *toolmanager.Manager, emit stream.EmitFunc) ([]*model.ChatCompletionMessage, error) {
|
||||
// The third return value toolUsed indicates whether at least one tool was actually
|
||||
// invoked during the loop (i.e. the model selected a tool). Callers use it to decide
|
||||
// whether to skip downstream gating (e.g. topic selection).
|
||||
func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, systemPrompt string, chatMessages []message.ChatMessage, manager *toolmanager.Manager, emit stream.EmitFunc) ([]*model.ChatCompletionMessage, bool, error) {
|
||||
finalMessages, err := buildArkMessages(chatMessages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
routerProfile := profile
|
||||
if state != nil {
|
||||
@@ -40,7 +44,7 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s
|
||||
}
|
||||
finalMessages = append([]*model.ChatCompletionMessage{systemMessage}, finalMessages...)
|
||||
}
|
||||
return finalMessages, nil
|
||||
return finalMessages, false, nil
|
||||
}
|
||||
|
||||
decisionMessages := append([]*model.ChatCompletionMessage(nil), finalMessages...)
|
||||
@@ -72,7 +76,7 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s
|
||||
finalMessages = append([]*model.ChatCompletionMessage{systemMessage}, finalMessages...)
|
||||
decisionMessages = append([]*model.ChatCompletionMessage{systemMessage}, decisionMessages...)
|
||||
}
|
||||
return finalMessages, nil
|
||||
return finalMessages, false, nil
|
||||
}
|
||||
// 每轮调用都重新加载最新配置,确保管理员在 /admin/llm/api 保存后立即生效
|
||||
cfg := state.effectiveConfig()
|
||||
@@ -102,6 +106,15 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s
|
||||
}
|
||||
decisionMessages = append([]*model.ChatCompletionMessage{routerSystemMessage}, decisionMessages...)
|
||||
}
|
||||
// toolUsed 记录本轮是否真的执行了至少一次工具调用,供调用方决定是否跳过话题选择等后续门控。
|
||||
toolUsed := false
|
||||
// 签到意图强制调用:用户明确想签到(「签到/打卡/上台」等)时,模型却不主动调
|
||||
// sign 工具的话,会被下游话题判定当成噪音丢弃。这里在循环外预判意图,待模型
|
||||
// 该轮未请求任何工具时强制注入一次 sign 调用(用用户原文作为 raw_text),
|
||||
// 保证签到一定落库、且不会被话题判定丢弃。
|
||||
forceSignText := detectSignIntent(chatMessages)
|
||||
_, signAvailable := toolByName["sign"]
|
||||
signInvoked := false // 本轮循环中是否已经调用过 sign(含模型主动调与强制调)
|
||||
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}})
|
||||
@@ -115,13 +128,13 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s
|
||||
ParallelToolCalls: BoolPtr(false),
|
||||
}, time.Duration(cfg.Timeout)*time.Second)
|
||||
if err != nil {
|
||||
return finalMessages, err
|
||||
return finalMessages, toolUsed, err
|
||||
}
|
||||
if tracker := stream.TrackerFromContext(ctx); tracker != nil {
|
||||
tracker.AddTool(resp.Usage.PromptTokens, resp.Usage.CompletionTokens)
|
||||
}
|
||||
if len(resp.Choices) == 0 {
|
||||
return finalMessages, nil
|
||||
return finalMessages, toolUsed, nil
|
||||
}
|
||||
choice := resp.Choices[0]
|
||||
if emit != nil {
|
||||
@@ -132,10 +145,19 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s
|
||||
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: "模型未请求工具,进入回答生成"})
|
||||
// 模型本轮未请求任何工具。若检测到签到意图且 sign 工具可用、本次循环尚未调过 sign,
|
||||
// 则强制注入一次 sign 调用(以用户原文作为 raw_text),保证签到一定落库。
|
||||
if forced := buildForcedSignCall(forceSignText, signAvailable, signInvoked); forced != nil {
|
||||
if emit != nil {
|
||||
emit(stream.Frame{Type: "trace", Tool: "sign", Stage: "tool_calls", Status: "running", Message: "检测到签到意图,模型未调用签到工具,强制调用 sign", Data: map[string]any{"tools": []string{"sign"}, "forced": true, "iteration": i + 1}})
|
||||
}
|
||||
calls = []*model.ToolCall{forced}
|
||||
} else {
|
||||
if emit != nil {
|
||||
emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "request", Status: "success", Message: "模型未请求工具,进入回答生成"})
|
||||
}
|
||||
return finalMessages, toolUsed, nil
|
||||
}
|
||||
return finalMessages, nil
|
||||
}
|
||||
callNames := make([]string, 0, len(calls))
|
||||
for _, call := range calls {
|
||||
@@ -146,10 +168,15 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s
|
||||
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}})
|
||||
}
|
||||
// 模型确实请求了工具调用,标记 toolUsed=true
|
||||
toolUsed = true
|
||||
assistantMessage := &model.ChatCompletionMessage{Role: "assistant", ToolCalls: calls, Content: choice.Message.Content}
|
||||
finalMessages = append(finalMessages, assistantMessage)
|
||||
decisionMessages = append(decisionMessages, assistantMessage)
|
||||
for _, call := range calls {
|
||||
if call != nil && call.Function.Name == "sign" {
|
||||
signInvoked = true
|
||||
}
|
||||
result := ExecuteAgentToolCall(ctx, call, toolByName, emit)
|
||||
resultContent := &model.ChatCompletionMessageContent{StringValue: &result}
|
||||
toolMessage := &model.ChatCompletionMessage{Role: "tool", ToolCallID: call.ID, Content: resultContent}
|
||||
@@ -160,7 +187,7 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s
|
||||
limitText := "工具调用轮数已达到上限。请基于已有工具结果回答,并说明可能未完成全部工具调用。"
|
||||
limitMessage := &model.ChatCompletionMessage{Role: "system", Content: &model.ChatCompletionMessageContent{StringValue: &limitText}}
|
||||
finalMessages = append(finalMessages, limitMessage)
|
||||
return finalMessages, nil
|
||||
return finalMessages, toolUsed, nil
|
||||
}
|
||||
|
||||
func buildArkMessages(chatMessages []message.ChatMessage) ([]*model.ChatCompletionMessage, error) {
|
||||
@@ -188,3 +215,83 @@ func BoolPtr(b bool) *bool {
|
||||
func IntPtr(i int) *int {
|
||||
return &i
|
||||
}
|
||||
|
||||
// signIntentKeywords 是判定签到意图的关键词。命中任一即认为用户想签到。
|
||||
var signIntentKeywords = []string{"签到", "打卡", "上台"}
|
||||
|
||||
// signNegationKeywords 是会否决签到意图的关键词。当消息同时命中签到关键词与
|
||||
// 这些否决词时,说明用户不是要签到,而是要对签到记录做删除/查询/取消等操作,
|
||||
// 此时不应强制签到(否则会把「删除签到信息」这句话本身当签到正文写库)。
|
||||
var signNegationKeywords = []string{"删除", "取消", "撤回", "撤销", "清除", "清空", "查询", "查看", "列表", "统计", "不要", "别"}
|
||||
|
||||
// detectSignIntent 取最后一条用户消息,若包含签到意图关键词(且不含否决词)
|
||||
// 则返回该消息原文(去除前缀的「[来自 ...]」等格式化包装),否则返回空串。
|
||||
func detectSignIntent(chatMessages []message.ChatMessage) string {
|
||||
userText := lastUserMessageText(chatMessages)
|
||||
if strings.TrimSpace(userText) == "" {
|
||||
return ""
|
||||
}
|
||||
hit := false
|
||||
for _, kw := range signIntentKeywords {
|
||||
if strings.Contains(userText, kw) {
|
||||
hit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hit {
|
||||
return ""
|
||||
}
|
||||
// 命中否决词则不视为签到意图
|
||||
for _, kw := range signNegationKeywords {
|
||||
if strings.Contains(userText, kw) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return stripFromPrefix(userText)
|
||||
}
|
||||
|
||||
// stripFromPrefix 去掉 autoreply.formatUserMessage 加上的「[来自 ...] 」前缀,
|
||||
// 让签到正文只保留用户实际发送的内容。
|
||||
func stripFromPrefix(s string) string {
|
||||
if idx := strings.Index(s, "]"); idx >= 0 && strings.HasPrefix(strings.TrimSpace(s), "[") {
|
||||
return strings.TrimSpace(s[idx+1:])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// buildForcedSignCall 在满足条件时构造一次强制 sign 调用。条件:
|
||||
// - 有签到意图原文(signText 非空)
|
||||
// - sign 工具可用
|
||||
// - 本次循环尚未调过 sign(避免重复签到)
|
||||
//
|
||||
// 调用参数仅含 raw_text(用户原文),由 sign 工具回退为签到正文。
|
||||
func buildForcedSignCall(signText string, signAvailable, signInvoked bool) *model.ToolCall {
|
||||
if strings.TrimSpace(signText) == "" || !signAvailable || signInvoked {
|
||||
return nil
|
||||
}
|
||||
args, _ := json.Marshal(map[string]string{"raw_text": signText})
|
||||
argsStr := string(args)
|
||||
return &model.ToolCall{
|
||||
ID: "forced_sign",
|
||||
Type: model.ToolTypeFunction,
|
||||
Function: model.FunctionCall{
|
||||
Name: "sign",
|
||||
Arguments: argsStr,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// lastUserMessageText 返回消息列表中最后一条 role 为 user 的消息内容。
|
||||
func lastUserMessageText(messages []message.ChatMessage) string {
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
msg := messages[i]
|
||||
role := msg.Role
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
if role == "user" {
|
||||
return msg.Content
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package topicrouter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"meshtastic_mqtt_server/internal/completion"
|
||||
"meshtastic_mqtt_server/internal/llm"
|
||||
"meshtastic_mqtt_server/internal/message"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
)
|
||||
|
||||
// DefaultSystemPrompt 是话题选择判定模型的默认系统提示词。
|
||||
// 模型被要求只输出 REPLY 或 IGNORE:REPLY 表示应当回复,IGNORE 表示应当丢弃。
|
||||
const DefaultSystemPrompt = "你是一个话题过滤器,判断用户最新消息是否属于应当回复的话题范围。\n如果应当回复,请输出 REPLY;如果不应当回复,请输出 IGNORE。\n只输出 REPLY 或 IGNORE,不要输出任何其他内容。"
|
||||
|
||||
// Config holds the topic selection configuration
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
OpenAIName string
|
||||
Timeout int
|
||||
MaxTokens int
|
||||
SystemPrompt string
|
||||
}
|
||||
|
||||
// ConfigStore 定义从持久化层读取最新话题选择配置的能力。
|
||||
// 每次 Judge 都会调用 LoadTopicConfig,从而保证管理员在 /admin/llm/api
|
||||
// 修改配置后立即生效,无需重启。
|
||||
type ConfigStore interface {
|
||||
LoadTopicConfig() (*Config, error)
|
||||
}
|
||||
|
||||
// State manages the topic router state
|
||||
type State struct {
|
||||
cfg *Config
|
||||
ai *llm.State
|
||||
store ConfigStore
|
||||
}
|
||||
|
||||
// Option is a function that configures the State
|
||||
type Option func(*State)
|
||||
|
||||
// WithConfigStore 注入运行时配置加载器,State 会在每次需要时拉取最新配置。
|
||||
func WithConfigStore(store ConfigStore) Option {
|
||||
return func(s *State) {
|
||||
s.store = store
|
||||
}
|
||||
}
|
||||
|
||||
// NewState creates a new topic router state
|
||||
func NewState(cfg *Config, ai *llm.State, options ...Option) (*State, error) {
|
||||
if cfg == nil {
|
||||
cfg = &Config{
|
||||
Enabled: false,
|
||||
Timeout: 30,
|
||||
MaxTokens: 512,
|
||||
SystemPrompt: DefaultSystemPrompt,
|
||||
}
|
||||
}
|
||||
if ai == nil {
|
||||
return nil, errors.New("topic 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 topic router: %w", err)
|
||||
}
|
||||
}
|
||||
state := &State{cfg: cfg, ai: ai}
|
||||
for _, option := range options {
|
||||
option(state)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// effectiveConfig 返回当前生效的配置:优先从 store 加载最新值,加载失败时回退到内存 cfg。
|
||||
// 调用方拿到的永远是非 nil 指针;内存 cfg 也保持同步以便其它读取点。
|
||||
func (s *State) effectiveConfig() *Config {
|
||||
if s == nil {
|
||||
return &Config{}
|
||||
}
|
||||
if s.store != nil {
|
||||
if latest, err := s.store.LoadTopicConfig(); err == nil && latest != nil {
|
||||
s.cfg = latest
|
||||
return latest
|
||||
}
|
||||
}
|
||||
if s.cfg == nil {
|
||||
return &Config{}
|
||||
}
|
||||
return s.cfg
|
||||
}
|
||||
|
||||
// Profile 返回话题选择使用的 LLM profile,OpenAIName 为空时回退到 fallback(主 profile)。
|
||||
func (s *State) Profile(fallback *llm.Profile) *llm.Profile {
|
||||
if s == nil || s.ai == nil {
|
||||
return fallback
|
||||
}
|
||||
cfg := s.effectiveConfig()
|
||||
name := strings.TrimSpace(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 {
|
||||
return Config{}
|
||||
}
|
||||
return *s.effectiveConfig()
|
||||
}
|
||||
|
||||
// Judge 对最近一条用户消息做话题判定。
|
||||
// 返回值 shouldReply:true 表示命中/放行(应进入主回复),false 表示应丢弃不回复。
|
||||
// 当话题选择未启用、未配置提供商,或判定调用失败时,一律放行(返回 true),
|
||||
// 避免判定接口故障导致所有未命中工具的消息被丢弃。
|
||||
func Judge(ctx context.Context, state *State, fallback *llm.Profile, messages []message.ChatMessage) (bool, error) {
|
||||
if state == nil {
|
||||
return true, nil
|
||||
}
|
||||
cfg := state.effectiveConfig()
|
||||
if !cfg.Enabled {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
profile := state.Profile(fallback)
|
||||
if profile == nil || profile.Client == nil {
|
||||
// 未配置话题选择的 AI 提供商,回退到放行
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// 取最后一条用户消息作为判定输入
|
||||
userText := lastUserMessage(messages)
|
||||
if strings.TrimSpace(userText) == "" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
systemPrompt := strings.TrimSpace(cfg.SystemPrompt)
|
||||
if systemPrompt == "" {
|
||||
systemPrompt = DefaultSystemPrompt
|
||||
}
|
||||
|
||||
arkMessages := make([]*model.ChatCompletionMessage, 0, 2)
|
||||
arkMessages = append(arkMessages, &model.ChatCompletionMessage{
|
||||
Role: "system",
|
||||
Content: &model.ChatCompletionMessageContent{
|
||||
StringValue: &systemPrompt,
|
||||
},
|
||||
})
|
||||
arkMessages = append(arkMessages, &model.ChatCompletionMessage{
|
||||
Role: "user",
|
||||
Content: &model.ChatCompletionMessageContent{
|
||||
StringValue: &userText,
|
||||
},
|
||||
})
|
||||
|
||||
maxTokens := cfg.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = 512
|
||||
}
|
||||
timeout := time.Duration(cfg.Timeout) * time.Second
|
||||
if cfg.Timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
resp, err := completion.CompleteChat(ctx, profile, model.CreateChatCompletionRequest{
|
||||
Model: profile.Config.Model,
|
||||
Messages: arkMessages,
|
||||
MaxTokens: &maxTokens,
|
||||
}, timeout)
|
||||
if err != nil {
|
||||
// 判定调用失败时放行,避免接口故障导致全部丢消息
|
||||
return true, err
|
||||
}
|
||||
if len(resp.Choices) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
text := ""
|
||||
if resp.Choices[0].Message.Content != nil && resp.Choices[0].Message.Content.StringValue != nil {
|
||||
text = *resp.Choices[0].Message.Content.StringValue
|
||||
}
|
||||
// 解析模型输出:包含 REPLY 即命中(忽略大小写)
|
||||
upper := strings.ToUpper(strings.TrimSpace(text))
|
||||
if strings.Contains(upper, "REPLY") {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// lastUserMessage 返回消息列表中最后一条 role 为 user 的消息内容。
|
||||
func lastUserMessage(messages []message.ChatMessage) string {
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
msg := messages[i]
|
||||
role := msg.Role
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
if role == "user" {
|
||||
return msg.Content
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -417,12 +417,14 @@ func run(cfg *configpkg.Config) error {
|
||||
)
|
||||
|
||||
aiService, err = ai.NewService(ai.Config{
|
||||
LLMProviders: providerConfigs,
|
||||
DataDir: cfg.AI.DataDir,
|
||||
Enabled: cfg.AI.Enabled,
|
||||
ConsoleLog: cfg.ConsoleLog.LLM,
|
||||
ToolConfigStore: store,
|
||||
ToolRouterStore: store,
|
||||
LLMProviders: providerConfigs,
|
||||
DataDir: cfg.AI.DataDir,
|
||||
Enabled: cfg.AI.Enabled,
|
||||
ConsoleLog: cfg.ConsoleLog.LLM,
|
||||
ToolConfigStore: store,
|
||||
ToolRouterStore: store,
|
||||
TopicRouterStore: store,
|
||||
Store: store,
|
||||
}, store.DB(), botSenderAdapter)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to initialize AI service: %v\n", err)
|
||||
|
||||
@@ -54,6 +54,8 @@ import type {
|
||||
LLMProviderResponse,
|
||||
LLMPlatformRouterPayload,
|
||||
LLMPlatformRouterResponse,
|
||||
LLMTopicConfigPayload,
|
||||
LLMTopicConfigResponse,
|
||||
LLMPrimaryConfigPayload,
|
||||
LLMPrimaryConfigResponse,
|
||||
} from './types'
|
||||
@@ -526,6 +528,15 @@ export function updateLLMToolRouter(payload: Partial<LLMPlatformRouterPayload>):
|
||||
return putJSON<LLMPlatformRouterResponse>('/api/admin/llm/tool-router', payload)
|
||||
}
|
||||
|
||||
// LLM Topic Config API - 话题选择配置
|
||||
export function getLLMTopicConfig(): Promise<LLMTopicConfigResponse> {
|
||||
return getJSON<LLMTopicConfigResponse>('/api/admin/llm/topic-config')
|
||||
}
|
||||
|
||||
export function updateLLMTopicConfig(payload: Partial<LLMTopicConfigPayload>): Promise<LLMTopicConfigResponse> {
|
||||
return putJSON<LLMTopicConfigResponse>('/api/admin/llm/topic-config', payload)
|
||||
}
|
||||
|
||||
// LLM Primary Config API - 主 AI 回复配置
|
||||
export function getLLMPrimaryConfig(): Promise<LLMPrimaryConfigResponse> {
|
||||
return getJSON<LLMPrimaryConfigResponse>('/api/admin/llm/primary-config')
|
||||
@@ -536,4 +547,4 @@ export function updateLLMPrimaryConfig(payload: Partial<LLMPrimaryConfigPayload>
|
||||
}
|
||||
|
||||
// 静默使用未导出类型,避免 TS6133(未使用的导入)。
|
||||
export type { LLMMessage, LLMMessageStatus, LLMProvider, LLMPlatformRouter, LLMPrimaryConfig } from './types'
|
||||
export type { LLMMessage, LLMMessageStatus, LLMProvider, LLMPlatformRouter, LLMTopicConfig, LLMPrimaryConfig } from './types'
|
||||
|
||||
@@ -5,12 +5,14 @@ import {
|
||||
deleteLLMProvider,
|
||||
getLLMProviders,
|
||||
getLLMToolRouter,
|
||||
getLLMTopicConfig,
|
||||
getLLMPrimaryConfig,
|
||||
updateLLMProvider,
|
||||
updateLLMToolRouter,
|
||||
updateLLMTopicConfig,
|
||||
updateLLMPrimaryConfig,
|
||||
} from '../api'
|
||||
import type { LLMPlatformRouter, LLMProvider, LLMPrimaryConfig } from '../types'
|
||||
import type { LLMPlatformRouter, LLMTopicConfig, LLMProvider, LLMPrimaryConfig } from '../types'
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
@@ -44,6 +46,18 @@ const toolRouterForm = ref({
|
||||
system_prompt: '',
|
||||
})
|
||||
|
||||
// Topic Config 相关 - 话题选择配置
|
||||
const topicConfig = ref<LLMTopicConfig | null>(null)
|
||||
const editingTopicConfig = ref(false)
|
||||
|
||||
const topicConfigForm = ref({
|
||||
enabled: false,
|
||||
openai_name: '',
|
||||
timeout: 30,
|
||||
max_tokens: 512,
|
||||
system_prompt: '',
|
||||
})
|
||||
|
||||
// Primary AI Config 相关 - 主 AI 回复配置
|
||||
const primaryConfig = ref<LLMPrimaryConfig | null>(null)
|
||||
const editingPrimaryConfig = ref(false)
|
||||
@@ -98,6 +112,16 @@ async function loadPrimaryConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTopicConfig() {
|
||||
try {
|
||||
const response = await getLLMTopicConfig()
|
||||
topicConfig.value = response.item
|
||||
} catch (err) {
|
||||
// 如果不存在,使用默认值
|
||||
console.warn('Topic config not found, using defaults')
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateProvider() {
|
||||
isCreatingProvider.value = true
|
||||
providerForm.value = {
|
||||
@@ -203,6 +227,35 @@ async function saveToolRouter() {
|
||||
}
|
||||
}
|
||||
|
||||
function openEditTopicConfig() {
|
||||
if (topicConfig.value) {
|
||||
topicConfigForm.value = {
|
||||
enabled: topicConfig.value.enabled,
|
||||
openai_name: topicConfig.value.openai_name,
|
||||
timeout: topicConfig.value.timeout,
|
||||
max_tokens: topicConfig.value.max_tokens,
|
||||
system_prompt: topicConfig.value.system_prompt,
|
||||
}
|
||||
}
|
||||
editingTopicConfig.value = true
|
||||
}
|
||||
|
||||
function closeTopicConfigForm() {
|
||||
editingTopicConfig.value = false
|
||||
}
|
||||
|
||||
async function saveTopicConfig() {
|
||||
try {
|
||||
await updateLLMTopicConfig(topicConfigForm.value)
|
||||
success.value = '更新成功'
|
||||
clearSuccess()
|
||||
closeTopicConfigForm()
|
||||
await loadTopicConfig()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
|
||||
function openEditPrimaryConfig() {
|
||||
if (primaryConfig.value) {
|
||||
primaryConfigForm.value = {
|
||||
@@ -236,6 +289,7 @@ async function savePrimaryConfig() {
|
||||
onMounted(() => {
|
||||
loadProviders()
|
||||
loadToolRouter()
|
||||
loadTopicConfig()
|
||||
loadPrimaryConfig()
|
||||
})
|
||||
</script>
|
||||
@@ -386,6 +440,90 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Topic Config 配置 - 话题选择配置 -->
|
||||
<div class="admin-section">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h3>话题选择配置</h3>
|
||||
<p class="section-desc">当工具路由未命中任何工具时,由话题选择判断是否回复。命中(输出 REPLY)才进入主回复,否则丢弃不回复。</p>
|
||||
</div>
|
||||
<button v-if="!editingTopicConfig" class="admin-button" @click="openEditTopicConfig">编辑配置</button>
|
||||
</div>
|
||||
|
||||
<div v-if="editingTopicConfig" class="tool-router-form">
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" v-model="topicConfigForm.enabled" />
|
||||
启用话题选择
|
||||
</label>
|
||||
<p class="form-hint">未启用时,未命中工具的消息一律进入主回复(不做过滤)</p>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>使用的 AI 配置</label>
|
||||
<select v-model="topicConfigForm.openai_name" class="form-input">
|
||||
<option value="">请选择</option>
|
||||
<option v-for="p in activeProviders" :key="p.name" :value="p.name">{{ p.name }}</option>
|
||||
</select>
|
||||
<p class="form-hint">选择用于话题判定的 AI 提供商配置,留空则使用主回复配置</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>超时时间(秒)</label>
|
||||
<input type="number" v-model.number="topicConfigForm.timeout" class="form-input" min="1" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>最大 Token 数</label>
|
||||
<input type="number" v-model.number="topicConfigForm.max_tokens" class="form-input" min="1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>系统提示词</label>
|
||||
<textarea v-model="topicConfigForm.system_prompt" class="form-textarea" rows="6"></textarea>
|
||||
<p class="form-hint">要求模型对应当回复的消息输出 REPLY,否则输出 IGNORE</p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="admin-button admin-button-secondary" @click="closeTopicConfigForm">取消</button>
|
||||
<button class="admin-button" @click="saveTopicConfig">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="topicConfig" class="tool-router-display">
|
||||
<div class="router-status">
|
||||
<span class="status-badge" :class="{ active: topicConfig.enabled, inactive: !topicConfig.enabled }">
|
||||
{{ topicConfig.enabled ? '已启用' : '已停用' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="router-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">使用的 AI 配置</span>
|
||||
<span class="detail-value">{{ topicConfig.openai_name || '未设置(使用主回复配置)' }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">超时时间</span>
|
||||
<span class="detail-value">{{ topicConfig.timeout }} 秒</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">最大 Token 数</span>
|
||||
<span class="detail-value">{{ topicConfig.max_tokens }}</span>
|
||||
</div>
|
||||
<div class="detail-row full-width">
|
||||
<span class="detail-label">系统提示词</span>
|
||||
<pre class="detail-value system-prompt">{{ topicConfig.system_prompt }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<p>暂无话题选择配置,点击上方按钮进行配置。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Primary AI Config 配置 - 主 AI 回复配置 -->
|
||||
<div class="admin-section">
|
||||
<div class="section-header">
|
||||
|
||||
@@ -669,6 +669,30 @@ export interface LLMPlatformRouterResponse {
|
||||
item: LLMPlatformRouter
|
||||
}
|
||||
|
||||
// LLM Topic Config 相关类型 - 话题选择配置
|
||||
export interface LLMTopicConfig {
|
||||
id: number
|
||||
enabled: boolean
|
||||
openai_name: string
|
||||
timeout: number
|
||||
max_tokens: number
|
||||
system_prompt: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface LLMTopicConfigPayload {
|
||||
enabled: boolean
|
||||
openai_name: string
|
||||
timeout: number
|
||||
max_tokens: number
|
||||
system_prompt: string
|
||||
}
|
||||
|
||||
export interface LLMTopicConfigResponse {
|
||||
item: LLMTopicConfig
|
||||
}
|
||||
|
||||
// LLM Primary Config 相关类型 - 主 AI 回复配置
|
||||
export interface LLMPrimaryConfig {
|
||||
id: number
|
||||
|
||||
Reference in New Issue
Block a user