- platform-bridge: unified message model + Adapter interface - qq/adapter: OBv11 WebSocket protocol with @mention detection - telegram/discord: stub adapters with send capability - webhook: HTTP POST receiver for generic webhook integration - Router: message routing from platform → AI-Core → back - Group chat rules: 500 char limit, 3s gap, silence keywords - main.go: conditional adapter loading via env vars
279 lines
8.8 KiB
Go
279 lines
8.8 KiB
Go
// Cyrene v2 — LLM-powered AI companion platform.
|
|
// Single binary entry point that wires all services together.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/nats"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/platform"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/plugin"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/calculator"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/crypto"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/datetime"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/file"
|
|
httpplugin "git.yeij.top/AskaEth/Cyrene_v2/plugins/http"
|
|
iotmock "git.yeij.top/AskaEth/Cyrene_v2/plugins/iot-mock"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/json"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/markdown"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/random"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/text"
|
|
webfetch "git.yeij.top/AskaEth/Cyrene_v2/plugins/web-fetch"
|
|
websearch "git.yeij.top/AskaEth/Cyrene_v2/plugins/web-search"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/llm"
|
|
aiCoreCtx "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/context"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/orchestrator"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/persona"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/subsession"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/api"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/ws"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/services/memory"
|
|
bridge "git.yeij.top/AskaEth/Cyrene_v2/services/platform-bridge"
|
|
bridgeqq "git.yeij.top/AskaEth/Cyrene_v2/services/platform-bridge/qq"
|
|
bridgetg "git.yeij.top/AskaEth/Cyrene_v2/services/platform-bridge/telegram"
|
|
bridgedc "git.yeij.top/AskaEth/Cyrene_v2/services/platform-bridge/discord"
|
|
bridgewh "git.yeij.top/AskaEth/Cyrene_v2/services/platform-bridge/webhook"
|
|
voicesvc "git.yeij.top/AskaEth/Cyrene_v2/services/voice"
|
|
)
|
|
|
|
func main() {
|
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
|
log.Println("🧠 Cyrene v2 启动中...")
|
|
|
|
// === 1. Platform & Config ===
|
|
plat := platform.Detect()
|
|
log.Printf("[platform] %s", plat.Summary())
|
|
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
log.Printf("[config] .env load failed: %v", err)
|
|
}
|
|
cfg.ValidateOrFatal()
|
|
cfg.LogSanitized()
|
|
plat.EnsureDirs()
|
|
|
|
// === 2. Database ===
|
|
database, err := db.Connect(db.Config{
|
|
Host: cfg.PostgresHost, Port: cfg.PostgresPort,
|
|
User: cfg.PostgresUser, Password: cfg.PostgresPassword,
|
|
DBName: cfg.PostgresDB, SSLMode: cfg.PostgresSSLMode,
|
|
MaxOpenConns: 25, MaxIdleConns: 5,
|
|
})
|
|
if err != nil {
|
|
log.Printf("[db] warning: %v (running without persistence)", err)
|
|
} else {
|
|
defer database.Close()
|
|
db.RunMigration(context.Background(), database, "memories", db.SplitSQL(memory.CreateTableSQL()))
|
|
}
|
|
|
|
// === 3. NATS (in-process) ===
|
|
natsConn := nats.NewInProcess()
|
|
log.Printf("[nats] ready")
|
|
_ = natsConn
|
|
|
|
// === 4. Plugin Registry ===
|
|
reg := plugin.NewRegistry()
|
|
pluginCfg := plugin.PluginConfig{
|
|
DataDir: cfg.DataDir,
|
|
NATSURL: cfg.NATSUrl,
|
|
DatabaseURL: cfg.GetDatabaseURL(),
|
|
LogLevel: cfg.LogLevel,
|
|
}
|
|
|
|
// Load IoT plugin
|
|
if cfg.IoTProvider == "mock" || cfg.IoTProvider == "" {
|
|
reg.Register(iotmock.New(), pluginCfg)
|
|
}
|
|
|
|
// Load tool plugins
|
|
reg.Register(calculator.New(), pluginCfg)
|
|
reg.Register(datetime.New(), pluginCfg)
|
|
reg.Register(crypto.New(), pluginCfg)
|
|
reg.Register(random.New(), pluginCfg)
|
|
reg.Register(text.New(), pluginCfg)
|
|
reg.Register(json.New(), pluginCfg)
|
|
reg.Register(markdown.New(), pluginCfg)
|
|
reg.Register(file.New(), pluginCfg)
|
|
reg.Register(httpplugin.New(), pluginCfg)
|
|
reg.Register(webfetch.New(), pluginCfg)
|
|
if cfg.SearXNGURL != "" {
|
|
reg.Register(websearch.New(cfg.SearXNGURL), pluginCfg)
|
|
}
|
|
|
|
log.Printf("[plugin] loaded %d plugins: %v", reg.Count(), reg.ListNames())
|
|
|
|
// === 5. Voice Service ===
|
|
voiceSvc := voicesvc.New(voicesvc.Config{
|
|
STTProvider: cfg.ASRProvider,
|
|
TTSProviders: []string{cfg.TTSProvider},
|
|
DashScopeKey: cfg.DashScopeAPIKey,
|
|
})
|
|
log.Printf("[voice] ready")
|
|
|
|
// === 6. Memory Service ===
|
|
var memSvc *memory.Service
|
|
if database != nil {
|
|
memRepo := memory.NewRepository(database)
|
|
memSvc = memory.NewService(memRepo)
|
|
log.Printf("[memory] connected")
|
|
} else {
|
|
log.Printf("[memory] disabled (no database)")
|
|
}
|
|
|
|
// === 7. AI-Core: Persona ===
|
|
personaLoader, err := persona.NewLoader(cfg.PersonaDir)
|
|
if err != nil {
|
|
log.Printf("[persona] load error: %v (using builtin)", err)
|
|
}
|
|
log.Printf("[persona] loaded %d persona(s): %v", personaLoader.Count(), personaLoader.List())
|
|
personaInjector := persona.NewInjector(personaLoader.Default())
|
|
|
|
// === 8. AI-Core: LLM Adapter ===
|
|
llmCfg := llm.DefaultConfig()
|
|
llmCfg.BaseURL = cfg.LLMBaseURL
|
|
llmCfg.APIKey = cfg.LLMAPIKey
|
|
llmCfg.Model = cfg.LLMModel
|
|
llmCfg.FallbackModel = cfg.LLMFallbackModel
|
|
llmSelector := llm.NewModelSelector(llmCfg)
|
|
log.Printf("[llm] adapter ready (model=%s)", cfg.LLMModel)
|
|
|
|
// === 9. AI-Core: Context Store ===
|
|
ctxStore := aiCoreCtx.NewStore(4000, 50)
|
|
log.Printf("[context] sliding window ready (4000 tokens, 50 turns)")
|
|
|
|
// === 10. AI-Core: Sub-session Manager ===
|
|
subMgr := subsession.NewManager(
|
|
&subsession.GeneralProvider{},
|
|
&subsession.MemoryProvider{},
|
|
&subsession.IoTProvider{
|
|
GetDeviceState: func(ctx context.Context) (string, error) {
|
|
iot := reg.GetIoTProvider()
|
|
if iot == nil {
|
|
return "", fmt.Errorf("no iot provider")
|
|
}
|
|
devices, err := iot.ListDevices(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
states := make([]orchestrator.DeviceState, len(devices))
|
|
for i, d := range devices {
|
|
states[i] = orchestrator.DeviceState{ID: d.ID, Name: d.Name, State: d.State}
|
|
}
|
|
return orchestrator.BuildIoTContext(states), nil
|
|
},
|
|
},
|
|
)
|
|
log.Printf("[subsession] 4 providers ready")
|
|
|
|
// === 11. AI-Core: Orchestrator ===
|
|
semanticCache := llm.NewSemanticCache()
|
|
orch := orchestrator.NewPipeline(llmSelector, semanticCache, ctxStore, subMgr, personaInjector)
|
|
log.Printf("[orchestrator] pipeline ready")
|
|
|
|
// === 11.5 Platform Bridge ===
|
|
bridgeRouter := bridge.NewRouter()
|
|
// QQ adapter — enabled when OBv11 WebSocket URL is configured
|
|
if qqURL := os.Getenv("QQ_OBV11_WS_URL"); qqURL != "" {
|
|
qqAdapter := bridgeqq.New(bridgeqq.Config{
|
|
Name: "qq-main", WSURL: qqURL,
|
|
AccessToken: os.Getenv("QQ_ACCESS_TOKEN"),
|
|
BotQQ: os.Getenv("QQ_BOT_QQ"),
|
|
})
|
|
bridgeRouter.Register(qqAdapter)
|
|
}
|
|
// Telegram
|
|
if tgToken := os.Getenv("TG_BOT_TOKEN"); tgToken != "" {
|
|
bridgeRouter.Register(bridgetg.New(bridgetg.Config{Name: "tg-main", Token: tgToken}))
|
|
}
|
|
// Discord
|
|
if dcToken := os.Getenv("DC_BOT_TOKEN"); dcToken != "" {
|
|
bridgeRouter.Register(bridgedc.New(bridgedc.Config{Name: "dc-main", Token: dcToken}))
|
|
}
|
|
// Webhook
|
|
if whAddr := os.Getenv("WEBHOOK_LISTEN_ADDR"); whAddr != "" {
|
|
bridgeRouter.Register(bridgewh.New(bridgewh.Config{Name: "webhook-main", ListenAddr: whAddr}))
|
|
}
|
|
|
|
// Route incoming messages to orchestrator
|
|
bridgeRouter.SetMessageHandler(func(msg *bridge.UnifiedMessage) {
|
|
log.Printf("[bridge] incoming %s from %s: %s", msg.Platform, msg.SenderName, msg.Content)
|
|
tokenCh, _, err := orch.ProcessInput(context.Background(), "bridge-"+msg.Source, msg.Content)
|
|
if err != nil {
|
|
log.Printf("[bridge] orchestrator error: %v", err)
|
|
return
|
|
}
|
|
var fullContent string
|
|
for token := range tokenCh {
|
|
fullContent += token.Content
|
|
}
|
|
if fullContent != "" {
|
|
bridgeRouter.SendTo(msg.Platform, msg.Source, fullContent, bridge.SendOptions{
|
|
MaxLength: 500, MinGap: 3 * time.Second,
|
|
})
|
|
}
|
|
})
|
|
|
|
bridgeRouter.StartAll(); log.Printf("[bridge] platform bridge ready")
|
|
|
|
// === 12. Gateway ===
|
|
userStore := api.NewMemUserStore(cfg.AdminPassword)
|
|
authMgr := api.NewRealAuthManager(cfg.JWTSecret, cfg.JWTExpiryHours, userStore)
|
|
|
|
// Build dependencies for gateway handlers
|
|
gwDeps := api.Dependencies{
|
|
AuthMgr: authMgr,
|
|
Orchestrator: orch,
|
|
}
|
|
if memSvc != nil {
|
|
gwDeps.MemorySvc = memory.NewAPIAdapter(memSvc)
|
|
}
|
|
gwDeps.VoiceSvc = voiceSvc
|
|
|
|
wsHub := ws.NewHub(orch)
|
|
gwDeps.WSHub = wsHub
|
|
|
|
apiRouter := api.New(gwDeps)
|
|
|
|
wsMux := http.NewServeMux()
|
|
wsMux.HandleFunc("/ws/chat", wsHub.UpgradeHandler())
|
|
|
|
mainMux := http.NewServeMux()
|
|
mainMux.Handle("/api/", apiRouter)
|
|
mainMux.Handle("/ws/", wsMux)
|
|
|
|
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
|
srv := &http.Server{Addr: addr, Handler: mainMux}
|
|
|
|
// === 13. Start ===
|
|
go func() {
|
|
log.Printf("[gateway] listening on %s", addr)
|
|
log.Printf("[gateway] plugins=%d tools=%d", reg.Count(), len(reg.GetAllToolDefinitions()))
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
log.Fatalf("[gateway] %v", err)
|
|
}
|
|
}()
|
|
|
|
quit := make(chan os.Signal, 1)
|
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
|
<-quit
|
|
|
|
log.Println("[gateway] shutting down...")
|
|
reg.Shutdown()
|
|
srv.Close()
|
|
log.Println("Cyrene v2 已安全退出")
|
|
|
|
_ = voiceSvc
|
|
_ = memSvc
|
|
_ = orch
|
|
} |