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.
402 lines
13 KiB
Go
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})
|
|
}
|
|
})
|
|
}
|