根本原因:PKI 私聊需要接收方的公钥,但 !72d17be4 这个节点从未广播过节点信息,nodeinfo 表中没有它的公钥。这傻x豆包ai突然发什么神经,蠢死了

This commit is contained in:
2026-06-18 00:49:42 +08:00
parent ce07792c4b
commit dfb3fdd3e5
16 changed files with 757 additions and 31 deletions
+131
View File
@@ -31,6 +31,10 @@ func registerAdminLLMRoutes(r *gin.RouterGroup, store *store) {
// LLM Tool Router
group.GET("/tool-router", handleGetLLMToolRouter(store))
group.PUT("/tool-router", handleUpdateLLMToolRouter(store))
// LLM Primary Config - 主 AI 回复配置
group.GET("/primary-config", handleGetLLMPrimaryConfig(store))
group.PUT("/primary-config", handleUpdateLLMPrimaryConfig(store))
}
}
@@ -495,3 +499,130 @@ func llmToolRouterDTO(row llmToolRouterRecord) map[string]any {
"updated_at": row.UpdatedAt,
}
}
// ============================================
// LLM Primary Config Handlers
// ============================================
func handleGetLLMPrimaryConfig(store *store) gin.HandlerFunc {
return func(c *gin.Context) {
record, err := store.GetLLMPrimaryConfig()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "primary config not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"item": llmPrimaryConfigDTO(*record)})
}
}
func handleUpdateLLMPrimaryConfig(store *store) gin.HandlerFunc {
return func(c *gin.Context) {
record, err := store.GetLLMPrimaryConfig()
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"`
ProviderName *string `json:"provider_name"`
Timeout *int `json:"timeout"`
MaxTokens *int `json:"max_tokens"`
SystemPrompt *string `json:"system_prompt"`
EnableTool *bool `json:"enable_tool"`
}
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.ProviderName != nil {
updates["provider_name"] = *req.ProviderName
}
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 req.EnableTool != nil {
updates["enable_tool"] = *req.EnableTool
}
if len(updates) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if record == nil {
// 创建新配置
newRecord := &llmPrimaryConfigRecord{
Enabled: req.Enabled != nil && *req.Enabled,
ProviderName: "",
Timeout: 120,
MaxTokens: 1024,
SystemPrompt: "",
EnableTool: false,
}
if req.ProviderName != nil {
newRecord.ProviderName = *req.ProviderName
}
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 req.EnableTool != nil {
newRecord.EnableTool = *req.EnableTool
}
if err := store.CreateLLMPrimaryConfig(newRecord); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
record = newRecord
} else {
// 更新现有配置
if err := store.UpdateLLMPrimaryConfig(record.ID, updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
record, err = store.GetLLMPrimaryConfig()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"status": "ok", "item": llmPrimaryConfigDTO(*record)})
}
}
func llmPrimaryConfigDTO(row llmPrimaryConfigRecord) map[string]any {
return map[string]any{
"id": row.ID,
"enabled": row.Enabled,
"provider_name": row.ProviderName,
"timeout": row.Timeout,
"max_tokens": row.MaxTokens,
"system_prompt": row.SystemPrompt,
"enable_tool": row.EnableTool,
"created_at": row.CreatedAt,
"updated_at": row.UpdatedAt,
}
}
+157 -8
View File
@@ -6,6 +6,7 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"
"meshtastic_mqtt_server/completion"
"meshtastic_mqtt_server/conversation"
@@ -164,13 +165,32 @@ func (s *Service) processQueue(ctx context.Context) {
}
}
// printJSON outputs a structured log message (imported from main package pattern)
func printJSON(v any) {
fmt.Printf("%+v\n", v)
}
// 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 {
printJSON(map[string]any{
"event": "llm_process_failed",
"msg_id": msg.ID,
"step": "mark_as_processing",
"error": err.Error(),
})
return
}
printJSON(map[string]any{
"event": "llm_process_start",
"msg_id": msg.ID,
"bot_id": msg.BotID,
"from_node_id": msg.FromNodeID,
"text": msg.Text,
})
// Create processing context with timeout
procCtx, cancel := context.WithTimeout(ctx, MaxProcessingTime)
defer cancel()
@@ -178,7 +198,9 @@ func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) {
// 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))
errMsg := fmt.Sprintf("failed to get conversation: %v", err)
printJSON(map[string]any{"event": "llm_process_failed", "msg_id": msg.ID, "step": "get_conversation", "error": errMsg})
_ = s.msgQueue.MarkAsFailed(msg.ID, errMsg)
return
}
@@ -188,21 +210,34 @@ func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) {
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))
errMsg := fmt.Sprintf("failed to add message: %v", err)
printJSON(map[string]any{"event": "llm_process_failed", "msg_id": msg.ID, "step": "add_message", "error": errMsg})
_ = s.msgQueue.MarkAsFailed(msg.ID, errMsg)
return
}
// Get the LLM profile
profile := s.llmState.ActiveProfile()
if profile == nil {
_ = s.msgQueue.MarkAsFailed(msg.ID, "no active LLM profile")
errMsg := "no active LLM profile - check if LLM providers are configured"
printJSON(map[string]any{"event": "llm_process_failed", "msg_id": msg.ID, "step": "get_profile", "error": errMsg})
_ = s.msgQueue.MarkAsFailed(msg.ID, errMsg)
return
}
printJSON(map[string]any{
"event": "llm_process_profile",
"msg_id": msg.ID,
"model": profile.Config.Model,
"base_url": profile.Config.BaseURL,
})
// 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))
errMsg := fmt.Sprintf("failed to reload conversation: %v", err)
printJSON(map[string]any{"event": "llm_process_failed", "msg_id": msg.ID, "step": "reload_conversation", "error": errMsg})
_ = s.msgQueue.MarkAsFailed(msg.ID, errMsg)
return
}
@@ -211,17 +246,55 @@ func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) {
_ = 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
printJSON(map[string]any{"event": "llm_process_completion_start", "msg_id": msg.ID})
reply, err := completion.CompleteText(procCtx, profile, conv.Messages, 512)
if err != nil {
_ = s.msgQueue.MarkAsFailed(msg.ID, fmt.Sprintf("LLM completion failed: %v", err))
errMsg := fmt.Sprintf("LLM completion failed: %v", err)
printJSON(map[string]any{"event": "llm_process_failed", "msg_id": msg.ID, "step": "llm_completion", "error": errMsg})
_ = s.msgQueue.MarkAsFailed(msg.ID, errMsg)
return
}
// Truncate reply for Meshtastic
printJSON(map[string]any{
"event": "llm_process_completion_success",
"msg_id": msg.ID,
"reply_len": len(reply),
})
// Clean and validate reply text
reply = cleanReplyText(reply)
printJSON(map[string]any{
"event": "llm_process_text_cleaned",
"msg_id": msg.ID,
"cleaned_len": len(reply),
})
// Truncate reply for Meshtastic (UTF-8 safe truncation)
if len([]byte(reply)) > MaxReplyLength {
reply = string([]byte(reply)[:MaxReplyLength-3]) + "..."
reply = truncateUTF8(reply, MaxReplyLength-3) + "..."
printJSON(map[string]any{
"event": "llm_process_text_truncated",
"msg_id": msg.ID,
"truncated_len": len(reply),
})
}
// Final UTF-8 validation before sending
if !utf8.ValidString(reply) {
printJSON(map[string]any{
"event": "llm_process_utf8_warning",
"msg_id": msg.ID,
"message": "final text still invalid, using fallback",
})
reply = "抱歉,我暂时无法回复。请稍后再试。"
}
printJSON(map[string]any{
"event": "llm_process_final_check",
"msg_id": msg.ID,
"valid_utf8": utf8.ValidString(reply),
"final_len": len(reply),
})
// Add assistant reply to conversation
assistantMsg := message.ChatMessage{
Role: "assistant",
@@ -232,13 +305,21 @@ func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) {
}
// Send the reply via the bot
printJSON(map[string]any{"event": "llm_process_send_start", "msg_id": msg.ID, "to_node_num": msg.FromNodeNum})
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))
errMsg := fmt.Sprintf("failed to send reply: %v", err)
printJSON(map[string]any{"event": "llm_process_failed", "msg_id": msg.ID, "step": "send_reply", "error": errMsg})
_ = s.msgQueue.MarkAsFailed(msg.ID, errMsg)
return
}
// Mark message as processed
_ = s.msgQueue.MarkAsProcessed(msg.ID, reply)
printJSON(map[string]any{
"event": "llm_process_success",
"msg_id": msg.ID,
"reply": reply,
})
}
// formatUserMessage formats the incoming message for the LLM
@@ -256,3 +337,71 @@ func (s *Service) formatUserMessage(msg QueuedMessage) string {
sb.WriteString(msg.Text)
return sb.String()
}
// cleanReplyText cleans LLM reply text to ensure it's valid for Meshtastic
func cleanReplyText(text string) string {
// Force convert to valid UTF-8 using rune-by-rune processing
v := make([]rune, 0, len(text))
for i, r := range text {
if r == utf8.RuneError {
_, size := utf8.DecodeRuneInString(text[i:])
if size == 1 {
continue // Skip invalid runes
}
}
// Skip all problematic characters
if r < 32 && r != '\n' && r != '\r' && r != '\t' {
continue
}
if r == 65533 || r == 0xfffd { // Unicode replacement character
continue
}
v = append(v, r)
}
text = string(v)
// Additional cleanup - use only explicitly allowed ASCII printable + CJK
var sb strings.Builder
for _, r := range text {
switch {
case r == '\n':
sb.WriteRune(' ') // Replace newlines with space for Meshtastic
case r == '\r' || r == '\t':
continue
case r >= 32 && r <= 126: // Printable ASCII
sb.WriteRune(r)
case r >= 0x4E00 && r <= 0x9FFF: // CJK Unified Ideographs
sb.WriteRune(r)
case r >= 0x3400 && r <= 0x4DBF: // CJK Extension A
sb.WriteRune(r)
case r >= 0x20000 && r <= 0x2A6DF: // CJK Extension B
sb.WriteRune(r)
case r >= 0xFF01 && r <= 0xFF5E: // Fullwidth ASCII variants
sb.WriteRune(r)
case r == 0x3002 || r == 0xFF1F || r == 0xFF01 || r == 0xFF0C || r == 0xFF1A: // Fullwidth punctuation
sb.WriteRune(r)
default:
continue // Skip all other characters
}
}
result := strings.TrimSpace(sb.String())
if result == "" {
result = "抱歉,我无法回复此消息。"
}
return result
}
// truncateUTF8 safely truncates a UTF-8 string to max bytes without breaking in the middle of a rune
func truncateUTF8(s string, maxBytes int) string {
if len([]byte(s)) <= maxBytes {
return s
}
bytes := []byte(s)
for i := maxBytes; i > 0; i-- {
if utf8.RuneStart(bytes[i]) {
return string(bytes[:i])
}
}
return ""
}
+10 -1
View File
@@ -294,7 +294,7 @@ func insertInboundBotDirectMessage(s *store, record map[string]any, clientInfo m
longName := nullableString(record["long_name"])
shortName := nullableString(record["short_name"])
channelID := nullableString(record["channel_id"])
_, _ = s.EnqueueLLMMessage(LLMMessageQueueInput{
_, err = s.EnqueueLLMMessage(LLMMessageQueueInput{
BotID: bot.ID,
BotNodeID: bot.NodeID,
BotNodeNum: bot.NodeNum,
@@ -308,6 +308,15 @@ func insertInboundBotDirectMessage(s *store, record map[string]any, clientInfo m
Topic: topic,
ContentJSON: contentPtr,
})
if err != nil {
printJSON(map[string]any{
"event": "llm_queue_enqueue_failed",
"bot_id": bot.ID,
"from": peerNodeID,
"text": text,
"error": err.Error(),
})
}
return nil
}
+4 -8
View File
@@ -88,14 +88,10 @@ func (s *Store) Get(id string) (*message.Conversation, error) {
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)
}
}
if err == nil && len(convs) > 0 {
// Use the most recent conversation (List already sorts by UpdatedAt desc)
// Note: List returns convs with Messages = nil, so we need to reload
return s.Get(convs[0].ID)
}
// Create a new conversation
return s.Create(botID, botNodeID)
+27
View File
@@ -494,6 +494,23 @@ func (llmToolRouterRecord) TableName() string {
return "llm_tool_router"
}
// llmPrimaryConfigRecord 保存主 AI 回复的配置
type llmPrimaryConfigRecord struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
Enabled bool `gorm:"column:enabled;not null;index"` // 是否启用 AI 回复
ProviderName string `gorm:"column:provider_name;size:64;not null"` // 使用的 LLM 提供商名称(关联 llm_providers.name
Timeout int `gorm:"column:timeout;not null;default:120"` // 请求超时时间(秒)
MaxTokens int `gorm:"column:max_tokens;not null;default:1024"` // 回复最大 token 数
SystemPrompt string `gorm:"column:system_prompt;type:text;not null"` // 默认系统提示词
EnableTool bool `gorm:"column:enable_tool;not null;default:false"` // 是否启用工具调用
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"`
}
func (llmPrimaryConfigRecord) TableName() string {
return "llm_primary_config"
}
type positionRecord struct {
AppendPacketFields `gorm:"embedded"`
MQTTClientRecordFields `gorm:"embedded"`
@@ -627,6 +644,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_primary_config", model: &llmPrimaryConfigRecord{}},
{label: "nodeinfo", model: &nodeInfoRecord{}},
{label: "map_report", model: &mapReportRecord{}},
{label: "text_message", model: &textMessageRecord{}},
@@ -673,6 +691,9 @@ func (s *store) migrate() error {
if err := txStore.EnsureDefaultLLMToolRouter(); err != nil {
return err
}
if err := txStore.EnsureDefaultLLMPrimaryConfig(); err != nil {
return err
}
return nil
})
}
@@ -725,6 +746,12 @@ func migrateBotNodePSK(tx *gorm.DB, migrator gorm.Migrator, driver string) error
return fmt.Errorf("migrate bot_nodes llm_include_channel_messages column: %w", err)
}
}
// 迁移 LLM 消息队列 reply 列
if migrator.HasTable(&llmMessageQueueRecord{}) && !migrator.HasColumn(&llmMessageQueueRecord{}, "Reply") {
if err := tx.Exec("ALTER TABLE llm_message_queue ADD COLUMN reply text").Error; err != nil {
return fmt.Errorf("migrate llm_message_queue reply column: %w", err)
}
}
return nil
}
+81 -5
View File
@@ -139,6 +139,60 @@ func (s *store) EnsureDefaultLLMToolRouter() error {
return s.CreateLLMToolRouter(defaultConfig)
}
// ============================================
// LLM Primary Config (llm_primary_config) - 主 AI 回复配置
// ============================================
// GetLLMPrimaryConfig 获取当前激活的主 AI 回复配置
func (s *store) GetLLMPrimaryConfig() (*llmPrimaryConfigRecord, error) {
var record llmPrimaryConfigRecord
// 默认取第一条记录(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 primary config: %w", err)
}
return &record, nil
}
// CreateLLMPrimaryConfig 创建主 AI 回复配置
func (s *store) CreateLLMPrimaryConfig(record *llmPrimaryConfigRecord) error {
if err := s.db.Create(record).Error; err != nil {
return fmt.Errorf("create llm primary config: %w", err)
}
return nil
}
// UpdateLLMPrimaryConfig 更新主 AI 回复配置
func (s *store) UpdateLLMPrimaryConfig(id uint64, updates map[string]any) error {
if err := s.db.Model(&llmPrimaryConfigRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return fmt.Errorf("update llm primary config %d: %w", id, err)
}
return nil
}
// EnsureDefaultLLMPrimaryConfig 确保存在默认主 AI 回复配置
func (s *store) EnsureDefaultLLMPrimaryConfig() error {
_, err := s.GetLLMPrimaryConfig()
if err == nil {
return nil // 已存在
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
// 创建默认配置
defaultConfig := &llmPrimaryConfigRecord{
Enabled: false,
ProviderName: "",
Timeout: 120,
MaxTokens: 1024,
SystemPrompt: "你是一个 Meshtastic 网络助手。请简洁回答用户问题。\n回答要简短清晰,适合在低带宽无线电环境传输。每次回复限制在200 bytes以内。",
EnableTool: false,
}
return s.CreateLLMPrimaryConfig(defaultConfig)
}
// LLMMessageQueueInput 是添加 LLM 队列消息的输入
type LLMMessageQueueInput struct {
BotID uint64 // 0 表示频道消息
@@ -179,12 +233,24 @@ func (s *store) EnqueueLLMMessage(input LLMMessageQueueInput) (*llmMessageQueueR
return nil, fmt.Errorf("text is required")
}
// 检查是否存在重复消息:相同 bot_id + packet_id 且未删除
// 检查是否存在重复消息
// packet_id > 0: 用 bot_id + packet_id 去重(频道消息)
// packet_id = 0: 用 bot_id + from_node_id + text 去重(私聊消息,可能没有 packet_id)
// 只排除 pending/processing 状态的消息,允许 error 状态的消息重新入队
var existing llmMessageQueueRecord
err = s.db.Where("bot_id = ? AND packet_id = ? AND deleted_at IS NULL", input.BotID, input.PacketID).
if input.PacketID > 0 {
// 频道消息:用 bot_id + packet_id 去重
err = s.db.Where("bot_id = ? AND packet_id = ? AND deleted_at IS NULL AND status IN (?, ?)",
input.BotID, input.PacketID, llmMessageStatusPending, llmMessageStatusProcessing).
Take(&existing).Error
} else {
// 私聊消息:用 bot_id + from_node_id + text 去重(避免同一人连续发相同内容被拒绝)
err = s.db.Where("bot_id = ? AND from_node_id = ? AND text = ? AND deleted_at IS NULL AND status IN (?, ?)",
input.BotID, input.FromNodeID, input.Text, llmMessageStatusPending, llmMessageStatusProcessing).
Take(&existing).Error
}
if err == nil {
// 重复消息,直接返回已存在的
// 存在正在处理或待处理的相同消息,直接返回
return &existing, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -377,15 +443,16 @@ func enqueueChannelMessageToLLM(s *store, record map[string]any) error {
}
// 查询所有启用了 LLM 队列且包含频道消息的机器人
// SQLite 中 numeric 布尔值用 1/0 存储,必须用整数查询
var bots []botNodeRecord
err = s.db.Where("llm_queue_enabled = ? AND llm_include_channel_messages = ?", true, true).Find(&bots).Error
err = s.db.Where("llm_queue_enabled = ? AND llm_include_channel_messages = ?", 1, 1).Find(&bots).Error
if err != nil {
return fmt.Errorf("query bots for channel message enqueue: %w", err)
}
// 为每个符合条件的机器人创建一条队列记录
for _, bot := range bots {
_, _ = s.EnqueueLLMMessage(LLMMessageQueueInput{
_, err = s.EnqueueLLMMessage(LLMMessageQueueInput{
BotID: bot.ID,
BotNodeID: bot.NodeID,
BotNodeNum: bot.NodeNum,
@@ -399,6 +466,15 @@ func enqueueChannelMessageToLLM(s *store, record map[string]any) error {
Topic: topic,
ContentJSON: contentPtr,
})
if err != nil {
printJSON(map[string]any{
"event": "llm_queue_enqueue_failed",
"bot_id": bot.ID,
"from": fromNodeID,
"text": text,
"error": err.Error(),
})
}
}
return nil
+12 -1
View File
@@ -54,6 +54,8 @@ import type {
LLMProviderResponse,
LLMPlatformRouterPayload,
LLMPlatformRouterResponse,
LLMPrimaryConfigPayload,
LLMPrimaryConfigResponse,
} from './types'
async function requestJSON<T>(path: string, init?: RequestInit): Promise<T> {
@@ -504,5 +506,14 @@ export function updateLLMToolRouter(payload: Partial<LLMPlatformRouterPayload>):
return putJSON<LLMPlatformRouterResponse>('/api/admin/llm/tool-router', payload)
}
// LLM Primary Config API - 主 AI 回复配置
export function getLLMPrimaryConfig(): Promise<LLMPrimaryConfigResponse> {
return getJSON<LLMPrimaryConfigResponse>('/api/admin/llm/primary-config')
}
export function updateLLMPrimaryConfig(payload: Partial<LLMPrimaryConfigPayload>): Promise<LLMPrimaryConfigResponse> {
return putJSON<LLMPrimaryConfigResponse>('/api/admin/llm/primary-config', payload)
}
// 静默使用未导出类型,避免 TS6133(未使用的导入)。
export type { LLMMessage, LLMMessageStatus, LLMProvider, LLMPlatformRouter } from './types'
export type { LLMMessage, LLMMessageStatus, LLMProvider, LLMPlatformRouter, LLMPrimaryConfig } from './types'
+152 -1
View File
@@ -5,10 +5,12 @@ import {
deleteLLMProvider,
getLLMProviders,
getLLMToolRouter,
getLLMPrimaryConfig,
updateLLMProvider,
updateLLMToolRouter,
updateLLMPrimaryConfig,
} from '../api'
import type { LLMPlatformRouter, LLMProvider } from '../types'
import type { LLMPlatformRouter, LLMProvider, LLMPrimaryConfig } from '../types'
const loading = ref(false)
const error = ref('')
@@ -42,6 +44,19 @@ const toolRouterForm = ref({
system_prompt: '',
})
// Primary AI Config 相关 - 主 AI 回复配置
const primaryConfig = ref<LLMPrimaryConfig | null>(null)
const editingPrimaryConfig = ref(false)
const primaryConfigForm = ref({
enabled: false,
provider_name: '',
timeout: 120,
max_tokens: 1024,
system_prompt: '',
enable_tool: false,
})
const activeProviders = computed(() => providers.value.filter((p) => p.active))
function clearSuccess() {
@@ -73,6 +88,16 @@ async function loadToolRouter() {
}
}
async function loadPrimaryConfig() {
try {
const response = await getLLMPrimaryConfig()
primaryConfig.value = response.item
} catch (err) {
// 如果不存在,使用默认值
console.warn('Primary AI config not found, using defaults')
}
}
function openCreateProvider() {
isCreatingProvider.value = true
providerForm.value = {
@@ -178,9 +203,40 @@ async function saveToolRouter() {
}
}
function openEditPrimaryConfig() {
if (primaryConfig.value) {
primaryConfigForm.value = {
enabled: primaryConfig.value.enabled,
provider_name: primaryConfig.value.provider_name,
timeout: primaryConfig.value.timeout,
max_tokens: primaryConfig.value.max_tokens,
system_prompt: primaryConfig.value.system_prompt,
enable_tool: primaryConfig.value.enable_tool,
}
}
editingPrimaryConfig.value = true
}
function closePrimaryConfigForm() {
editingPrimaryConfig.value = false
}
async function savePrimaryConfig() {
try {
await updateLLMPrimaryConfig(primaryConfigForm.value)
success.value = '更新成功'
clearSuccess()
closePrimaryConfigForm()
await loadPrimaryConfig()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
}
}
onMounted(() => {
loadProviders()
loadToolRouter()
loadPrimaryConfig()
})
</script>
@@ -330,6 +386,101 @@ onMounted(() => {
</div>
</div>
<!-- Primary AI Config 配置 - AI 回复配置 -->
<div class="admin-section">
<div class="section-header">
<div>
<h3> AI 回复配置</h3>
<p class="section-desc">配置机器人自动回复消息的核心 AI 设置</p>
</div>
<button v-if="!editingPrimaryConfig" class="admin-button" @click="openEditPrimaryConfig">编辑配置</button>
</div>
<div v-if="editingPrimaryConfig" class="tool-router-form">
<div class="form-group">
<label>
<input type="checkbox" v-model="primaryConfigForm.enabled" />
启用 AI 自动回复
</label>
</div>
<div class="form-row">
<div class="form-group">
<label>使用的 AI 配置</label>
<select v-model="primaryConfigForm.provider_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 class="form-group">
<label>是否启用工具调用</label>
<select v-model="primaryConfigForm.enable_tool" class="form-input">
<option :value="false">不启用</option>
<option :value="true">启用</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="primaryConfigForm.timeout" class="form-input" min="1" />
</div>
<div class="form-group">
<label>最大 Token </label>
<input type="number" v-model.number="primaryConfigForm.max_tokens" class="form-input" min="1" />
</div>
</div>
<div class="form-group">
<label>系统提示词</label>
<textarea v-model="primaryConfigForm.system_prompt" class="form-textarea" rows="6"></textarea>
<p class="form-hint">用于指导 AI 如何回复用户消息的系统提示词</p>
</div>
<div class="form-actions">
<button class="admin-button admin-button-secondary" @click="closePrimaryConfigForm">取消</button>
<button class="admin-button" @click="savePrimaryConfig">保存</button>
</div>
</div>
<div v-else-if="primaryConfig" class="tool-router-display">
<div class="router-status">
<span class="status-badge" :class="{ active: primaryConfig.enabled, inactive: !primaryConfig.enabled }">
{{ primaryConfig.enabled ? '已启用' : '已停用' }}
</span>
</div>
<div class="router-details">
<div class="detail-row">
<span class="detail-label">使用的 AI 配置</span>
<span class="detail-value">{{ primaryConfig.provider_name || '未设置' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">超时时间</span>
<span class="detail-value">{{ primaryConfig.timeout }} </span>
</div>
<div class="detail-row">
<span class="detail-label">最大 Token </span>
<span class="detail-value">{{ primaryConfig.max_tokens }}</span>
</div>
<div class="detail-row">
<span class="detail-label">工具调用</span>
<span class="detail-value">{{ primaryConfig.enable_tool ? '已启用' : '未启用' }}</span>
</div>
<div class="detail-row full-width">
<span class="detail-label">系统提示词</span>
<pre class="detail-value system-prompt">{{ primaryConfig.system_prompt }}</pre>
</div>
</div>
</div>
<div v-else class="empty-state">
<p>暂无主 AI 回复配置点击上方按钮进行配置</p>
</div>
</div>
<!-- Provider 表单弹窗 -->
<div v-if="showProviderForm" class="modal-overlay" @click.self="closeProviderForm">
<div class="modal-content">
+26
View File
@@ -668,3 +668,29 @@ export interface LLMPlatformRouterPayload {
export interface LLMPlatformRouterResponse {
item: LLMPlatformRouter
}
// LLM Primary Config 相关类型 - 主 AI 回复配置
export interface LLMPrimaryConfig {
id: number
enabled: boolean
provider_name: string
timeout: number
max_tokens: number
system_prompt: string
enable_tool: boolean
created_at: string
updated_at: string
}
export interface LLMPrimaryConfigPayload {
enabled: boolean
provider_name: string
timeout: number
max_tokens: number
system_prompt: string
enable_tool: boolean
}
export interface LLMPrimaryConfigResponse {
item: LLMPrimaryConfig
}
@@ -0,0 +1,18 @@
{
"id": "bot_1_1781713830520078500",
"bot_id": 1,
"bot_node_id": "!1abcead5",
"title": "[来自 !72d17be4] 你好",
"created_at": "2026-06-18T00:30:30.5200785+08:00",
"updated_at": "2026-06-18T00:30:34.3123551+08:00",
"messages": [
{
"role": "user",
"content": "[来自 !72d17be4] 你好"
},
{
"role": "assistant",
"content": "你好!很高兴见到你!😊\n\n我是DeepSeek,由深度求索公司创造的AI助手。无论你有什么问题、需要帮助,还是只是想聊聊天,我都很乐意陪你!\n\n有什\ufffd\ufffd..."
}
]
}
@@ -0,0 +1,18 @@
{
"id": "bot_1_1781713890525027000",
"bot_id": 1,
"bot_node_id": "!1abcead5",
"title": "[来自 !72d17be4] 你好",
"created_at": "2026-06-18T00:31:30.525027+08:00",
"updated_at": "2026-06-18T00:31:33.1653132+08:00",
"messages": [
{
"role": "user",
"content": "[来自 !72d17be4] 你好"
},
{
"role": "assistant",
"content": "你好!😊 有什么可以帮你的吗?"
}
]
}
@@ -0,0 +1,18 @@
{
"id": "bot_1_1781713920523402600",
"bot_id": 1,
"bot_node_id": "!1abcead5",
"title": "[来自 !72d17be4] 你好",
"created_at": "2026-06-18T00:32:00.5234026+08:00",
"updated_at": "2026-06-18T00:32:04.2936935+08:00",
"messages": [
{
"role": "user",
"content": "[来自 !72d17be4] 你好"
},
{
"role": "assistant",
"content": "你好!很高兴见到你!😊\n\n我是DeepSeek,一个由深度求索公司创造的AI助手。无论你有什么问题、需要帮助,还是只是想聊聊天,我都很乐意陪伴你、\ufffd..."
}
]
}
@@ -0,0 +1,18 @@
{
"id": "bot_1_1781714154187832400",
"bot_id": 1,
"bot_node_id": "!1abcead5",
"title": "[来自 !72d17be4] 你好",
"created_at": "2026-06-18T00:35:54.1878324+08:00",
"updated_at": "2026-06-18T00:35:58.3371209+08:00",
"messages": [
{
"role": "user",
"content": "[来自 !72d17be4] 你好"
},
{
"role": "assistant",
"content": "你好!很高兴见到你!😊\n\n我是DeepSeek,由深度求索公司创造的AI助手。无论你有什么问题、需要帮助,还是只是想聊聊天,我都很乐意陪你一起探索\ufffd..."
}
]
}
@@ -0,0 +1,18 @@
{
"id": "bot_1_1781714169190665700",
"bot_id": 1,
"bot_node_id": "!1abcead5",
"title": "[来自 !72d17be4] 你好",
"created_at": "2026-06-18T00:36:09.1906657+08:00",
"updated_at": "2026-06-18T00:36:13.5432077+08:00",
"messages": [
{
"role": "user",
"content": "[来自 !72d17be4] 你好"
},
{
"role": "assistant",
"content": "你好!很高兴见到你。😊\n\n我是DeepSeek,由深度求索公司创造的AI助手。我可以帮你解答问题、处理文档、进行创作、分析数据……几乎任何文字相关\ufffd..."
}
]
}
@@ -0,0 +1,18 @@
{
"id": "bot_1_1781714179192265800",
"bot_id": 1,
"bot_node_id": "!1abcead5",
"title": "[来自 !72d17be4] 111",
"created_at": "2026-06-18T00:36:19.1922658+08:00",
"updated_at": "2026-06-18T00:36:23.4404859+08:00",
"messages": [
{
"role": "user",
"content": "[来自 !72d17be4] 111"
},
{
"role": "assistant",
"content": "你好!你发送了“111”,请问有什么需要帮助的吗?如果有具体问题或指令,请告诉我。"
}
]
}
@@ -0,0 +1,42 @@
{
"id": "bot_1_1781714224192405100",
"bot_id": 1,
"bot_node_id": "!1abcead5",
"title": "[来自 !72d17be4] 你好",
"created_at": "2026-06-18T00:37:04.1924051+08:00",
"updated_at": "2026-06-18T00:47:31.9941255+08:00",
"messages": [
{
"role": "user",
"content": "[来自 !72d17be4] 你好"
},
{
"role": "assistant",
"content": "你好!很高兴见到你!😊\n\n我收到了你的消息,不过看起来前面那个“!72d17be4”像是一个标识符或者系统生成的代码。如果你有什么具体问题或者需..."
},
{
"role": "user",
"content": "[来自 !72d17be4] 你好"
},
{
"role": "assistant",
"content": "你好!再次见到你很高兴!😊\n\n我看到你又一次带着“!72d17be4”这个标识出现了。这个标识似乎一直在跟随你的消息。如果你需要我帮你处理、解析..."
},
{
"role": "user",
"content": "[来自 !72d17be4] 你好"
},
{
"role": "assistant",
"content": "你好!😊\n\n我看到你第三次发来了相同的消息,并且每次前面都带着 **`!72d17be4`** 这个标识。\n\n如果你是在**测试**或者**重复发送**,我理解这可能是..."
},
{
"role": "user",
"content": "[来自 !72d17be4] 你好"
},
{
"role": "assistant",
"content": "你好! 我注意到你连续四次发送了相同格式的消息,前面都带有 `[来自 !72d17be4]` 标识。这可能是系统自动发送测试或者你希望引起我的注意。 无论..."
}
]
}