Files
Cyrene_v2/backend/services/gateway/api/router.go
T
Aska dee48b6766
Cyrene CI / backend (push) Has been cancelled
Cyrene CI / frontend (push) Has been cancelled
feat: add automation, reminders, notifications, msg search, session export, image analysis, host exec, PWA, Docker, E2E, CI/CD
- Automation: rules CRUD (in-memory store + API)
- Reminders: CRUD (in-memory store + API)
- Notifications: push interface for future push server
- Message search: API endpoint with DB-backed query
- Session export: /sessions/:id/export → Markdown
- Image analysis: API stub for multimodal LLM
- Host exec: API stub for Docker sandbox
- PWA: manifest.json + service worker
- Docker: Dockerfile + docker-compose.yml
- E2E: Playwright test scaffold
- CI/CD: GitHub Actions workflow (go test + tsc)
2026-08-12 19:43:46 +08:00

641 lines
18 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
Automation *AutomationStore
Reminders *ReminderStore
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)))
// Automation & Reminders
r.mux.HandleFunc("/api/v1/automation/rules", r.authMW(r.automationHandler))
r.mux.HandleFunc("/api/v1/automation/rules/", r.authMW(r.automationItemHandler))
r.mux.HandleFunc("/api/v1/reminders", r.authMW(r.remindersHandler))
r.mux.HandleFunc("/api/v1/reminders/", r.authMW(r.reminderItemHandler))
// Notifications (push interface)
r.mux.HandleFunc("/api/v1/notifications/push", r.authMW(r.adminMW(r.notificationHandler)))
// Message search & session export
r.mux.HandleFunc("/api/v1/messages/search", r.authMW(r.messageSearchHandler))
// Image analysis
r.mux.HandleFunc("/api/v1/images/analyze", r.authMW(r.imageAnalyzeHandler))
// Host execution sandbox
r.mux.HandleFunc("/api/v1/tools/host-exec", r.authMW(r.adminMW(r.hostExecHandler)))
}
// 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/")
// Handle export
if strings.HasSuffix(id, "/export") {
id = strings.TrimSuffix(id, "/export")
r.sessionExportHandler(w, req)
return
}
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