From 99fb474bcfb19ff8d85cafd3ac6be7b6a7d57844 Mon Sep 17 00:00:00 2001 From: kevin Date: Wed, 1 Jul 2026 11:55:37 +0800 Subject: [PATCH] =?UTF-8?q?ai=E6=9C=8D=E5=8A=A1=E7=8A=B6=E6=80=81=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/ai/manager.go | 203 ++++++++++++++++++ internal/llmadmin/admin_llm_routes.go | 53 +++++ internal/web/web.go | 3 + main.go | 95 ++++---- meshmap_frontend/src/api.ts | 10 + .../src/components/AdminLLMApi.vue | 85 +++++++- meshmap_frontend/src/types.ts | 7 + py/.gitignore | 3 +- py/migrate_mysql8_to_mysql57.py | 22 +- 9 files changed, 405 insertions(+), 76 deletions(-) create mode 100644 internal/ai/manager.go diff --git a/internal/ai/manager.go b/internal/ai/manager.go new file mode 100644 index 0000000..cfacba6 --- /dev/null +++ b/internal/ai/manager.go @@ -0,0 +1,203 @@ +package ai + +import ( + "context" + "fmt" + "sync" + + "meshtastic_mqtt_server/internal/autoreply" + "meshtastic_mqtt_server/internal/llm" + storepkg "meshtastic_mqtt_server/internal/store" + + "gorm.io/gorm" +) + +// AIServiceStatus reports the current state of the AI service +type AIServiceStatus struct { + Running bool `json:"running"` + Enabled bool `json:"enabled"` + ProviderCount int `json:"provider_count"` + Message string `json:"message,omitempty"` +} + +// AIManager manages the lifecycle of the AI service, supporting restart +type AIManager struct { + mu sync.Mutex + service *Service + cfg Config + db *gorm.DB + botSender autoreply.BotSender + ctx context.Context + store *storepkg.Store +} + +// NewAIManager creates a new AIManager +func NewAIManager(cfg Config, db *gorm.DB, botSender autoreply.BotSender, ctx context.Context, store *storepkg.Store) *AIManager { + return &AIManager{ + cfg: cfg, + db: db, + botSender: botSender, + ctx: ctx, + store: store, + } +} + +// SetConfigEnabled sets the enabled flag on the config +func (m *AIManager) SetConfigEnabled(enabled bool) { + m.cfg.Enabled = enabled +} + +// SetProviderConfigs sets the LLM provider configs +func (m *AIManager) SetProviderConfigs(configs []llm.ProviderConfig) { + m.cfg.LLMProviders = configs +} + +// Init creates and starts the AI service +func (m *AIManager) Init() error { + m.mu.Lock() + defer m.mu.Unlock() + + svc, err := NewService(m.cfg, m.db, m.botSender) + if err != nil { + return fmt.Errorf("failed to create AI service: %w", err) + } + if err := svc.Start(m.ctx); err != nil { + return fmt.Errorf("failed to start AI service: %w", err) + } + m.service = svc + return nil +} + +// Stop stops the currently running AI service +func (m *AIManager) Stop() { + m.mu.Lock() + defer m.mu.Unlock() + if m.service != nil { + m.service.Stop() + m.service = nil + } +} + +// Status returns the current AI service status +func (m *AIManager) Status() AIServiceStatus { + m.mu.Lock() + defer m.mu.Unlock() + + providerCount := len(m.cfg.LLMProviders) + if m.service == nil { + msg := "AI 服务未运行,可在配置提供商后点击重启" + if providerCount == 0 { + msg = "尚未配置 AI 提供商,请先添加提供商配置" + } + return AIServiceStatus{ + Running: false, + Enabled: m.cfg.Enabled, + ProviderCount: providerCount, + Message: msg, + } + } + enabled := m.service.Enabled() + if !enabled { + return AIServiceStatus{ + Running: false, + Enabled: false, + ProviderCount: providerCount, + Message: "AI 服务未启用", + } + } + return AIServiceStatus{ + Running: true, + Enabled: true, + ProviderCount: providerCount, + } +} + +// Restart stops the current AI service and creates a new one from DB +func (m *AIManager) Restart() error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.service != nil { + m.service.Stop() + m.service = nil + } + + providers, err := m.store.ListLLMProviders(true) + if err != nil { + return fmt.Errorf("加载 LLM 提供商列表失败: %w", err) + } + if len(providers) == 0 { + return fmt.Errorf("没有配置任何 LLM 提供商,请先添加配置") + } + + providerConfigs := make([]llm.ProviderConfig, 0, len(providers)) + for _, p := range providers { + 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, + }) + } + m.cfg.LLMProviders = providerConfigs + + svc, err := NewService(m.cfg, m.db, m.botSender) + if err != nil { + return fmt.Errorf("创建 AI 服务失败: %w", err) + } + if err := svc.Start(m.ctx); err != nil { + return fmt.Errorf("启动 AI 服务失败: %w", err) + } + + m.service = svc + return nil +} + +// ReloadLLMProvider delegates to the current service +func (m *AIManager) ReloadLLMProvider(config interface{}) error { + m.mu.Lock() + svc := m.service + m.mu.Unlock() + + if svc == nil { + return nil + } + return svc.ReloadLLMProvider(config) +} + +// AddLLMProvider delegates to the current service +func (m *AIManager) AddLLMProvider(config interface{}) error { + m.mu.Lock() + svc := m.service + m.mu.Unlock() + + if svc == nil { + return nil + } + return svc.AddLLMProvider(config) +} + +// RemoveLLMProvider delegates to the current service +func (m *AIManager) RemoveLLMProvider(name string) error { + m.mu.Lock() + svc := m.service + m.mu.Unlock() + + if svc == nil { + return nil + } + return svc.RemoveLLMProvider(name) +} + +// AIServiceStatus returns the AI service status for the web interface +func (m *AIManager) AIServiceStatus() AIServiceStatus { + return m.Status() +} + +// RestartAIService restarts the AI service for the web interface +func (m *AIManager) RestartAIService() error { + return m.Restart() +} \ No newline at end of file diff --git a/internal/llmadmin/admin_llm_routes.go b/internal/llmadmin/admin_llm_routes.go index ab0bf01..a9b98e7 100644 --- a/internal/llmadmin/admin_llm_routes.go +++ b/internal/llmadmin/admin_llm_routes.go @@ -9,6 +9,7 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + aipkg "meshtastic_mqtt_server/internal/ai" storepkg "meshtastic_mqtt_server/internal/store" "meshtastic_mqtt_server/internal/webutil" ) @@ -18,6 +19,8 @@ type LLMProviderReloader interface { ReloadLLMProvider(config interface{}) error AddLLMProvider(config interface{}) error RemoveLLMProvider(name string) error + AIServiceStatus() aipkg.AIServiceStatus + RestartAIService() error } func RegisterRoutes(r *gin.RouterGroup, store *storepkg.Store, aiService LLMProviderReloader) { @@ -49,6 +52,10 @@ func RegisterRoutes(r *gin.RouterGroup, store *storepkg.Store, aiService LLMProv // LLM Primary Config - 主 AI 回复配置 group.GET("/primary-config", handleGetLLMPrimaryConfig(store)) group.PUT("/primary-config", handleUpdateLLMPrimaryConfig(store)) + + // AI Service Status + group.GET("/status", handleGetAIServiceStatus(aiService)) + group.POST("/restart", handleRestartAIService(aiService)) } } @@ -828,3 +835,49 @@ func llmPrimaryConfigDTO(row storepkg.LLMPrimaryConfigRecord) map[string]any { "updated_at": row.UpdatedAt, } } + +// ============================================ +// AI Service Status & Restart Handlers +// ============================================ + +func handleGetAIServiceStatus(aiService LLMProviderReloader) gin.HandlerFunc { + return func(c *gin.Context) { + if aiService == nil { + c.JSON(http.StatusOK, gin.H{ + "running": false, + "enabled": false, + "provider_count": 0, + "message": "AI 服务未初始化", + }) + return + } + status := aiService.AIServiceStatus() + c.JSON(http.StatusOK, gin.H{ + "running": status.Running, + "enabled": status.Enabled, + "provider_count": status.ProviderCount, + "message": status.Message, + }) + } +} + +func handleRestartAIService(aiService LLMProviderReloader) gin.HandlerFunc { + return func(c *gin.Context) { + if aiService == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "AI 服务未初始化,无法重启"}) + return + } + if err := aiService.RestartAIService(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "重启 AI 服务失败: " + err.Error()}) + return + } + status := aiService.AIServiceStatus() + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "running": status.Running, + "enabled": status.Enabled, + "provider_count": status.ProviderCount, + "message": "AI 服务已重启", + }) + } +} diff --git a/internal/web/web.go b/internal/web/web.go index 41543cd..ae67766 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -13,6 +13,7 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + aipkg "meshtastic_mqtt_server/internal/ai" "meshtastic_mqtt_server/internal/auth" blockingpkg "meshtastic_mqtt_server/internal/blocking" botpkg "meshtastic_mqtt_server/internal/bot" @@ -32,6 +33,8 @@ type LLMProviderReloader interface { ReloadLLMProvider(config interface{}) error AddLLMProvider(config interface{}) error RemoveLLMProvider(name string) error + AIServiceStatus() aipkg.AIServiceStatus + RestartAIService() error } func NewHTTPServer(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender, aiService LLMProviderReloader) *http.Server { diff --git a/main.go b/main.go index bfd4111..2dbb0c1 100644 --- a/main.go +++ b/main.go @@ -398,16 +398,48 @@ func run(cfg *configpkg.Config) error { defer forwardManager.StopAll() // Initialize AI Service - var aiService *ai.Service + // Create bot sender adapter - 支持频道消息和私聊消息两种发送方式 + botSenderAdapter := autoreply.NewBotServiceAdapter( + // SendDirectText: 发送私聊消息 + func(ctx context.Context, botID uint64, toNodeNum int64, text string) error { + _, err := botSender.SendText(ctx, botpkg.SendTextRequest{ + BotID: botID, + MessageType: "direct", + ToNodeNum: &toNodeNum, + Text: text, + }) + return err + }, + // SendChannelText: 发送频道消息 + func(ctx context.Context, botID uint64, channelID string, text string) error { + _, err := botSender.SendText(ctx, botpkg.SendTextRequest{ + BotID: botID, + MessageType: "channel", + ChannelID: channelID, + Text: text, + }) + return err + }, + ) + + aiManager := ai.NewAIManager(ai.Config{ + DataDir: cfg.AI.DataDir, + Enabled: cfg.AI.Enabled, + ConsoleLog: cfg.ConsoleLog.LLM, + ToolConfigStore: store, + ToolRouterStore: store, + TopicRouterStore: store, + Store: store, + }, store.DB(), botSenderAdapter, botCtx, store) + if cfg.AI.Enabled { - // Get LLM providers from database - llmProviders, err := store.ListLLMProviders(true) + aiManager.SetConfigEnabled(true) + providers, 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 { + } else if len(providers) > 0 { + providerConfigs := make([]llm.ProviderConfig, 0, len(providers)) + for _, p := range providers { providerConfigs = append(providerConfigs, llm.ProviderConfig{ Name: p.Name, Active: p.Active, @@ -418,48 +450,11 @@ func run(cfg *configpkg.Config) error { ContextWindowTokens: p.ContextWindowTokens, }) } - - // Create bot sender adapter - 支持频道消息和私聊消息两种发送方式 - botSenderAdapter := autoreply.NewBotServiceAdapter( - // SendDirectText: 发送私聊消息 - func(ctx context.Context, botID uint64, toNodeNum int64, text string) error { - _, err := botSender.SendText(ctx, botpkg.SendTextRequest{ - BotID: botID, - MessageType: "direct", - ToNodeNum: &toNodeNum, - Text: text, - }) - return err - }, - // SendChannelText: 发送频道消息 - func(ctx context.Context, botID uint64, channelID string, text string) error { - _, err := botSender.SendText(ctx, botpkg.SendTextRequest{ - BotID: botID, - MessageType: "channel", - ChannelID: channelID, - Text: text, - }) - return err - }, - ) - - aiService, err = ai.NewService(ai.Config{ - 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 { + aiManager.SetProviderConfigs(providerConfigs) + if err := aiManager.Init(); 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() + defer aiManager.Stop() printJSON(map[string]any{"event": "ai_service_started", "providers": len(providerConfigs)}) } } else { @@ -475,11 +470,7 @@ func run(cfg *configpkg.Config) error { return err } mqttStatus := webpkg.MQTTRuntimeStatus{Server: server, Address: mqttAddr, TLS: cfg.MQTT.TLS.Enabled, Stats: messageStats, ClientStats: clientStats, DBQueue: dbQueue, DedupQueue: mqttHook.dedupQueue} - var llmReloader webpkg.LLMProviderReloader - if aiService != nil { - llmReloader = aiService - } - handler := webpkg.NewRouter(cfg.Web, cfg.ConsoleLog.Web, store, sessions, mqttStatus, blocking, forwardManager, settings, botSender, llmReloader) + handler := webpkg.NewRouter(cfg.Web, cfg.ConsoleLog.Web, store, sessions, mqttStatus, blocking, forwardManager, settings, botSender, aiManager) webAddresses := []string{} if cfg.Web.PortEnabled { httpServer := &http.Server{ diff --git a/meshmap_frontend/src/api.ts b/meshmap_frontend/src/api.ts index 20067b8..dd4fde5 100644 --- a/meshmap_frontend/src/api.ts +++ b/meshmap_frontend/src/api.ts @@ -6,6 +6,7 @@ import type { AdminRuntimeSettingsPayload, AdminRuntimeSettingsResponse, AdminUsersResponse, + AIServiceStatus, BotMessage, BotMessageMutationResponse, BotNode, @@ -519,6 +520,15 @@ export function deleteLLMProvider(name: string): Promise<{ status: string; warni return deleteJSON<{ status: string; warning?: string }>(`/api/admin/llm/providers/${encodeURIComponent(name)}`) } +// AI Service Status API +export function getAIServiceStatus(): Promise { + return getJSON('/api/admin/llm/status') +} + +export function restartAIService(): Promise { + return postJSON('/api/admin/llm/restart') +} + // LLM Tool Router API export function getLLMToolRouter(): Promise { return getJSON('/api/admin/llm/tool-router') diff --git a/meshmap_frontend/src/components/AdminLLMApi.vue b/meshmap_frontend/src/components/AdminLLMApi.vue index c19bff2..a143238 100644 --- a/meshmap_frontend/src/components/AdminLLMApi.vue +++ b/meshmap_frontend/src/components/AdminLLMApi.vue @@ -11,14 +11,20 @@ import { updateLLMToolRouter, updateLLMTopicConfig, updateLLMPrimaryConfig, + getAIServiceStatus, + restartAIService, } from '../api' -import type { LLMPlatformRouter, LLMTopicConfig, LLMProvider, LLMPrimaryConfig } from '../types' +import type { LLMPlatformRouter, LLMTopicConfig, LLMProvider, LLMPrimaryConfig, AIServiceStatus } from '../types' const loading = ref(false) const error = ref('') const success = ref('') const showWarning = ref(false) +// AI Service Status +const aiStatus = ref({ running: false, enabled: false, provider_count: 0 }) +const restarting = ref(false) + // LLM Provider 相关 const providers = ref([]) const editingProvider = ref(null) @@ -299,7 +305,31 @@ async function savePrimaryConfig() { } } +async function loadAIStatus() { + try { + aiStatus.value = await getAIServiceStatus() + } catch (err) { + console.warn('Failed to load AI service status', err) + } +} + +async function handleRestartAI() { + if (!confirm('确定要重启 AI 服务吗?')) return + restarting.value = true + try { + const result = await restartAIService() + aiStatus.value = result + success.value = result.message || 'AI 服务已重启' + clearSuccess() + } catch (err) { + error.value = err instanceof Error ? err.message : String(err) + } finally { + restarting.value = false + } +} + onMounted(() => { + loadAIStatus() loadProviders() loadToolRouter() loadTopicConfig() @@ -311,6 +341,21 @@ onMounted(() => {

