- new emotion package: rule-based sentiment (positive/negative lexicon) drives mood transitions (开心/平静/难过) with 30min inactivity decay; affection 0-100 grows with positive interaction, with 初识→熟悉→信赖→ 羁绊 tiers - pipeline records each user message and injects a mood line into the system prompt so the persona's tone follows her mood - mood changes broadcast over WS; frontend shows a mood badge with affection tooltip; also handles the 'reminder' message type from the reminder scheduler - tests: sentiment scoring, mood shift, affection bounds, prompt line, onChange callback
563 lines
19 KiB
Go
563 lines
19 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/background"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/emotion"
|
|
"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/model"
|
|
"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/engine"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/store"
|
|
"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()))
|
|
db.RunMigration(context.Background(), database, "chat", db.SplitSQL(store.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)
|
|
|
|
// Embedding: real API when configured, n-gram FNV fallback otherwise
|
|
emb := memory.NewEmbedder(cfg.EmbeddingAPIURL, cfg.EmbeddingAPIKey, cfg.EmbeddingModel)
|
|
if err := db.RunMigration(context.Background(), database, "memories-vector", db.SplitSQL(memory.VectorMigrationSQL(emb.Dims()))); err != nil {
|
|
logger.Infof("[memory] pgvector unavailable, keyword-only search: %v", err)
|
|
} else {
|
|
memRepo.EnableVector()
|
|
memSvc.SetEmbedder(emb)
|
|
logger.Infof("[memory] hybrid search enabled (vector dims=%d, api=%v)", emb.Dims(), emb.IsAvailable())
|
|
}
|
|
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)")
|
|
|
|
// === 9.5 Chat persistence (sessions + messages) ===
|
|
var chatStore *store.ChatStore
|
|
if database != nil {
|
|
chatStore = store.NewChatStore(database)
|
|
ctxStore.SetPersister(&chatPersisterAdapter{s: chatStore})
|
|
logger.Infof("[chat] session/message persistence ready")
|
|
}
|
|
|
|
// === 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()
|
|
|
|
// Wire tracing into the dialogue pipeline
|
|
orch.SetTracer(pipelineTracer{traceLogger})
|
|
|
|
// === 12.5 Background Thinker ===
|
|
thinker := background.New(true, 5*time.Minute, 30*time.Minute)
|
|
thinker.SetLLM(&memory.LLMAdapterBridge{Adapter: llmSelector.DefaultAdapter()})
|
|
thinker.SetHistoryProvider(func(sessionID string, limit int) []model.Message {
|
|
msgs := ctxStore.Get(sessionID)
|
|
if len(msgs) > limit {
|
|
msgs = msgs[len(msgs)-limit:]
|
|
}
|
|
return msgs
|
|
})
|
|
thinker.Start(context.Background())
|
|
orch.SetPostChatHook(thinker.TriggerPostChat)
|
|
|
|
// === 12.6 Emotion tracker ===
|
|
emotionTracker := emotion.NewTracker()
|
|
orch.SetEmotionTracker(emotionTracker)
|
|
logger.Infof("[emotion] tracker ready (mood=%s)", emotionTracker.Snapshot().Mood)
|
|
|
|
// === 12.6 Memory decay scheduler ===
|
|
if memSvc != nil {
|
|
go func() {
|
|
ticker := time.NewTicker(6 * time.Hour)
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
if n, err := memSvc.ApplyDecayAll(context.Background()); err != nil {
|
|
logger.Infof("[memory] decay run failed: %v", err)
|
|
} else if n > 0 {
|
|
logger.Infof("[memory] decay applied (%d memories updated)", n)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
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)
|
|
}
|
|
if chatStore != nil {
|
|
gwDeps.ChatStore = chatStore
|
|
}
|
|
gwDeps.VoiceSvc = voiceSvc
|
|
|
|
wsHub := ws.NewHub(orch)
|
|
gwDeps.WSHub = wsHub
|
|
|
|
// Push background thoughts to connected web clients
|
|
go func() {
|
|
for th := range thinker.ThoughtChan() {
|
|
wsHub.Broadcast(map[string]any{"type": "thinking", "content": th.Content})
|
|
}
|
|
}()
|
|
|
|
// Broadcast mood changes to web clients
|
|
emotionTracker.OnChange(func(s emotion.Snapshot) {
|
|
wsHub.Broadcast(map[string]any{
|
|
"type": "mood", "mood": string(s.Mood),
|
|
"intensity": s.Intensity, "affection": s.Affection,
|
|
})
|
|
})
|
|
|
|
// === 12.7 Automation engine ===
|
|
autoEngine := engine.New(
|
|
automationSourceAdapter{gwDeps.Automation},
|
|
func(ctx context.Context, sessionID, userID, message string) {
|
|
tokenCh, _, err := orch.ProcessInput(ctx, sessionID, userID, message)
|
|
if err != nil {
|
|
logger.Infof("[automation] action failed: %v", err)
|
|
return
|
|
}
|
|
for range tokenCh {
|
|
}
|
|
},
|
|
iotQuerierAdapter{reg},
|
|
30*time.Second,
|
|
)
|
|
autoEngine.Start(context.Background())
|
|
gwDeps.AutomationEng = autoEngine
|
|
|
|
// === 12.8 Reminder scheduler ===
|
|
remSched := engine.NewReminderScheduler(
|
|
gwDeps.Reminders,
|
|
func(userID, title, body string) {
|
|
wsHub.Broadcast(map[string]any{"type": "reminder", "title": title, "content": body})
|
|
},
|
|
&memory.LLMAdapterBridge{Adapter: llmSelector.DefaultAdapter()},
|
|
30*time.Second,
|
|
)
|
|
remSched.Start(context.Background())
|
|
|
|
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
|
|
}
|
|
|
|
// automationSourceAdapter adapts api.AutomationStore to engine.RuleSource.
|
|
type automationSourceAdapter struct{ s *api.AutomationStore }
|
|
|
|
func (a automationSourceAdapter) List() []engine.Rule {
|
|
rules := a.s.List()
|
|
out := make([]engine.Rule, len(rules))
|
|
for i, r := range rules {
|
|
out[i] = engine.Rule{
|
|
ID: r.ID, Name: r.Name, Trigger: r.Trigger,
|
|
Condition: r.Condition, Action: r.Action, Enabled: r.Enabled,
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// iotQuerierAdapter adapts the plugin registry to engine.DeviceQuerier.
|
|
type iotQuerierAdapter struct{ reg *plugin.Registry }
|
|
|
|
func (a iotQuerierAdapter) ListDevices(ctx context.Context) ([]plugin.Device, error) {
|
|
iot := a.reg.GetIoTProvider()
|
|
if iot == nil {
|
|
return nil, fmt.Errorf("no iot provider")
|
|
}
|
|
return iot.ListDevices(ctx)
|
|
}
|
|
|
|
// chatPersisterAdapter adapts store.ChatStore to aiCoreCtx.Persister.
|
|
type chatPersisterAdapter struct{ s *store.ChatStore }
|
|
|
|
func (a *chatPersisterAdapter) SaveMessage(sessionID, userID string, msg model.Message) {
|
|
if userID == "" {
|
|
userID = "unknown"
|
|
}
|
|
if err := a.s.AddMessage(context.Background(), sessionID, userID, msg.Role, msg.Content); err != nil {
|
|
logger.Infof("[chat] persist message failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func (a *chatPersisterAdapter) LoadRecent(sessionID string, limit int) []model.Message {
|
|
msgs, err := a.s.ListMessages(context.Background(), sessionID, limit)
|
|
if err != nil {
|
|
logger.Infof("[chat] load history failed: %v", err)
|
|
return nil
|
|
}
|
|
out := make([]model.Message, len(msgs))
|
|
for i, m := range msgs {
|
|
out[i] = model.Message{SessionID: m.SessionID, Role: m.Role, Content: m.Content, CreatedAt: m.CreatedAt}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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":
|
|
minGapMs := 0
|
|
fmt.Sscanf(ac.Settings["send_interval_ms"], "%d", &minGapMs)
|
|
router.Register(bridgeqq.New(bridgeqq.Config{
|
|
Name: ac.Name, WSURL: ac.Settings["ws_url"],
|
|
AccessToken: ac.Settings["access_token"], BotQQ: ac.Settings["bot_qq"],
|
|
MinGapMs: minGapMs,
|
|
}))
|
|
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.SenderID, 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})
|
|
}
|
|
})
|
|
}
|