Files
Cyrene_v2/backend/cmd/cyrene/main.go
T
Aska cc040b861d feat(ai-core): trigger memory extraction after each conversation round
The WS hub and platform bridge now call Pipeline.FinishRound when the
response stream completes, recording the assistant reply and invoking
the LLM memory extractor asynchronously.
2026-08-20 19:40:58 +08:00

402 lines
13 KiB
Go

// Cyrene v2 — LLM-powered AI companion platform.
// Single binary entry point that wires all services together.
package main
import (
"context"
"fmt"
"log"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"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"
llmlog "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/llm"
tracepkg "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/trace"
perfpkg "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/perf"
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)
logger.Info("🧠 Cyrene v2 启动中...")
// === 1. Platform & Config ===
plat := platform.Detect()
logger.Infof("[platform] %s", plat.Summary())
cfg, err := config.Load()
if err != nil {
logger.Infof("[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 {
logger.Infof("[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()
logger.Infof("[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)
}
logger.Infof("[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,
})
logger.Infof("[voice] ready")
// === 6. Memory Service ===
var memSvc *memory.Service
if database != nil {
memRepo := memory.NewRepository(database)
memSvc = memory.NewService(memRepo)
logger.Infof("[memory] connected")
} else {
logger.Infof("[memory] disabled (no database)")
}
// === 7. AI-Core: Persona ===
personaLoader, err := persona.NewLoader(cfg.PersonaDir)
if err != nil {
logger.Infof("[persona] load error: %v (using builtin)", err)
}
logger.Infof("[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)
// Wire LLM call logging to admin panel
llmCallLogger := llm.NewCallLogger(200)
llmSelector.DefaultAdapter().SetCallLogger(func(c llm.CallRecord) {
llmCallLogger.Record(c)
})
logger.Infof("[llm] adapter ready (model=%s)", cfg.LLMModel)
// === 9. AI-Core: Context Store ===
ctxStore := aiCoreCtx.NewStore(4000, 50)
logger.Infof("[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
},
},
)
logger.Infof("[subsession] 4 providers ready")
// === 11. AI-Core: Orchestrator ===
semanticCache := llm.NewSemanticCache()
orch := orchestrator.NewPipeline(llmSelector, semanticCache, ctxStore, subMgr, personaInjector)
if memSvc != nil {
memAdapter := llmSelector.DefaultAdapter()
memExtractor := memory.NewExtractor(memSvc, &memory.LLMAdapterBridge{Adapter: memAdapter})
orch.SetMemoryExtractor(memExtractor)
}
orch.SetToolExecutor(&toolExecAdapter{reg: reg})
logger.Infof("[orchestrator] pipeline ready")
// === 11.5 Platform Bridge ===
bridgeCfgPath := "configs/bridge.json"
bridgeRouter := bridge.NewRouter()
bridgeStore := bridge.NewConfigStore(bridgeCfgPath, func(cfg *bridge.BridgeConfig) {
bridgeRouter.StopAll()
bridgeRouter = bridge.NewRouter()
loadAdaptersFromConfig(bridgeRouter, cfg, orch)
bridgeRouter.StartAll()
})
bridgeStore.StartWatching(5 * time.Second)
// Load initial adapters
loadAdaptersFromConfig(bridgeRouter, &bridge.BridgeConfig{Adapters: bridgeStore.GetAdapters()}, orch)
bridgeRouter.StartAll()
logger.Infof("[bridge] config store ready (path=%s, %d adapters)", bridgeCfgPath, bridgeStore.Count())
// === 12. Gateway ===
userStore := api.NewMemUserStore(cfg.AdminUsername, cfg.AdminPassword)
authMgr := api.NewRealAuthManager(cfg.JWTSecret, cfg.JWTExpiryHours, userStore)
// Build dependencies for gateway handlers
llmLogger := &llmLoggerAdapter{CallLogger: llmCallLogger}
traceLogger := tracepkg.NewLogger(500)
perfMon := perfpkg.NewMonitor(120, 10*time.Second)
perfMon.Start()
gwDeps := api.Dependencies{
AuthMgr: authMgr,
Orchestrator: orch,
LLMLogger: llmLogger,
TraceLog: &traceAdapter{Logger: traceLogger},
PerfMon: &perfAdapter{Monitor: perfMon},
ModelConfig: api.NewModelConfigStore(),
Automation: api.NewAutomationStore(),
Reminders: api.NewReminderStore(),
BridgeCfg: &bridgeConfigAdapter{ConfigStore: bridgeStore},
PluginList: pluginNamesToMap(reg.ListNames()),
}
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 ===
// Register restart handler — forks a new process and exits
api.RestartFunc = func() {
logger.Infof("[gateway] restarting...")
reg.Shutdown()
srv.Close()
// Re-exec self with same args and env
exe, _ := os.Executable()
proc, err := os.StartProcess(exe, os.Args, &os.ProcAttr{
Env: os.Environ(),
Files: []*os.File{os.Stdin, os.Stdout, os.Stderr},
})
if err != nil {
logger.Fatalf("[gateway] restart failed: %v", err)
}
logger.Infof("[gateway] new process PID=%d, exiting...", proc.Pid)
os.Exit(0)
}
go func() {
logger.Infof("[gateway] listening on %s", addr)
logger.Infof("[gateway] plugins=%d tools=%d", reg.Count(), len(reg.GetAllToolDefinitions()))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatalf("[gateway] %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logger.Info("[gateway] shutting down...")
reg.Shutdown()
srv.Close()
logger.Info("Cyrene v2 已安全退出")
_ = voiceSvc
_ = memSvc
_ = orch
}
// llmLoggerAdapter adapts llm.Logger to api.LLMCallLogger interface.
type llmLoggerAdapter struct{ *llmlog.CallLogger }
func (a *llmLoggerAdapter) Recent(n int) []map[string]any { return a.CallLogger.RecentMaps(n) }
func pluginNamesToMap(names []string) []map[string]string {
result := make([]map[string]string, len(names))
for i, n := range names {
result[i] = map[string]string{"name": n, "enabled": "true"}
}
return result
}
// perfAdapter adapts perf.Monitor to api.PerfMonitor interface.
type perfAdapter struct{ *perfpkg.Monitor }
func (a *perfAdapter) Latest() map[string]any {
s := a.Monitor.Latest()
return map[string]any{
"alloc_mb": s.AllocMB, "sys_mb": s.SysMB,
"num_gc": s.NumGC, "goroutines": s.Goroutines, "heap_objects": s.HeapObj,
}
}
func (a *perfAdapter) History() []map[string]any {
snapshots := a.Monitor.History()
result := make([]map[string]any, len(snapshots))
for i, s := range snapshots {
result[i] = map[string]any{
"timestamp": s.Timestamp.Format(time.RFC3339),
"alloc_mb": s.AllocMB, "sys_mb": s.SysMB,
"num_gc": s.NumGC, "goroutines": s.Goroutines,
}
}
return result
}
// toolExecAdapter adapts the plugin registry to orchestrator.ToolExecutor.
type toolExecAdapter struct{ reg *plugin.Registry }
func (t *toolExecAdapter) Tools() []llm.ToolSpec {
defs := t.reg.GetAllToolDefinitions()
specs := make([]llm.ToolSpec, len(defs))
for i, d := range defs {
specs[i] = llm.ToolSpec{Name: d.Name, Description: d.Description, Parameters: d.Parameters}
}
return specs
}
func (t *toolExecAdapter) Execute(ctx context.Context, name string, params map[string]any) (string, error) {
return t.reg.ExecuteTool(ctx, name, params)
}
// bridgeConfigAdapter adapts bridge.ConfigStore to api.BridgeConfigManager.
type bridgeConfigAdapter struct{ *bridge.ConfigStore }
func (a *bridgeConfigAdapter) GetAdapters() []map[string]any {
adapters := a.ConfigStore.GetAdapters()
result := make([]map[string]any, len(adapters))
for i, ac := range adapters {
result[i] = map[string]any{
"name": ac.Name, "type": ac.Type, "enabled": ac.Enabled,
"settings": ac.Settings, "created_at": ac.CreatedAt, "updated_at": ac.UpdatedAt,
}
}
return result
}
func (a *bridgeConfigAdapter) SaveAdapter(name, adapterType string, enabled bool, settings map[string]string) error {
return a.ConfigStore.SaveAdapter(bridge.AdapterConfig{
Name: name, Type: adapterType, Enabled: enabled, Settings: settings,
})
}
func (a *bridgeConfigAdapter) DeleteAdapter(name string) error {
return a.ConfigStore.DeleteAdapter(name)
}
func loadAdaptersFromConfig(router *bridge.Router, cfg *bridge.BridgeConfig, orch *orchestrator.Pipeline) {
for _, ac := range cfg.Adapters {
if !ac.Enabled { continue }
switch ac.Type {
case "qq-obv11":
router.Register(bridgeqq.New(bridgeqq.Config{
Name: ac.Name, WSURL: ac.Settings["ws_url"],
AccessToken: ac.Settings["access_token"], BotQQ: ac.Settings["bot_qq"],
}))
case "telegram":
router.Register(bridgetg.New(bridgetg.Config{Name: ac.Name, Token: ac.Settings["token"]}))
case "discord":
router.Register(bridgedc.New(bridgedc.Config{Name: ac.Name, Token: ac.Settings["token"]}))
case "webhook":
router.Register(bridgewh.New(bridgewh.Config{Name: ac.Name, ListenAddr: ac.Settings["listen_addr"], APIKey: ac.Settings["api_key"]}))
}
}
router.SetMessageHandler(func(msg *bridge.UnifiedMessage) {
sessionID := "bridge-" + msg.Source
tokenCh, _, err := orch.ProcessInput(context.Background(), sessionID, msg.Content)
if err != nil { return }
var fc string
for t := range tokenCh { fc += t.Content }
orch.FinishRound(sessionID, msg.SenderID, fc)
if fc != "" {
router.SendTo(msg.Platform, msg.Source, fc, bridge.SendOptions{MaxLength: 500, MinGap: 3 * time.Second})
}
})
}