LLM API 配置管理

+
+ + + + + + +
+

{{ error }}

{{ success }}

@@ -703,13 +748,49 @@ onMounted(() => { } .admin-llm-api h2 { - margin: 0 0 2rem; + margin: 0 0 1rem; font-size: 1.75rem; font-weight: 700; color: #1e293b; letter-spacing: -0.02em; } +.ai-status-bar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1.25rem; + background: white; + border-radius: 12px; + border: 1px solid #e2e8f0; + margin-bottom: 1.5rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +.status-indicator { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.status-indicator.running { + background: #22c55e; + box-shadow: 0 0 6px rgba(34, 197, 94, 0.5); +} + +.status-indicator.stopped { + background: #ef4444; + box-shadow: 0 0 6px rgba(239, 68, 68, 0.4); +} + +.status-text { + flex: 1; + font-size: 0.9rem; + color: #475569; + font-weight: 500; +} + .admin-section { background: white; padding: 1.75rem; diff --git a/meshmap_frontend/src/types.ts b/meshmap_frontend/src/types.ts index 6e9cdab..31eb745 100644 --- a/meshmap_frontend/src/types.ts +++ b/meshmap_frontend/src/types.ts @@ -647,6 +647,13 @@ export interface LLMProviderResponse { warning?: string } +export interface AIServiceStatus { + running: boolean + enabled: boolean + provider_count: number + message?: string +} + // LLM Tool Router 相关类型 export interface LLMPlatformRouter { id: number diff --git a/py/.gitignore b/py/.gitignore index ed8ebf5..e9c8b09 100644 --- a/py/.gitignore +++ b/py/.gitignore @@ -1 +1,2 @@ -__pycache__ \ No newline at end of file +__pycache__ +db_config.py \ No newline at end of file diff --git a/py/migrate_mysql8_to_mysql57.py b/py/migrate_mysql8_to_mysql57.py index e18ab34..a7a35b1 100644 --- a/py/migrate_mysql8_to_mysql57.py +++ b/py/migrate_mysql8_to_mysql57.py @@ -26,27 +26,7 @@ from typing import Any import pymysql import pymysql.cursors -# ============================================================ -# 硬编码数据库配置 -# ============================================================ - -SOURCE_CONFIG = { - "host": "127.0.0.1", - "port": 3306, - "user": "root", - "password": "PLEASE_CHANGE_ME", - "database": "meshtastic", - "charset": "utf8mb4", -} - -TARGET_CONFIG = { - "host": "127.0.0.1", - "port": 3307, - "user": "root", - "password": "PLEASE_CHANGE_ME", - "database": "meshtastic", - "charset": "utf8mb4", -} +from db_config import SOURCE_CONFIG, TARGET_CONFIG BATCH_SIZE = 5000