Files
Cyrene_v2/backend/services/gateway/api/router.go
T
Aska 1a80ac2a00 feat(admin): add LLM call logs, system info, plugin list pages
- ai-core/llm: ring-buffer call logger with stats
- Gateway: /api/v1/admin/llm-calls, /system, /plugins, /sessions endpoints
- Frontend: LLMCallsPage (table + stats), SystemPage (version/plugins/sessions)
- Admin nav expanded to 7 items
2026-08-12 16:56:52 +08:00

420 lines
12 KiB
Go

// Package api provides HTTP routing, middleware, and handlers for the Cyrene Gateway.
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"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
}
// Dependencies holds all injectable services.
type Dependencies struct {
AuthMgr AuthManager
MemorySvc MemorySearcher
VoiceSvc VoiceService
Orchestrator OrchestratorPipeline
WSHub WebSocketHub
LLMLogger LLMCallLogger
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)))
}
// 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": r.deps.WSHub.Count()})
}
// --- 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