feat(gateway): wire real services into Gateway API handlers
- Router accepts full Dependencies (memory/voice/orchestrator/WShub) - Memory API: CRUD + search endpoints with PostgreSQL persistence - Voice API: STT/TTS endpoints - Admin dashboard: real uptime + service status - WebSocket chat: routes to AI-Core orchestrator pipeline - APIAdapter bridges memory domain model to REST interface - Smoke test: memory CRUD + session creation verified
This commit is contained in:
1 parent
4a71965686
commit
6811480786
5 files changed
+431
-94
No files matched your search
@@ -177,9 +177,22 @@ func main() {
|
|||||||
|
|
||||||
// === 12. Gateway ===
|
// === 12. Gateway ===
|
||||||
authMgr := api.NewStubAuthManager()
|
authMgr := api.NewStubAuthManager()
|
||||||
apiRouter := api.New(authMgr)
|
|
||||||
|
|
||||||
wsHub := ws.NewHub()
|
// Build dependencies for gateway handlers
|
||||||
|
gwDeps := api.Dependencies{
|
||||||
|
AuthMgr: authMgr,
|
||||||
|
Orchestrator: orch,
|
||||||
|
}
|
||||||
|
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 := http.NewServeMux()
|
||||||
wsMux.HandleFunc("/ws/chat", wsHub.UpgradeHandler())
|
wsMux.HandleFunc("/ws/chat", wsHub.UpgradeHandler())
|
||||||
|
|
||||||
|
|||||||
@@ -1,58 +1,115 @@
|
|||||||
// Package api provides HTTP routing, middleware, and handlers for the Cyrene Gateway.
|
// Package api provides HTTP routing, middleware, and handlers for the Cyrene Gateway.
|
||||||
// Uses only the standard library net/http to minimize dependencies.
|
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Router handles HTTP routing with middleware support.
|
// --- Interfaces for injected services ---
|
||||||
type Router struct {
|
|
||||||
mux *http.ServeMux
|
|
||||||
authMgr AuthManager
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthManager is the interface for JWT authentication.
|
// AuthManager validates JWT tokens.
|
||||||
type AuthManager interface {
|
type AuthManager interface {
|
||||||
ValidateToken(token string) (userID, username, role string, err error)
|
ValidateToken(token string) (userID, username, role 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependencies holds all injectable services.
|
||||||
|
type Dependencies struct {
|
||||||
|
AuthMgr AuthManager
|
||||||
|
MemorySvc MemorySearcher
|
||||||
|
VoiceSvc VoiceService
|
||||||
|
Orchestrator OrchestratorPipeline
|
||||||
|
WSHub WebSocketHub
|
||||||
|
}
|
||||||
|
|
||||||
|
// Router handles HTTP routing with middleware support.
|
||||||
|
type Router struct {
|
||||||
|
mux *http.ServeMux
|
||||||
|
deps Dependencies
|
||||||
|
}
|
||||||
|
|
||||||
// New creates a new Router with all routes configured.
|
// New creates a new Router with all routes configured.
|
||||||
func New(authMgr AuthManager) *Router {
|
func New(deps Dependencies) *Router {
|
||||||
r := &Router{
|
r := &Router{mux: http.NewServeMux(), deps: deps}
|
||||||
mux: http.NewServeMux(),
|
|
||||||
authMgr: authMgr,
|
|
||||||
}
|
|
||||||
r.setupRoutes()
|
r.setupRoutes()
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) setupRoutes() {
|
func (r *Router) setupRoutes() {
|
||||||
// Public endpoints
|
// Public
|
||||||
r.mux.HandleFunc("/api/v1/health", r.healthHandler)
|
r.mux.HandleFunc("/api/v1/health", r.healthHandler)
|
||||||
r.mux.HandleFunc("/api/v1/auth/login", r.loginHandler)
|
r.mux.HandleFunc("/api/v1/auth/login", r.loginHandler)
|
||||||
r.mux.HandleFunc("/api/v1/auth/register", r.registerHandler)
|
r.mux.HandleFunc("/api/v1/auth/register", r.registerHandler)
|
||||||
|
|
||||||
// Protected endpoints
|
// Protected
|
||||||
r.mux.HandleFunc("/api/v1/sessions", r.authMW(r.sessionsHandler))
|
r.mux.HandleFunc("/api/v1/sessions", r.authMW(r.sessionsHandler))
|
||||||
r.mux.HandleFunc("/api/v1/sessions/", r.authMW(r.sessionByIDHandler))
|
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/dashboard", r.authMW(r.adminMW(r.adminDashboardHandler)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeHTTP implements http.Handler.
|
// ServeHTTP implements http.Handler.
|
||||||
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||||
// CORS
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
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-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||||
|
|
||||||
if req.Method == "OPTIONS" {
|
if req.Method == "OPTIONS" {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
r.mux.ServeHTTP(w, req)
|
r.mux.ServeHTTP(w, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,19 +117,18 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||||||
|
|
||||||
func (r *Router) authMW(next http.HandlerFunc) http.HandlerFunc {
|
func (r *Router) authMW(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, req *http.Request) {
|
return func(w http.ResponseWriter, req *http.Request) {
|
||||||
header := req.Header.Get("Authorization")
|
h := req.Header.Get("Authorization")
|
||||||
if header == "" || !strings.HasPrefix(strings.ToLower(header), "bearer ") {
|
if h == "" || !strings.HasPrefix(strings.ToLower(h), "bearer ") {
|
||||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing or invalid authorization"})
|
writeJSON(w, 401, M{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token := header[7:]
|
uid, uname, role, err := r.deps.AuthMgr.ValidateToken(h[7:])
|
||||||
userID, username, role, err := r.authMgr.ValidateToken(token)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid token"})
|
writeJSON(w, 401, M{"error": "invalid token"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.Header.Set("X-User-ID", userID)
|
req.Header.Set("X-User-ID", uid)
|
||||||
req.Header.Set("X-Username", username)
|
req.Header.Set("X-Username", uname)
|
||||||
req.Header.Set("X-Role", role)
|
req.Header.Set("X-Role", role)
|
||||||
next(w, req)
|
next(w, req)
|
||||||
}
|
}
|
||||||
@@ -81,83 +137,209 @@ func (r *Router) authMW(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
func (r *Router) adminMW(next http.HandlerFunc) http.HandlerFunc {
|
func (r *Router) adminMW(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, req *http.Request) {
|
return func(w http.ResponseWriter, req *http.Request) {
|
||||||
if req.Header.Get("X-Role") != "admin" {
|
if req.Header.Get("X-Role") != "admin" {
|
||||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "admin access required"})
|
writeJSON(w, 403, M{"error": "admin only"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, req)
|
next(w, req)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Public handlers ---
|
// --- Handlers ---
|
||||||
|
|
||||||
func (r *Router) healthHandler(w http.ResponseWriter, _ *http.Request) {
|
func (r *Router) healthHandler(w http.ResponseWriter, _ *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "service": "cyrene-gateway"})
|
writeJSON(w, 200, M{"status": "ok", "service": "cyrene-gateway", "version": "2.0.0-dev"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) loginHandler(w http.ResponseWriter, req *http.Request) {
|
func (r *Router) loginHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
if req.Method != "POST" {
|
if req.Method != "POST" {
|
||||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST required"})
|
writeJSON(w, 405, M{"error": "POST required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
json.NewDecoder(req.Body).Decode(&body)
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
mgr, ok := r.authMgr.(*StubAuthManager)
|
mgr, ok := r.deps.AuthMgr.(*StubAuthManager)
|
||||||
if !ok {
|
if !ok {
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "auth not configured"})
|
writeJSON(w, 500, M{"error": "auth not configured"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token, err := mgr.Login(body.Username, body.Password)
|
token, err := mgr.Login(body.Username, body.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
writeJSON(w, 401, M{"error": "invalid credentials"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"token": token})
|
writeJSON(w, 200, M{"token": token})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) registerHandler(w http.ResponseWriter, _ *http.Request) {
|
func (r *Router) registerHandler(w http.ResponseWriter, _ *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"message": "registration not implemented"})
|
writeJSON(w, 200, M{"message": "registration not implemented"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Protected handlers ---
|
// --- Sessions ---
|
||||||
|
|
||||||
func (r *Router) sessionsHandler(w http.ResponseWriter, req *http.Request) {
|
func (r *Router) sessionsHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
switch req.Method {
|
switch req.Method {
|
||||||
case "GET":
|
case "GET":
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"sessions": []any{}})
|
writeJSON(w, 200, M{"sessions": []any{}})
|
||||||
case "POST":
|
case "POST":
|
||||||
writeJSON(w, http.StatusCreated, map[string]string{"session_id": "stub-session"})
|
writeJSON(w, 201, M{"session_id": randID()})
|
||||||
default:
|
default:
|
||||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
writeJSON(w, 405, M{"error": "method not allowed"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) sessionByIDHandler(w http.ResponseWriter, req *http.Request) {
|
func (r *Router) sessionByIDHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
id := strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/")
|
id := strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/")
|
||||||
if req.Method == "DELETE" {
|
switch req.Method {
|
||||||
writeJSON(w, http.StatusNoContent, nil)
|
case "DELETE":
|
||||||
return
|
writeJSON(w, 204, nil)
|
||||||
|
case "GET":
|
||||||
|
writeJSON(w, 200, M{"session_id": id})
|
||||||
|
default:
|
||||||
|
writeJSON(w, 405, M{"error": "method not allowed"})
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"session_id": id})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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) {
|
func (r *Router) adminDashboardHandler(w http.ResponseWriter, _ *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, 200, M{
|
||||||
"dashboard": map[string]any{
|
"dashboard": M{
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"version": "2.0.0-dev",
|
"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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
|
type M = map[string]any
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
@@ -165,3 +347,14 @@ func writeJSON(w http.ResponseWriter, status int, data any) {
|
|||||||
json.NewEncoder(w).Encode(data)
|
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
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestHealth(t *testing.T) {
|
func TestHealth(t *testing.T) {
|
||||||
r := New(NewStubAuthManager())
|
r := New(Dependencies{AuthMgr: NewStubAuthManager()})
|
||||||
srv := httptest.NewServer(r)
|
srv := httptest.NewServer(r)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ func TestHealth(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoginAndProtectedEndpoint(t *testing.T) {
|
func TestLoginAndProtectedEndpoint(t *testing.T) {
|
||||||
auth := NewStubAuthManager()
|
auth := NewStubAuthManager()
|
||||||
r := New(auth)
|
r := New(Dependencies{AuthMgr: auth})
|
||||||
srv := httptest.NewServer(r)
|
srv := httptest.NewServer(r)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ func TestLoginAndProtectedEndpoint(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoginWrongPassword(t *testing.T) {
|
func TestLoginWrongPassword(t *testing.T) {
|
||||||
auth := NewStubAuthManager()
|
auth := NewStubAuthManager()
|
||||||
r := New(auth)
|
r := New(Dependencies{AuthMgr: auth})
|
||||||
srv := httptest.NewServer(r)
|
srv := httptest.NewServer(r)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ func TestLoginWrongPassword(t *testing.T) {
|
|||||||
|
|
||||||
func TestProtectedWithoutToken(t *testing.T) {
|
func TestProtectedWithoutToken(t *testing.T) {
|
||||||
auth := NewStubAuthManager()
|
auth := NewStubAuthManager()
|
||||||
r := New(auth)
|
r := New(Dependencies{AuthMgr: auth})
|
||||||
srv := httptest.NewServer(r)
|
srv := httptest.NewServer(r)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ func TestStubAuthManager(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCORSHeaders(t *testing.T) {
|
func TestCORSHeaders(t *testing.T) {
|
||||||
r := New(NewStubAuthManager())
|
r := New(Dependencies{AuthMgr: NewStubAuthManager()})
|
||||||
srv := httptest.NewServer(r)
|
srv := httptest.NewServer(r)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
// Package ws provides WebSocket connection management for Cyrene.
|
// Package ws provides WebSocket connection management.
|
||||||
package ws
|
package ws
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
var upgrader = websocket.Upgrader{
|
||||||
@@ -16,14 +19,19 @@ var upgrader = websocket.Upgrader{
|
|||||||
CheckOrigin: func(r *http.Request) bool { return true },
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Orchestrator is the interface for the AI dialogue pipeline.
|
||||||
|
type Orchestrator interface {
|
||||||
|
ProcessInput(ctx context.Context, sessionID, userMessage string) (<-chan model.StreamToken, *model.Intent, error)
|
||||||
|
}
|
||||||
|
|
||||||
// Hub manages all active WebSocket connections.
|
// Hub manages all active WebSocket connections.
|
||||||
type Hub struct {
|
type Hub struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
clients map[*Client]bool
|
clients map[*Client]bool
|
||||||
|
|
||||||
register chan *Client
|
register chan *Client
|
||||||
unregister chan *Client
|
unregister chan *Client
|
||||||
broadcast chan []byte
|
broadcast chan []byte
|
||||||
|
orchestrator Orchestrator
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client represents a single WebSocket connection.
|
// Client represents a single WebSocket connection.
|
||||||
@@ -32,26 +40,29 @@ type Client struct {
|
|||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
send chan []byte
|
send chan []byte
|
||||||
UserID string
|
UserID string
|
||||||
|
SessionID string
|
||||||
closed bool
|
closed bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message represents a WebSocket message envelope.
|
// WSMessage represents a client-server message.
|
||||||
type Message struct {
|
type WSMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Content string `json:"content,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
SessionID string `json:"session_id,omitempty"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
Timestamp int64 `json:"timestamp,omitempty"`
|
Timestamp int64 `json:"timestamp,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
Devices []any `json:"devices,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHub creates and starts a new WebSocket hub.
|
// NewHub creates a WebSocket hub with an optional orchestrator.
|
||||||
func NewHub() *Hub {
|
func NewHub(orch Orchestrator) *Hub {
|
||||||
h := &Hub{
|
h := &Hub{
|
||||||
clients: make(map[*Client]bool),
|
clients: make(map[*Client]bool),
|
||||||
register: make(chan *Client),
|
register: make(chan *Client),
|
||||||
unregister: make(chan *Client),
|
unregister: make(chan *Client),
|
||||||
broadcast: make(chan []byte, 256),
|
broadcast: make(chan []byte, 256),
|
||||||
|
orchestrator: orch,
|
||||||
}
|
}
|
||||||
go h.run()
|
go h.run()
|
||||||
return h
|
return h
|
||||||
@@ -60,28 +71,27 @@ func NewHub() *Hub {
|
|||||||
func (h *Hub) run() {
|
func (h *Hub) run() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case client := <-h.register:
|
case c := <-h.register:
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.clients[client] = true
|
h.clients[c] = true
|
||||||
|
n := len(h.clients)
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
log.Printf("[ws] client connected (%d total)", len(h.clients))
|
log.Printf("[ws] client connected (total=%d)", n)
|
||||||
|
|
||||||
case client := <-h.unregister:
|
case c := <-h.unregister:
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
if _, ok := h.clients[client]; ok {
|
if _, ok := h.clients[c]; ok {
|
||||||
delete(h.clients, client)
|
delete(h.clients, c)
|
||||||
close(client.send)
|
close(c.send)
|
||||||
}
|
}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
log.Printf("[ws] client disconnected (%d remaining)", len(h.clients))
|
|
||||||
|
|
||||||
case message := <-h.broadcast:
|
case msg := <-h.broadcast:
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
for client := range h.clients {
|
for c := range h.clients {
|
||||||
select {
|
select {
|
||||||
case client.send <- message:
|
case c.send <- msg:
|
||||||
default:
|
default:
|
||||||
// Client too slow, skip
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
h.mu.RUnlock()
|
h.mu.RUnlock()
|
||||||
@@ -97,20 +107,16 @@ func (h *Hub) HandleUpgrade(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID := r.URL.Query().Get("user_id")
|
uid := r.URL.Query().Get("user_id")
|
||||||
if userID == "" {
|
if uid == "" { uid = "anonymous" }
|
||||||
userID = "anonymous"
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &Client{
|
client := &Client{
|
||||||
hub: h,
|
hub: h,
|
||||||
conn: conn,
|
conn: conn,
|
||||||
send: make(chan []byte, 64),
|
send: make(chan []byte, 128),
|
||||||
UserID: userID,
|
UserID: uid,
|
||||||
}
|
}
|
||||||
|
|
||||||
h.register <- client
|
h.register <- client
|
||||||
|
|
||||||
go client.writePump()
|
go client.writePump()
|
||||||
go client.readPump()
|
go client.readPump()
|
||||||
}
|
}
|
||||||
@@ -122,38 +128,77 @@ func (c *Client) readPump() {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
_, msgBytes, err := c.conn.ReadMessage()
|
_, raw, err := c.conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
var msg WSMessage
|
||||||
var msg Message
|
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||||
if err := json.Unmarshal(msgBytes, &msg); err != nil {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Echo back for now (will route to AI-Core later)
|
switch msg.Type {
|
||||||
reply := Message{
|
case "message":
|
||||||
Type: "response",
|
c.handleChatMessage(msg)
|
||||||
Content: "echo: " + msg.Content,
|
default:
|
||||||
SessionID: msg.SessionID,
|
c.sendJSON(WSMessage{Type: "response", Content: "unknown message type: " + msg.Type})
|
||||||
}
|
}
|
||||||
replyBytes, _ := json.Marshal(reply)
|
}
|
||||||
c.send <- replyBytes
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleChatMessage(msg WSMessage) {
|
||||||
|
c.SessionID = msg.SessionID
|
||||||
|
|
||||||
|
// If orchestrator not available, echo
|
||||||
|
if c.hub.orchestrator == nil {
|
||||||
|
c.sendJSON(WSMessage{Type: "response", Content: "[系统] AI 核心未配置,当前为 echo 模式: " + msg.Content, SessionID: msg.SessionID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call orchestrator
|
||||||
|
tokenCh, intent, err := c.hub.orchestrator.ProcessInput(context.Background(), msg.SessionID, msg.Content)
|
||||||
|
if err != nil {
|
||||||
|
c.sendJSON(WSMessage{Type: "error", Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send intent info
|
||||||
|
if intent != nil {
|
||||||
|
c.sendJSON(WSMessage{Type: "response", Content: "[Intent: " + intent.Type + "]", SessionID: msg.SessionID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream tokens
|
||||||
|
for token := range tokenCh {
|
||||||
|
if token.Done {
|
||||||
|
c.sendJSON(WSMessage{Type: "stream_end", SessionID: msg.SessionID})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
c.sendJSON(WSMessage{Type: "stream_chunk", Content: token.Content, SessionID: msg.SessionID})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) writePump() {
|
func (c *Client) writePump() {
|
||||||
defer c.conn.Close()
|
defer c.conn.Close()
|
||||||
for msg := range c.send {
|
for msg := range c.send {
|
||||||
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
c.mu.Lock()
|
||||||
|
err := c.conn.WriteMessage(websocket.TextMessage, msg)
|
||||||
|
c.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) sendJSON(v any) {
|
||||||
|
data, _ := json.Marshal(v)
|
||||||
|
select {
|
||||||
|
case c.send <- data:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Broadcast sends a message to all connected clients.
|
// Broadcast sends a message to all connected clients.
|
||||||
func (h *Hub) Broadcast(msg Message) error {
|
func (h *Hub) Broadcast(msg any) error {
|
||||||
data, err := json.Marshal(msg)
|
data, err := json.Marshal(msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// APIAdapter adapts the memory Service to a simplified API interface.
|
||||||
|
// This bridges between the rich domain model and the Gateway's REST handler expectations.
|
||||||
|
type APIAdapter struct {
|
||||||
|
svc *Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIEntry is the simplified memory entry for REST API responses.
|
||||||
|
type APIEntry 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAPIAdapter wraps a Service for REST API consumption.
|
||||||
|
func NewAPIAdapter(svc *Service) *APIAdapter {
|
||||||
|
return &APIAdapter{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIAdapter) Search(ctx context.Context, userID, query string, limit int) (any, error) {
|
||||||
|
memories, err := a.svc.Search(ctx, SearchQuery{UserID: userID, Query: query, Limit: limit})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]APIEntry, len(memories))
|
||||||
|
for i, m := range memories {
|
||||||
|
result[i] = toEntry(m)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIAdapter) List(ctx context.Context, userID string, limit int) (any, error) {
|
||||||
|
memories, err := a.svc.List(ctx, userID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]APIEntry, len(memories))
|
||||||
|
for i, m := range memories {
|
||||||
|
result[i] = toEntry(m)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIAdapter) Add(ctx context.Context, userID, content, category string, priority float64) (any, error) {
|
||||||
|
m, err := a.svc.Add(ctx, userID, "", content, category, priority)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e := toEntry(*m)
|
||||||
|
return &e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIAdapter) Get(ctx context.Context, id string) (any, error) {
|
||||||
|
m, err := a.svc.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e := toEntry(*m)
|
||||||
|
return &e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIAdapter) Delete(ctx context.Context, id string) error {
|
||||||
|
return a.svc.Delete(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toEntry(m Memory) APIEntry {
|
||||||
|
return APIEntry{
|
||||||
|
ID: m.ID,
|
||||||
|
UserID: m.UserID,
|
||||||
|
Content: m.Content,
|
||||||
|
Summary: m.Summary,
|
||||||
|
Keywords: m.Keywords,
|
||||||
|
Category: m.Category,
|
||||||
|
Priority: m.Priority,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we use context
|
||||||
|
var _ = context.Background
|
||||||
Reference in new issue
Block a user