Files
Cyrene/backend/ai-core/cmd/main.go
T
AskaEth 6ef9e082a6 feat: 语音流式输入管线 + VAD前端集成 + 插件-工具合并清理
- 前端: VAD语音检测(@ricky0123/vad-web) + useVoiceInput双模式(流式WS/REST)
- Gateway: VoiceStreamManager代理WS流式STT到voice-service
- Voice-service: DashScope REST → Realtime WS → Whisper三级引擎 + ffmpeg转码
- 共享模块: pkg/audio(音频转换) + pkg/dashscope(ASR REST客户端)
- 清理: 移除旧plugin-manager和pkg/plugins,完成插件→工具合并
- 文档: 完善gateway-api.md和voice-service.md语音API文档
- 工具: scripts/voice/ 语音转换脚本集

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 11:50:40 +08:00

1230 lines
39 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"github.com/joho/godotenv"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/background"
aiConfig "git.yeij.top/AskaEth/Cyrene/ai-core/internal/config"
ctxbuild "git.yeij.top/AskaEth/Cyrene/ai-core/internal/context"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/host"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/llm"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/memory"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/model"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/orchestrator"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/persona"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/rag"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/subsession"
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/tools"
plgManager "git.yeij.top/AskaEth/Cyrene-Plugins/manager"
plgSDK "git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
pluginCalc "git.yeij.top/AskaEth/Cyrene-Plugins/calculator"
pluginCrypto "git.yeij.top/AskaEth/Cyrene-Plugins/crypto"
pluginDate "git.yeij.top/AskaEth/Cyrene-Plugins/datetime"
pluginFile "git.yeij.top/AskaEth/Cyrene-Plugins/file"
pluginHTTP "git.yeij.top/AskaEth/Cyrene-Plugins/http"
pluginJSON "git.yeij.top/AskaEth/Cyrene-Plugins/json"
pluginMD "git.yeij.top/AskaEth/Cyrene-Plugins/markdown"
pluginRand "git.yeij.top/AskaEth/Cyrene-Plugins/random"
pluginText "git.yeij.top/AskaEth/Cyrene-Plugins/text"
pluginWF "git.yeij.top/AskaEth/Cyrene-Plugins/web_fetch"
pluginWS "git.yeij.top/AskaEth/Cyrene-Plugins/web_search"
)
var cfg Config
func main() {
// 自动加载 .env 文件(优先从可执行文件位置反推仓库根目录)
_ = godotenv.Load() // 先尝试当前目录
if exe, err := os.Executable(); err == nil {
_ = godotenv.Load(filepath.Join(filepath.Dir(exe), "..", "..", ".env"))
}
// 兜底:如果 LLM_MODEL 仍未设置,打印提示
if os.Getenv("LLM_MODEL") == "" {
log.Println("ℹ 未找到 .env 文件,将使用环境变量或默认值")
}
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("🧠 AI-Core 服务启动中...")
// 加载配置
cfg = loadConfig()
// 初始化人格加载器
personaDir := cfg.PersonaDir
if personaDir == "" {
personaDir = "./internal/persona"
}
personaLoader, err := persona.NewLoader(personaDir)
if err != nil {
log.Fatalf("加载人格配置失败: %v", err)
}
log.Printf("已加载 %d 个人格: %v", len(personaLoader.List()), personaLoader.List())
// 初始化模型配置加载器 (Phase 6: 多模型配置)
configPath := getEnv("MODELS_CONFIG_PATH", "../models.json")
configLoader, err := aiConfig.NewLoader(configPath)
if err != nil {
log.Printf("⚠ 模型配置加载失败,回退到 .env: %v", err)
configLoader = nil
}
// 构建 .env 回退配置
envFallback := llm.OpenAIConfig{
BaseURL: cfg.LLMBaseURL,
APIKey: cfg.LLMAPIKey,
Model: cfg.LLMModel,
FallbackModel: cfg.LLMFallbackModel,
MaxRetries: 3,
Timeout: 180 * time.Second,
}
// 创建 ModelSelector (优先使用 models.json,回退到 .env)
modelSelector := llm.NewModelSelector(configLoader, envFallback)
// 为不同用途创建独立的适配器,支持目的路由
// 在 .env 回退模式下,所有适配器共享同一 provider
chatAdapter := modelSelector.DefaultAdapter()
provider, _ := modelSelector.Select(context.Background(), llm.PurposeIntentAnalysis)
intentAdapter := llm.NewAdapter(provider)
provider, _ = modelSelector.Select(context.Background(), llm.PurposeDeepThinking)
thinkerAdapter := llm.NewAdapter(provider)
provider, _ = modelSelector.Select(context.Background(), llm.PurposeMemoryExtraction)
memoryAdapter := llm.NewAdapter(provider)
provider, _ = modelSelector.Select(context.Background(), llm.PurposeToolCalling)
toolAdapter := llm.NewAdapter(provider)
if configLoader != nil && configLoader.HasConfig() {
log.Printf("LLM适配器已就绪: models.json 驱动 (chat=%s, intent=%s, think=%s, memory=%s, tool=%s)",
chatAdapter.ModelName(), intentAdapter.ModelName(), thinkerAdapter.ModelName(), memoryAdapter.ModelName(), toolAdapter.ModelName())
} else {
log.Printf("LLM适配器已就绪: .env 驱动 (模型=%s)", chatAdapter.ModelName())
}
// 初始化记忆系统
var memStore *memory.Store
var memRetriever *memory.Retriever
var memExtractor *memory.Extractor
if cfg.DatabaseURL != "" {
memStore = memory.NewStore(cfg.DatabaseURL)
defer memStore.Close()
memRetriever = memory.NewRetriever(memStore, nil)
// 记忆提取器使用 memory purpose 适配器
memExtractor = memory.NewExtractor(memStore, func(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error) {
return memoryAdapter.Chat(ctx, messages)
})
log.Println("记忆提取器已就绪")
}
// 初始化会话历史存储
convStore := ctxbuild.NewConversationStore(100)
// 从数据库恢复主会话历史(避免重启丢失上下文)
adminUserID := "admin"
adminSessionID := "admin-session-main"
if cfg.DatabaseURL != "" {
convStore.SetDatabaseURL(cfg.DatabaseURL)
if err := convStore.LoadFromDB(cfg.DatabaseURL, adminSessionID, 50); err != nil {
log.Printf("⚠ 从数据库恢复会话历史失败(不影响服务启动): %v", err)
}
}
log.Println("会话历史存储已就绪 (上限50条)")
// 初始化上下文构建器
ctxBuilder := ctxbuild.NewBuilder(convStore)
// 初始化 IoT 客户端
var iotClient *tools.IoTClient
if cfg.IoTServiceURL != "" {
iotClient = tools.NewIoTClient(cfg.IoTServiceURL)
log.Printf("IoT 客户端已就绪: %s", cfg.IoTServiceURL)
} else {
log.Println("IoT 客户端未配置 (IOT_SERVICE_URL 和 IOT_DEBUG_SERVICE_URL 均为空)")
}
// 初始化主机操控管理器 (沙箱执行 + 文件系统隔离)
hostSandbox := host.NewSandbox(host.DefaultSandboxConfig())
directBackend := host.NewDirectBackend(hostSandbox)
hostManager := host.NewManager(directBackend)
dataDir := getEnv("DATA_DIR", "/tmp/cyrene_data")
hostManager.SetAllowedDirs([]string{dataDir, os.TempDir(), "."})
log.Printf("主机操控管理器已就绪: 沙箱执行 + 文件隔离 (数据目录=%s)", dataDir)
// 初始化完整OS环境管理器 (WSL/Docker,无沙箱限制,供 os_* 工具使用)
osManager := createOSManager()
if osManager != nil {
log.Printf("完整OS环境管理器已就绪: backend=%s", osManager.BackendName())
} else {
log.Println("完整OS环境管理器未配置 (设置 HOST_EXEC_BACKEND=wsl 或 docker 以启用)")
}
// 初始化 RAG 知识库 (Phase 6.6: 知识库 RAG 增强)
knowledgeDir := getEnv("KNOWLEDGE_DIR", "./data/knowledge")
ragEmbedder := rag.NewEmbedder(cfg.LLMBaseURL, cfg.LLMAPIKey, "text-embedding-3-small")
knowledgeStore := rag.NewKnowledgeStore(ragEmbedder, knowledgeDir)
knowledgeRetriever := rag.NewRetriever(knowledgeStore)
log.Printf("RAG 知识库已就绪: 目录=%s, 嵌入模型=text-embedding-3-small", knowledgeDir)
// 初始化工具注册中心 (使用共享插件模块)
toolRegistry := plgManager.NewToolRegistry()
var visionProvider llm.LLMProvider
var ocrProvider llm.LLMProvider
var videoProvider llm.LLMProvider
var asrProvider llm.ASRProvider
if getEnvBool("ENABLE_TOOLS", true) {
// 11 个共享通用插件 — 注册其工具到统一注册中心
registerPluginTools(toolRegistry, &pluginCalc.CalculatorPlugin{})
registerPluginTools(toolRegistry, &pluginDate.DatetimePlugin{})
registerPluginTools(toolRegistry, &pluginText.TextPlugin{})
registerPluginTools(toolRegistry, &pluginCrypto.CryptoPlugin{})
registerPluginTools(toolRegistry, &pluginRand.RandomPlugin{})
registerPluginTools(toolRegistry, &pluginMD.MarkdownPlugin{})
registerPluginTools(toolRegistry, &pluginJSON.JSONPlugin{})
registerPluginTools(toolRegistry, pluginFile.NewFilePlugin(dataDir))
registerPluginTools(toolRegistry, pluginHTTP.NewHTTPPlugin())
searxngURL := getEnv("SEARXNG_URL", "")
if searxngURL != "" {
registerPluginTools(toolRegistry, pluginWS.NewWebSearchPluginWithURL(searxngURL))
} else {
registerPluginTools(toolRegistry, pluginWS.NewWebSearchPlugin())
}
registerPluginTools(toolRegistry, pluginWF.NewWebFetchPlugin())
// ai-core 专属工具 — 通过 sdk.Tool 适配器注册
if iotClient != nil {
toolRegistry.Register(wrapTool(tools.NewIoTQueryTool(iotClient), "iot_query", "Query IoT Devices", "iot"))
toolRegistry.Register(wrapTool(tools.NewIoTControlTool(iotClient), "iot_control", "Control IoT Devices", "iot"))
}
if hostManager != nil {
toolRegistry.Register(wrapTool(tools.NewHostExecTool(hostManager), "host_exec", "Host Command Execution", "system"))
toolRegistry.Register(wrapTool(tools.NewHostFileTool(hostManager), "host_file", "Host File Operations", "system"))
toolRegistry.Register(wrapTool(tools.NewHostSystemTool(hostManager), "host_system", "Host System Info", "system"))
}
if osManager != nil {
toolRegistry.Register(wrapTool(tools.NewOSExecTool(osManager), "os_exec", "OS Command Execution", "system"))
toolRegistry.Register(wrapTool(tools.NewOSFileTool(osManager), "os_file", "OS File Operations", "system"))
toolRegistry.Register(wrapTool(tools.NewOSSystemTool(osManager), "os_system", "OS System Info", "system"))
}
visionProvider = nil
if configLoader != nil && configLoader.HasConfig() {
cfg := configLoader.GetConfig()
if route, ok := cfg.Routing["vision"]; ok && len(route.FallbackChain) > 0 {
for _, mid := range route.FallbackChain {
if _, ok := cfg.Models[mid]; ok {
visionProvider, _ = modelSelector.Select(context.Background(), llm.PurposeVision)
log.Printf("视觉模型已启用: %s", visionProvider.ModelName())
break
}
}
}
}
if visionProvider == nil {
log.Println("视觉模型未配置,vision_analyze 将使用 base64 模式")
}
// 初始化 OCR 模型(与视觉模型并行调用,提供文字提取结果给会话模型自主判断)
ocrProvider = nil
if configLoader != nil && configLoader.HasConfig() {
cfg := configLoader.GetConfig()
if route, ok := cfg.Routing["ocr"]; ok && len(route.FallbackChain) > 0 {
for _, mid := range route.FallbackChain {
if _, ok := cfg.Models[mid]; ok {
ocrProvider, _ = modelSelector.Select(context.Background(), llm.PurposeOCR)
log.Printf("OCR模型已启用: %s", ocrProvider.ModelName())
break
}
}
}
}
if ocrProvider == nil {
log.Println("OCR模型未配置,图片文字提取将复用视觉模型")
}
// 初始化视频理解模型
videoProvider = nil
if configLoader != nil && configLoader.HasConfig() {
cfg := configLoader.GetConfig()
if route, ok := cfg.Routing["video"]; ok && len(route.FallbackChain) > 0 {
for _, mid := range route.FallbackChain {
if _, ok := cfg.Models[mid]; ok {
videoProvider, _ = modelSelector.Select(context.Background(), llm.PurposeVideo)
log.Printf("视频理解模型已启用: %s", videoProvider.ModelName())
break
}
}
}
}
if videoProvider == nil {
log.Println("视频理解模型未配置")
}
// 初始化 ASR 语音识别模型
asrProvider = nil
if configLoader != nil && configLoader.HasConfig() {
cfg := configLoader.GetConfig()
if route, ok := cfg.Routing["speech_recognition"]; ok && len(route.FallbackChain) > 0 {
for _, mid := range route.FallbackChain {
if m, ok := cfg.Models[mid]; ok {
if p, ok := cfg.Providers[m.Provider]; ok {
asrProvider = llm.NewDashScopeASRProvider(p.BaseURL, p.APIKey, m.Name)
log.Printf("ASR语音识别模型已启用: %s", m.Name)
break
}
}
}
}
}
if asrProvider == nil {
log.Println("ASR语音识别模型未配置")
}
toolRegistry.Register(wrapTool(tools.NewVisionTool(visionProvider), "vision_analyze", "Image Vision Analysis & OCR", "multimodal"))
toolRegistry.Register(wrapTool(tools.NewVideoTool(videoProvider), "video_analyze", "Video Understanding & Analysis", "multimodal"))
if knowledgeRetriever != nil {
toolRegistry.Register(wrapTool(tools.NewKnowledgeSearchTool(knowledgeRetriever), "knowledge_search", "Search Knowledge Base", "knowledge"))
toolRegistry.Register(wrapTool(tools.NewKnowledgeIngestTool(knowledgeStore), "knowledge_ingest", "Ingest Knowledge Document", "knowledge"))
}
log.Printf("工具注册中心已就绪: %d 个工具 (%v)", len(toolRegistry.DefinitionNames()), toolRegistry.DefinitionNames())
}
// 初始化后台思考器(增强版:支持工具调用和记忆管理)
thinkerCfg := background.DefaultThinkerConfig()
// 创建记忆服务 HTTP 客户端(用于持久化思考日志到 memory-service
memServiceURL := getEnv("MEMORY_SERVICE_URL", "http://localhost:8091")
memClient := memory.NewClient(memServiceURL)
log.Printf("记忆服务客户端已就绪: %s", memServiceURL)
thinker := background.NewThinker(
thinkerCfg,
personaLoader,
memRetriever,
thinkerAdapter,
toolAdapter,
iotClient,
memStore,
toolRegistry,
convStore,
adminUserID,
adminSessionID,
cfg.AdminNickname,
memClient,
)
// 初始化动态调度加载器 (Phase: thinking-schedule)
schedulePath := getEnv("THINKING_SCHEDULE_PATH", "../thinking_schedule.json")
if scheduleLoader, err := background.NewScheduleLoader(schedulePath); err != nil {
log.Printf("⚠ 思考调度配置加载失败,使用固定间隔: %v", err)
} else if scheduleLoader.HasConfig() {
thinker.SetScheduleLoader(scheduleLoader)
log.Println("[后台思考] 动态调度已启用 (thinking_schedule.json)")
} else {
log.Println("[后台思考] 调度配置文件不存在,使用 .env 固定间隔")
}
thinker.Start()
defer thinker.Stop()
// 设置主动消息推送回调(调用 Gateway 内部 API
gatewayURL := getEnv("GATEWAY_URL", "http://localhost:8080")
internalToken := os.Getenv("INTERNAL_SERVICE_TOKEN")
if internalToken != "" {
proactiveClient := &http.Client{Timeout: 5 * time.Second}
thinker.SetMessagePusher(func(userID, sessionID, message string) {
reqBody, _ := json.Marshal(map[string]string{
"user_id": userID,
"session_id": sessionID,
"content": message,
})
req, _ := http.NewRequest("POST", gatewayURL+"/api/v1/internal/proactive-message", strings.NewReader(string(reqBody)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Internal-Token", internalToken)
resp, err := proactiveClient.Do(req)
if err != nil {
log.Printf("[主动消息] 推送失败: %v", err)
return
}
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
log.Printf("[主动消息] 已推送到 Gateway: user=%s, len=%d", userID, len(message))
} else {
log.Printf("[主动消息] Gateway 返回 %d", resp.StatusCode)
}
})
log.Printf("[主动消息] 推送已启用 (Gateway=%s)", gatewayURL)
} else {
log.Println("[主动消息] 未配置 INTERNAL_SERVICE_TOKEN,主动消息推送已禁用")
}
// 健康检查与对话API的HTTP mux
mux := http.NewServeMux()
// 初始化子会话管理器
subManager := subsession.NewManager(chatAdapter)
// 注册子会话提供者
subManager.Register(subsession.NewGeneralProvider(personaLoader))
if memRetriever != nil {
memProvider := subsession.NewMemoryProvider(memRetriever)
memProvider.SetFuzzySearch(memoryAdapter, memClient)
subManager.Register(memProvider)
}
if iotClient != nil {
subManager.Register(subsession.NewIoTProvider(iotClient, personaDir))
}
subManager.Register(subsession.NewReviewProvider())
if knowledgeRetriever != nil {
subManager.Register(subsession.NewKnowledgeProvider(knowledgeRetriever))
}
log.Printf("子会话管理器已就绪: %d 个提供者 (%v)", len(subManager.ListProviders()), subManager.ListProviders())
// 构建新的 Orchestrator (v2.0) — 传入 purpose 专用适配器
orch := orchestrator.NewOrchestrator(
personaLoader,
ctxBuilder,
chatAdapter,
intentAdapter,
subManager,
memRetriever,
memExtractor,
)
orch.SetToolRegistry(toolRegistry)
if visionProvider != nil {
orch.SetVisionProvider(visionProvider)
log.Printf("对话编排器: 视觉模型已注入 (%s)", visionProvider.ModelName())
}
if ocrProvider != nil {
orch.SetOCRProvider(ocrProvider)
log.Printf("对话编排器: OCR模型已注入 (%s)", ocrProvider.ModelName())
}
if videoProvider != nil {
orch.SetVideoProvider(videoProvider)
log.Printf("对话编排器: 视频模型已注入 (%s)\n", videoProvider.ModelName())
} else {
log.Println("对话编排器: 视频模型未配置,视频理解功能不可用")
}
if asrProvider != nil && asrProvider.IsAvailable() {
orch.SetASRProvider(asrProvider)
log.Printf("对话编排器: ASR语音识别模型已注入 (%s)\n", asrProvider.ModelName())
} else {
log.Println("对话编排器: ASR语音识别模型未配置")
}
log.Println("对话编排器 v2.0 已就绪")
_ = orch
// 注册对话API端点
mux.HandleFunc("/api/v1/chat", func(w http.ResponseWriter, r *http.Request) {
handleChat(w, r, orch, ctxBuilder, personaLoader, memRetriever, memExtractor, iotClient, thinker, toolRegistry, adminSessionID)
})
// 注册记忆API端点
mux.HandleFunc("/api/v1/memory/search", func(w http.ResponseWriter, r *http.Request) {
handleMemorySearch(w, r, memRetriever)
})
mux.HandleFunc("/api/v1/memory", func(w http.ResponseWriter, r *http.Request) {
handleMemoryCRUD(w, r, memStore, memExtractor)
})
// Phase 2: 在线状态通知端点 (Gateway -> ai-core)
mux.HandleFunc("/api/v1/internal/presence", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if r.Header.Get("X-Internal-Token") != os.Getenv("INTERNAL_SERVICE_TOKEN") {
w.WriteHeader(http.StatusUnauthorized)
return
}
var req struct {
UserID string `json:"user_id"`
Status string `json:"status"`
SessionID string `json:"session_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
online := req.Status == "online"
thinker.UpdatePresence(online, req.SessionID)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok","service":"ai-core","model":"` + chatAdapter.ModelName() + `"}`))
})
// LLM 调用日志(调试用)
mux.HandleFunc("/api/v1/llm-calls", func(w http.ResponseWriter, r *http.Request) {
limit := 50
if n, err := fmt.Sscanf(r.URL.Query().Get("limit"), "%d", &limit); n != 1 || err != nil || limit <= 0 {
limit = 50
}
if limit > 500 {
limit = 500
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"calls": llm.GetCalls(limit),
"total": len(llm.GetCalls(0)),
})
})
// LLM 调用 SSE 实时推送
mux.HandleFunc("/api/v1/llm-calls/stream", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
ch, done := llm.SubscribeCalls()
defer llm.UnsubscribeCalls(ch)
for {
select {
case rec := <-ch:
data, _ := json.Marshal(rec)
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
case <-done:
return
case <-r.Context().Done():
return
}
}
})
// 工具调用记录
mux.HandleFunc("/api/v1/tools/calls", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
toolName := r.URL.Query().Get("tool_name")
limit := 50
if n, err := fmt.Sscanf(r.URL.Query().Get("limit"), "%d", &limit); n != 1 || err != nil || limit <= 0 {
limit = 50
}
if limit > 500 {
limit = 500
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
offset := (page - 1) * limit
calls, total := toolRegistry.GetCallLogs(toolName, limit, offset)
if calls == nil {
calls = []plgManager.CallLogRecord{}
}
totalPages := total / limit
if total%limit != 0 {
totalPages++
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"calls": calls, "total": total, "total_pages": totalPages,
"page": page, "limit": limit,
})
})
// 工具调用统计
mux.HandleFunc("/api/v1/tools/calls/stats", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(toolRegistry.GetCallStats())
})
// OS 环境监控端点
mux.HandleFunc("/api/v1/system/info", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
result := map[string]interface{}{
"os_enabled": osManager != nil,
}
if osManager != nil {
result["backend"] = osManager.BackendName()
result["system"] = osManager.SystemInfo()
if disk, err := osManager.DiskUsage("/"); err == nil {
result["disk"] = disk
}
}
if hostManager != nil {
result["host"] = map[string]interface{}{
"backend": hostManager.BackendName(),
"system": hostManager.SystemInfo(),
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
})
// 启动HTTP服务
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: mux,
}
go func() {
log.Printf("🚀 AI-Core 服务已启动在端口 %s", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("服务启动失败: %v", err)
}
}()
// 优雅关闭
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("正在关闭 AI-Core 服务...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(ctx)
log.Println("AI-Core 服务已关闭")
}
// Config AI-Core配置
type Config struct {
Port string
PersonaDir string
LLMBaseURL string
LLMAPIKey string
LLMModel string
LLMFallbackModel string
DatabaseURL string
IoTServiceURL string
AdminNickname string // 昔涟对管理员用户的基本称呼
}
func loadConfig() Config {
return Config{
Port: getEnv("AI_CORE_PORT", "8081"),
PersonaDir: getEnv("PERSONA_DIR", "./internal/persona"),
LLMBaseURL: getEnv("LLM_API_URL", "https://api.openai.com/v1"),
LLMAPIKey: getEnv("LLM_API_KEY", ""),
LLMModel: getEnv("LLM_MODEL", "gpt-4o"),
LLMFallbackModel: getEnv("LLM_FALLBACK_MODEL", "gpt-4o-mini"),
DatabaseURL: buildDatabaseURL(),
IoTServiceURL: getEnvWithFallback("IOT_SERVICE_URL", "IOT_DEBUG_SERVICE_URL", ""),
AdminNickname: getEnv("ADMIN_NICKNAME", "管理员"),
}
}
func buildDatabaseURL() string {
host := getEnv("POSTGRES_HOST", "localhost")
port := getEnv("POSTGRES_PORT", "5432")
user := getEnv("POSTGRES_USER", "cyrene")
password := getEnv("POSTGRES_PASSWORD", "cyrene_pass")
dbname := getEnv("POSTGRES_DB", "cyrene_ai")
sslmode := getEnv("POSTGRES_SSLMODE", "disable")
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s",
user, password, host, port, dbname, sslmode)
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// getEnvWithFallback 获取环境变量,优先使用 primaryKey,如果为空则回退到 fallbackKey
func getEnvWithFallback(primaryKey, fallbackKey, defaultVal string) string {
if v := os.Getenv(primaryKey); v != "" {
return v
}
if v := os.Getenv(fallbackKey); v != "" {
return v
}
return defaultVal
}
func getEnvBool(key string, fallback bool) bool {
v := os.Getenv(key)
if v == "" {
return fallback
}
switch strings.ToLower(v) {
case "true", "1", "yes", "on":
return true
case "false", "0", "no", "off":
return false
default:
return fallback
}
}
// createOSManager 根据 HOST_EXEC_BACKEND 环境变量创建完整OS环境管理器。
// 支持 "wsl" 和 "docker" 两种后端。返回 nil 表示未配置或配置无效。
func createOSManager() *host.Manager {
backend := strings.ToLower(os.Getenv("HOST_EXEC_BACKEND"))
switch backend {
case "wsl":
distro := getEnv("WSL_DISTRO", "Ubuntu-22.04")
username := getEnv("WSL_USER", "cyrene")
password := os.Getenv("WSL_USER_PASSWORD")
maxTimeout := time.Duration(getEnvInt("HOST_EXEC_MAX_TIMEOUT", 300)) * time.Second
wslBackend := host.NewWSLBackend(distro, username, password, maxTimeout)
return host.NewManager(wslBackend)
case "docker":
container := getEnv("SANDBOX_CONTAINER", "cyrene-sandbox")
image := getEnv("SANDBOX_IMAGE", "ubuntu:22.04")
maxTimeout := time.Duration(getEnvInt("HOST_EXEC_MAX_TIMEOUT", 300)) * time.Second
dockerBackend := host.NewDockerBackend(container, image, maxTimeout)
return host.NewManager(dockerBackend)
default:
return nil
}
}
// getEnvInt 获取整数类型的环境变量
func getEnvInt(key string, fallback int) int {
v := os.Getenv(key)
if v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return fallback
}
return n
}
// registerPluginTools 从插件实例注册其所有工具到注册中心
func registerPluginTools(registry *plgManager.ToolRegistry, plugin plgSDK.Plugin) {
for _, t := range plugin.Tools() {
if err := registry.Register(t); err != nil {
log.Printf("⚠ 注册工具失败: %v", err)
}
}
}
// wrapTool 包装 ai-core 旧 ToolExecutor 为 sdk.Tool
func wrapTool(executor tools.ToolExecutor, id, displayName, category string) plgSDK.Tool {
return &toolAdapter{
executor: executor,
def: plgSDK.ToolDefinition{
ID: id, Name: id, DisplayName: displayName,
Description: executor.Definition().Description,
Category: category,
Complexity: plgSDK.ComplexitySimple,
Parameters: executor.Definition().Parameters,
},
}
}
type toolAdapter struct {
plgSDK.BaseTool
executor tools.ToolExecutor
def plgSDK.ToolDefinition
}
func (a *toolAdapter) Definition() plgSDK.ToolDefinition { return a.def }
func (a *toolAdapter) Execute(ctx context.Context, args map[string]interface{}) (*plgSDK.ToolResult, error) {
result, err := a.executor.Execute(ctx, args)
if err != nil {
return nil, err
}
return &plgSDK.ToolResult{
ToolName: result.ToolName, Success: result.Success,
Output: result.Data, Error: result.Error,
}, nil
}
// buildOpenAITools 将工具注册中心的定义转换为 LLM 层的 OpenAITool 格式
func buildOpenAITools(registry *plgManager.ToolRegistry) []llm.OpenAITool {
if registry == nil || !registry.IsEnabled() {
return nil
}
defs := registry.Definitions()
if len(defs) == 0 {
return nil
}
result := make([]llm.OpenAITool, 0, len(defs))
for _, d := range defs {
result = append(result, llm.OpenAITool{
Type: "function",
Function: llm.OpenAIToolFunc{
Name: d.Name,
Description: d.Description,
Parameters: d.Parameters,
},
})
}
return result
}
// handleChat 处理对话请求(SSE 流式响应)— 使用新 Orchestrator v2.0
func handleChat(
w http.ResponseWriter,
r *http.Request,
orch *orchestrator.Orchestrator,
ctxBuilder *ctxbuild.Builder,
_ *persona.Loader,
_ *memory.Retriever,
_ *memory.Extractor,
iotClient *tools.IoTClient,
thinker *background.Thinker,
_ *plgManager.ToolRegistry,
adminSessionID string,
) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 解析请求
var req struct {
UserID string `json:"user_id"`
SessionID string `json:"session_id"`
Message string `json:"message"`
Images []string `json:"images,omitempty"` // 图片 base64 data URL
VideoURLs []string `json:"video_urls,omitempty"` // 视频 URL (多模态)
VoiceURLs []string `json:"voice_urls,omitempty"` // 语音 URL (ASR 转录)
Mode string `json:"mode"`
Nickname string `json:"nickname,omitempty"`
IsAdmin bool `json:"is_admin"`
Source struct {
Platform string `json:"platform"`
ChannelID string `json:"channel_id"`
ChannelType string `json:"channel_type"`
SenderName string `json:"sender_name"`
OriginalUID string `json:"original_uid"`
} `json:"source,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的请求体", http.StatusBadRequest)
return
}
if req.Mode == "" {
req.Mode = "text"
}
// 平台静默观察模式:只记录消息、提取记忆、触发后台思考,不生成回复。
if req.Mode == "platform_silent" {
if thinker != nil {
thinker.RecordUserMessage(req.SessionID)
}
// 图片预处理:静默观察时也分析图片内容,供后台思考使用
message := req.Message
if len(req.Images) > 0 {
startTime := time.Now()
augmented := orch.PreprocessImages(r.Context(), message, req.Images)
if augmented != message {
message = augmented
log.Printf("[silent] 图片预处理耗时: %%v", time.Since(startTime))
}
}
ctxBuilder.CacheMessage(req.SessionID, model.RoleUser, message)
// 从观察到的群聊消息中提取记忆。
orch.ExtractMemoriesOnly(r.Context(), req.UserID, req.SessionID, message)
if thinker != nil {
thinker.TriggerPostChatThink()
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"silent_processed"}`))
return
}
ctx := r.Context()
// Inject admin flag for tool access control.
ctx = context.WithValue(ctx, plgManager.CtxKeyIsAdmin, req.IsAdmin)
// 0. 记录用户活动(重置闲置计时器)
if thinker != nil {
thinker.RecordUserMessage(req.SessionID)
}
// Admin private messages: redirect to the main admin session so conversation
// history is shared across platforms (QQ, web UI, etc.).
if req.UserID == "admin" && req.Source.ChannelType == "direct" && adminSessionID != "" {
req.SessionID = adminSessionID
}
// 确定用户昵称
userNickname := req.Nickname
if userNickname == "" {
userNickname = cfg.AdminNickname
}
// 1. 设置 SSE 响应头
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
return
}
// 1.5 缓存用户消息到会话历史(在 Orchestrator 之前,确保顺序正确:user → assistant
ctxBuilder.CacheMessage(req.SessionID, model.RoleUser, req.Message)
// 2. 调用 Orchestrator 处理(替代原有的线性处理流程)
// Orchestrator 内部处理:意图分析 → 子会话分派 → 结果汇总 → 综合生成回复
eventCh, err := orch.ProcessInput(ctx, orchestrator.ProcessParams{
UserID: req.UserID,
SessionID: req.SessionID,
Message: req.Message,
Images: req.Images,
VideoURLs: req.VideoURLs,
VoiceURLs: req.VoiceURLs,
Mode: req.Mode,
Nickname: userNickname,
ChannelType: req.Source.ChannelType,
})
if err != nil {
errData, _ := json.Marshal(map[string]string{"delta": "", "error": fmt.Sprintf("处理失败: %v", err)})
fmt.Fprintf(w, "data: %s\n\n", errData)
flusher.Flush()
fmt.Fprintf(w, "data: [DONE]\n\n")
flusher.Flush()
return
}
messageID := fmt.Sprintf("msg-%d", time.Now().UnixNano())
// 3. 流式输出 SSE
var fullContent string
for event := range eventCh {
switch event.Type {
case model.StreamToolProgress:
tp := event.ToolProgress
progressData, _ := json.Marshal(map[string]interface{}{
"type": "tool_progress",
"tool_name": tp.ToolName,
"status": tp.Status,
"progress": tp.Progress,
"message": tp.Message,
"message_id": messageID,
})
fmt.Fprintf(w, "data: %s\n\n", progressData)
flusher.Flush()
case model.StreamError:
log.Printf("[chat] 流式错误: %v", event.Error)
errData, _ := json.Marshal(map[string]string{"delta": "", "error": event.Error.Error()})
fmt.Fprintf(w, "data: %s\n\n", errData)
flusher.Flush()
fmt.Fprintf(w, "data: [DONE]\n\n")
flusher.Flush()
return
case model.StreamDelta:
fullContent += event.Delta
deltaData, _ := json.Marshal(map[string]string{
"delta": event.Delta,
"message_id": messageID,
})
fmt.Fprintf(w, "data: %s\n\n", deltaData)
flusher.Flush()
case model.StreamSegments:
// 发送断句信息
segData, _ := json.Marshal(map[string]interface{}{
"message_id": messageID,
"mode": req.Mode,
"segments": event.Segments,
})
fmt.Fprintf(w, "data: %s\n\n", segData)
flusher.Flush()
case model.StreamReview:
// 发送审查后的结构化消息(动作消息 + 聊天消息)
reviewData, _ := json.Marshal(map[string]interface{}{
"message_id": messageID,
"review_messages": event.ReviewMessages,
})
fmt.Fprintf(w, "data: %s\n\n", reviewData)
flusher.Flush()
case model.StreamDone:
// 下发结束标记
endData, _ := json.Marshal(map[string]interface{}{
"message_id": messageID,
"mode": req.Mode,
"done": true,
})
fmt.Fprintf(w, "data: %s\n\n", endData)
flusher.Flush()
fmt.Fprintf(w, "data: [DONE]\n\n")
flusher.Flush()
}
}
// 4. 对话完成后触发昔涟的自主思考(事件驱动,非定时)
if thinker != nil {
thinker.TriggerPostChatThink()
}
}
// handleMemorySearch 处理记忆搜索请求
func handleMemorySearch(
w http.ResponseWriter,
r *http.Request,
memRetriever *memory.Retriever,
) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
userID := r.URL.Query().Get("user_id")
if userID == "" {
http.Error(w, "缺少 user_id 参数", http.StatusBadRequest)
return
}
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "缺少 q 参数", http.StatusBadRequest)
return
}
if memRetriever == nil {
log.Printf("[memory] 记忆检索器未初始化: 数据库不可用")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"user_id": userID,
"query": query,
"memories": []interface{}{},
"error": "记忆系统未就绪",
"errorType": "memory_store_unavailable",
"hint": "PostgreSQL 数据库不可用,请检查数据库连接配置",
})
return
}
ctx := r.Context()
memories, err := memRetriever.Retrieve(ctx, userID, query)
if err != nil {
log.Printf("[memory] 检索失败: %v", err)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"user_id": userID,
"query": query,
"memories": []interface{}{},
"error": fmt.Sprintf("检索失败: %v", err),
"errorType": "retrieve_failed",
})
return
}
if memories == nil {
memories = []memory.MemoryEntry{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"user_id": userID,
"query": query,
"memories": memories,
"total": len(memories),
})
}
// handleMemoryCRUD 处理记忆的 CRUD 操作
func handleMemoryCRUD(
w http.ResponseWriter,
r *http.Request,
memStore *memory.Store,
memExtractor *memory.Extractor,
) {
switch r.Method {
case http.MethodGet:
// 列出用户的所有记忆
userID := r.URL.Query().Get("user_id")
if userID == "" {
http.Error(w, "缺少 user_id 参数", http.StatusBadRequest)
return
}
if memStore == nil {
log.Printf("[memory] 记忆存储未初始化: 数据库不可用")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"user_id": userID,
"memories": []interface{}{},
"error": "记忆系统未就绪",
"errorType": "memory_store_unavailable",
"hint": "PostgreSQL 数据库不可用,请检查数据库连接配置",
})
return
}
ctx := r.Context()
memories, err := memStore.Query(ctx, model.MemoryQuery{
UserID: userID,
Limit: 50,
})
if err != nil {
log.Printf("[memory] 查询失败: %v", err)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"user_id": userID,
"memories": []interface{}{},
"error": fmt.Sprintf("查询失败: %v", err),
"errorType": "query_failed",
})
return
}
if memories == nil {
memories = []model.MemoryEntry{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"user_id": userID,
"memories": memories,
"total": len(memories),
})
case http.MethodDelete:
// 删除单条记忆: DELETE /api/v1/memory?id=xxx
memoryID := r.URL.Query().Get("id")
if memoryID == "" {
http.Error(w, "缺少 id 参数", http.StatusBadRequest)
return
}
if memStore == nil {
log.Printf("[memory] 记忆存储未初始化: 无法删除")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "记忆系统未就绪",
"errorType": "memory_store_unavailable",
"hint": "PostgreSQL 数据库不可用,请检查数据库连接配置",
})
return
}
ctx := r.Context()
if err := memStore.Delete(ctx, memoryID); err != nil {
log.Printf("[memory] 删除失败: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": fmt.Sprintf("删除失败: %v", err),
"errorType": "delete_failed",
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "deleted",
"memory_id": memoryID,
})
case http.MethodPost:
// 手动添加记忆
var req struct {
UserID string `json:"user_id"`
Content string `json:"content"`
Category string `json:"category"`
Priority int `json:"priority"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的请求体", http.StatusBadRequest)
return
}
if req.UserID == "" || req.Content == "" {
http.Error(w, "缺少 user_id 或 content", http.StatusBadRequest)
return
}
if req.Category == "" {
req.Category = "other"
}
if req.Priority <= 0 {
req.Priority = 1
}
if memStore == nil {
log.Printf("[memory] 记忆存储未初始化: 无法保存")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "记忆系统未就绪",
"errorType": "memory_store_unavailable",
"hint": "PostgreSQL 数据库不可用,请检查数据库连接配置",
})
return
}
entry := &model.MemoryEntry{
UserID: req.UserID,
Content: req.Content,
Category: model.MemoryCategory(req.Category),
Priority: model.MemoryPriority(req.Priority),
}
ctx := r.Context()
if err := memStore.Save(ctx, entry); err != nil {
log.Printf("[memory] 保存失败: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": fmt.Sprintf("保存失败: %v", err),
"errorType": "save_failed",
})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "saved",
"memory": entry,
})
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}