Files
Cyrene_v2/backend/services/gateway/api/router.go
T
Aska 4e2e81f684 feat(trace): waterfall visualization + persona YAML sync
- trace.Logger: Trace+Span model with 14+ hop tracking
- trace.SpanTimer: convenience API for instrumentation
- trace_adapter.go: serialize Trace+Span for frontend
- Frontend TracePage: colored waterfall bars, expand/collapse, detail popup
- persona.go: full v1 YAML parser (identity/traits/forbidden/sections)
- cyrene_persona.yaml: v1 persona config copied to v2
- Orchestrator: TraceHook + MemoryExtractor injection points
2026-08-12 18:52:39 +08:00

615 lines
17 KiB
Go

// Package api provides HTTP routing, middleware, and handlers for the Cyrene Gateway.
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
)
// --- Interfaces for injected services ---
// AuthManager validates JWT tokens and handles login/register.
type AuthManager interface {
ValidateToken(token string) (userID, username, role string, err error)
Login(username, password string) (token string, err error)
Register(username, password string) (token string, err error)
}
// MemoryEntry is a simplified memory entry for API responses.
type MemoryEntry struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Content string `json:"content"`
Summary string `json:"summary"`
Keywords []string `json:"keywords"`
Category string `json:"category"`
Priority float64 `json:"priority"`
}
// MemorySearcher handles memory search queries.
type MemorySearcher interface {
Search(ctx context.Context, userID, query string, limit int) (any, error)
List(ctx context.Context, userID string, limit int) (any, error)
Add(ctx context.Context, userID, content, category string, priority float64) (any, error)
Get(ctx context.Context, id string) (any, error)
Delete(ctx context.Context, id string) error
}
// VoiceService handles STT and TTS.
type VoiceService interface {
Recognize(ctx context.Context, audioData []byte) (string, error)
Synthesize(ctx context.Context, text string, voice string) ([]byte, error)
}
// OrchestratorPipeline handles the main dialogue flow.
type OrchestratorPipeline interface {
ProcessInput(ctx context.Context, sessionID, userMessage string) (<-chan model.StreamToken, *model.Intent, error)
}
// WebSocketHub manages WS connections.
type WebSocketHub interface {
UpgradeHandler() http.HandlerFunc
Count() int
}
// LLMCallLogger provides LLM call history.
type LLMCallLogger interface {
Recent(n int) []map[string]any
Stats() map[string]any
}
// TraceLogger provides full-chain trace events.
type TraceLogger interface {
Recent(n int) []map[string]any
Clear() // Remove old traces
}
// PerfMonitor provides performance snapshot history.
type PerfMonitor interface {
Latest() map[string]any
History() []map[string]any
}
// BridgeConfigManager manages platform adapter configurations.
type BridgeConfigManager interface {
GetAdapters() []map[string]any
SaveAdapter(name, adapterType string, enabled bool, settings map[string]string) error
DeleteAdapter(name string) error
}
// Dependencies holds all injectable services.
type Dependencies struct {
AuthMgr AuthManager
MemorySvc MemorySearcher
VoiceSvc VoiceService
Orchestrator OrchestratorPipeline
WSHub WebSocketHub
LLMLogger LLMCallLogger
TraceLog TraceLogger
PerfMon PerfMonitor
BridgeCfg BridgeConfigManager
ModelConfig *ModelConfigStore
PluginList []map[string]string
}
// Router handles HTTP routing with middleware support.
type Router struct {
mux *http.ServeMux
deps Dependencies
}
// New creates a new Router with all routes configured.
func New(deps Dependencies) *Router {
r := &Router{mux: http.NewServeMux(), deps: deps}
r.setupRoutes()
return r
}
func (r *Router) setupRoutes() {
// Public
r.mux.HandleFunc("/api/v1/health", r.healthHandler)
r.mux.HandleFunc("/api/v1/auth/login", r.loginHandler)
r.mux.HandleFunc("/api/v1/auth/register", r.registerHandler)
// Protected
r.mux.HandleFunc("/api/v1/sessions", r.authMW(r.sessionsHandler))
r.mux.HandleFunc("/api/v1/sessions/", r.authMW(r.sessionByIDHandler))
// Memory
r.mux.HandleFunc("/api/v1/memory/search", r.authMW(r.memorySearchHandler))
r.mux.HandleFunc("/api/v1/memory", r.authMW(r.memoryHandler))
r.mux.HandleFunc("/api/v1/memory/", r.authMW(r.memoryHandler))
// Voice
r.mux.HandleFunc("/api/v1/voice/stt", r.authMW(r.voiceSTTHandler))
r.mux.HandleFunc("/api/v1/voice/tts", r.authMW(r.voiceTTSHandler))
// Admin
r.mux.HandleFunc("/api/v1/admin/dashboard", r.authMW(r.adminMW(r.adminDashboardHandler)))
r.mux.HandleFunc("/api/v1/admin/llm-calls", r.authMW(r.adminMW(r.llmCallsHandler)))
r.mux.HandleFunc("/api/v1/admin/system", r.authMW(r.adminMW(r.systemInfoHandler)))
r.mux.HandleFunc("/api/v1/admin/plugins", r.authMW(r.adminMW(r.pluginListHandler)))
r.mux.HandleFunc("/api/v1/admin/sessions", r.authMW(r.adminMW(r.adminSessionsHandler)))
r.mux.HandleFunc("/api/v1/admin/models/config", r.authMW(r.adminMW(r.modelConfigHandler)))
r.mux.HandleFunc("/api/v1/admin/models/config/", r.authMW(r.adminMW(r.modelConfigItemHandler)))
r.mux.HandleFunc("/api/v1/admin/trace", r.authMW(r.adminMW(r.traceHandler)))
r.mux.HandleFunc("/api/v1/admin/perf", r.authMW(r.adminMW(r.perfHandler)))
r.mux.HandleFunc("/api/v1/admin/bridge/config", r.authMW(r.adminMW(r.bridgeConfigHandler)))
r.mux.HandleFunc("/api/v1/admin/bridge/config/", r.authMW(r.adminMW(r.bridgeConfigItemHandler)))
}
// ServeHTTP implements http.Handler.
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
if req.Method == "OPTIONS" {
w.WriteHeader(http.StatusNoContent)
return
}
r.mux.ServeHTTP(w, req)
}
// --- Middleware ---
func (r *Router) authMW(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
h := req.Header.Get("Authorization")
if h == "" || !strings.HasPrefix(strings.ToLower(h), "bearer ") {
writeJSON(w, 401, M{"error": "unauthorized"})
return
}
uid, uname, role, err := r.deps.AuthMgr.ValidateToken(h[7:])
if err != nil {
writeJSON(w, 401, M{"error": "invalid token"})
return
}
req.Header.Set("X-User-ID", uid)
req.Header.Set("X-Username", uname)
req.Header.Set("X-Role", role)
next(w, req)
}
}
func (r *Router) adminMW(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
if req.Header.Get("X-Role") != "admin" {
writeJSON(w, 403, M{"error": "admin only"})
return
}
next(w, req)
}
}
// --- Handlers ---
func (r *Router) healthHandler(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, 200, M{"status": "ok", "service": "cyrene-gateway", "version": "2.0.0-dev"})
}
func (r *Router) loginHandler(w http.ResponseWriter, req *http.Request) {
if req.Method != "POST" {
writeJSON(w, 405, M{"error": "POST required"})
return
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
json.NewDecoder(req.Body).Decode(&body)
token, err := r.deps.AuthMgr.Login(body.Username, body.Password)
if err != nil {
writeJSON(w, 401, M{"error": "invalid credentials"})
return
}
writeJSON(w, 200, M{"token": token})
}
func (r *Router) registerHandler(w http.ResponseWriter, req *http.Request) {
if req.Method != "POST" {
writeJSON(w, 405, M{"error": "POST required"})
return
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
json.NewDecoder(req.Body).Decode(&body)
token, err := r.deps.AuthMgr.Register(body.Username, body.Password)
if err != nil {
writeJSON(w, 400, M{"error": err.Error()})
return
}
writeJSON(w, 201, M{"token": token})
}
// --- Sessions ---
func (r *Router) sessionsHandler(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case "GET":
writeJSON(w, 200, M{"sessions": []any{}})
case "POST":
writeJSON(w, 201, M{"session_id": randID()})
default:
writeJSON(w, 405, M{"error": "method not allowed"})
}
}
func (r *Router) sessionByIDHandler(w http.ResponseWriter, req *http.Request) {
id := strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/")
switch req.Method {
case "DELETE":
writeJSON(w, 204, nil)
case "GET":
writeJSON(w, 200, M{"session_id": id})
default:
writeJSON(w, 405, M{"error": "method not allowed"})
}
}
// --- Memory ---
func (r *Router) memorySearchHandler(w http.ResponseWriter, req *http.Request) {
if req.Method != "GET" {
writeJSON(w, 405, M{"error": "GET required"})
return
}
if r.deps.MemorySvc == nil {
writeJSON(w, 503, M{"error": "memory service unavailable"})
return
}
q := req.URL.Query().Get("q")
uid := req.Header.Get("X-User-ID")
result, err := r.deps.MemorySvc.Search(req.Context(), uid, q, 10)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 200, M{"data": result})
}
func (r *Router) memoryHandler(w http.ResponseWriter, req *http.Request) {
if r.deps.MemorySvc == nil {
writeJSON(w, 503, M{"error": "memory service unavailable"})
return
}
// Extract ID from path: /api/v1/memory/{id}
path := strings.TrimPrefix(req.URL.Path, "/api/v1/memory")
path = strings.TrimPrefix(path, "/")
userID := req.Header.Get("X-User-ID")
switch {
case path == "" && req.Method == "GET":
result, _ := r.deps.MemorySvc.List(req.Context(), userID, 20)
writeJSON(w, 200, M{"data": result})
case path == "" && req.Method == "POST":
var body struct {
Content string `json:"content"`
Category string `json:"category"`
Priority float64 `json:"priority"`
}
json.NewDecoder(req.Body).Decode(&body)
if body.Category == "" { body.Category = "fact" }
mem, err := r.deps.MemorySvc.Add(req.Context(), userID, body.Content, body.Category, body.Priority)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 201, M{"data": mem})
case path != "" && req.Method == "DELETE":
r.deps.MemorySvc.Delete(req.Context(), path)
writeJSON(w, 204, nil)
case path != "" && req.Method == "GET":
mem, err := r.deps.MemorySvc.Get(req.Context(), path)
if err != nil {
writeJSON(w, 404, M{"error": "memory not found"})
return
}
writeJSON(w, 200, M{"data": mem})
default:
writeJSON(w, 405, M{"error": "method not allowed"})
}
}
// --- Voice ---
func (r *Router) voiceSTTHandler(w http.ResponseWriter, req *http.Request) {
if req.Method != "POST" {
writeJSON(w, 405, M{"error": "POST required"})
return
}
if r.deps.VoiceSvc == nil {
writeJSON(w, 503, M{"error": "voice service unavailable"})
return
}
var body struct{ AudioData []byte `json:"audio_data"` }
json.NewDecoder(req.Body).Decode(&body)
text, err := r.deps.VoiceSvc.Recognize(req.Context(), body.AudioData)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 200, M{"text": text})
}
func (r *Router) voiceTTSHandler(w http.ResponseWriter, req *http.Request) {
if req.Method != "POST" {
writeJSON(w, 405, M{"error": "POST required"})
return
}
if r.deps.VoiceSvc == nil {
writeJSON(w, 503, M{"error": "voice service unavailable"})
return
}
var body struct {
Text string `json:"text"`
Voice string `json:"voice"`
}
json.NewDecoder(req.Body).Decode(&body)
if body.Voice == "" { body.Voice = "zh-CN-XiaoxiaoNeural" }
audio, err := r.deps.VoiceSvc.Synthesize(req.Context(), body.Text, body.Voice)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 200, M{"audio_size": len(audio)})
}
// --- Admin ---
var startTime = time.Now()
func (r *Router) adminDashboardHandler(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, 200, M{
"dashboard": M{
"status": "running",
"version": "2.0.0-dev",
"uptime_sec": int(time.Since(startTime).Seconds()),
"services": M{
"memory": r.deps.MemorySvc != nil,
"voice": r.deps.VoiceSvc != nil,
"ai_core": r.deps.Orchestrator != nil,
},
},
})
}
// --- Admin extra handlers ---
func (r *Router) llmCallsHandler(w http.ResponseWriter, req *http.Request) {
if r.deps.LLMLogger == nil {
writeJSON(w, 200, M{"calls": []any{}, "stats": M{}})
return
}
writeJSON(w, 200, M{
"calls": r.deps.LLMLogger.Recent(50),
"stats": r.deps.LLMLogger.Stats(),
})
}
func (r *Router) systemInfoHandler(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, 200, M{
"system": M{
"version": "2.0.0-dev",
"go_version": "1.26",
"uptime_sec": int(time.Since(startTime).Seconds()),
"ws_clients": r.deps.WSHub.Count(),
},
})
}
func (r *Router) pluginListHandler(w http.ResponseWriter, _ *http.Request) {
plugins := r.deps.PluginList
if plugins == nil { plugins = []map[string]string{} }
writeJSON(w, 200, M{"plugins": plugins})
}
func (r *Router) adminSessionsHandler(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, 200, M{"sessions": []any{}, "total": 0})
}
// --- Model Config ---
func (r *Router) modelConfigHandler(w http.ResponseWriter, req *http.Request) {
if r.deps.ModelConfig == nil {
writeJSON(w, 503, M{"error": "model config unavailable"})
return
}
switch req.Method {
case "GET":
writeJSON(w, 200, M{
"providers": r.deps.ModelConfig.GetProviders(),
"models": r.deps.ModelConfig.GetModels(),
"routing": r.deps.ModelConfig.GetRouting(),
})
default:
writeJSON(w, 405, M{"error": "method not allowed"})
}
}
func (r *Router) modelConfigItemHandler(w http.ResponseWriter, req *http.Request) {
if r.deps.ModelConfig == nil {
writeJSON(w, 503, M{"error": "model config unavailable"})
return
}
// Path: /api/v1/admin/models/config/{type}/{name}
path := strings.TrimPrefix(req.URL.Path, "/api/v1/admin/models/config/")
parts := strings.SplitN(path, "/", 2)
if len(parts) < 2 {
writeJSON(w, 400, M{"error": "invalid path"})
return
}
kind, name := parts[0], parts[1]
switch req.Method {
case "POST", "PUT":
var body map[string]any
json.NewDecoder(req.Body).Decode(&body)
switch kind {
case "providers":
r.deps.ModelConfig.SaveProvider(&ModelProvider{
Name: name, BaseURL: str(body, "base_url"),
APIKey: str(body, "api_key"), Timeout: num(body, "timeout_sec"),
Retries: num(body, "max_retries"),
})
case "models":
var tags []string
if t, ok := body["tags"].([]any); ok {
for _, v := range t { tags = append(tags, fmt.Sprint(v)) }
}
r.deps.ModelConfig.SaveModel(&ModelEntry{
ID: name, Name: str(body, "name"), Provider: str(body, "provider"),
Description: str(body, "description"), Tags: tags,
Params: body["params"].(map[string]any), Enabled: true,
})
case "routing":
var chain []string
if c, ok := body["fallback_chain"].([]any); ok {
for _, v := range c { chain = append(chain, fmt.Sprint(v)) }
}
r.deps.ModelConfig.SaveRouting(&RoutingRule{
Purpose: name, FallbackChain: chain, Required: body["required"].(bool),
})
}
writeJSON(w, 200, M{"ok": true})
case "DELETE":
switch kind {
case "providers": r.deps.ModelConfig.DeleteProvider(name)
case "models": r.deps.ModelConfig.DeleteModel(name)
case "routing": r.deps.ModelConfig.DeleteRouting(name)
}
writeJSON(w, 204, nil)
default:
writeJSON(w, 405, M{"error": "method not allowed"})
}
}
func str(m map[string]any, key string) string {
if v, ok := m[key].(string); ok { return v }
return ""
}
func num(m map[string]any, key string) int {
if v, ok := m[key].(float64); ok { return int(v) }
return 0
}
// --- Trace & Perf ---
func (r *Router) traceHandler(w http.ResponseWriter, req *http.Request) {
if r.deps.TraceLog == nil {
writeJSON(w, 200, M{"traces": []any{}})
return
}
if req.Method == "DELETE" {
r.deps.TraceLog.Clear()
writeJSON(w, 200, M{"ok": true})
return
}
writeJSON(w, 200, M{"traces": r.deps.TraceLog.Recent(50)})
}
func (r *Router) perfHandler(w http.ResponseWriter, req *http.Request) {
if r.deps.PerfMon == nil {
writeJSON(w, 200, M{"latest": M{}, "history": []any{}})
return
}
writeJSON(w, 200, M{
"latest": r.deps.PerfMon.Latest(),
"history": r.deps.PerfMon.History(),
})
}
// --- Bridge Config ---
func (r *Router) bridgeConfigHandler(w http.ResponseWriter, req *http.Request) {
if r.deps.BridgeCfg == nil {
writeJSON(w, 503, M{"error": "bridge config unavailable"})
return
}
switch req.Method {
case "GET":
writeJSON(w, 200, M{"adapters": r.deps.BridgeCfg.GetAdapters()})
default:
writeJSON(w, 405, M{"error": "method not allowed"})
}
}
func (r *Router) bridgeConfigItemHandler(w http.ResponseWriter, req *http.Request) {
if r.deps.BridgeCfg == nil {
writeJSON(w, 503, M{"error": "bridge config unavailable"})
return
}
name := strings.TrimPrefix(req.URL.Path, "/api/v1/admin/bridge/config/")
if name == "" {
writeJSON(w, 400, M{"error": "missing adapter name"})
return
}
switch req.Method {
case "PUT", "POST":
var body struct {
Type string `json:"type"`
Enabled bool `json:"enabled"`
Settings map[string]string `json:"settings"`
}
json.NewDecoder(req.Body).Decode(&body)
if err := r.deps.BridgeCfg.SaveAdapter(name, body.Type, body.Enabled, body.Settings); err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 200, M{"ok": true})
case "DELETE":
if err := r.deps.BridgeCfg.DeleteAdapter(name); err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 204, nil)
default:
writeJSON(w, 405, M{"error": "method not allowed"})
}
}
// --- Helpers ---
type M = map[string]any
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if data != nil {
json.NewEncoder(w).Encode(data)
}
}
func randID() string {
b := make([]byte, 8)
rand.Read(b)
return hex.EncodeToString(b)
}
func (r *Router) userID(req *http.Request) string { return req.Header.Get("X-User-ID") }
// Ensure unused imports don't cause issues