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
+448
-111
No files matched your search
@@ -177,9 +177,22 @@ func main() {
|
||||
|
||||
// === 12. Gateway ===
|
||||
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.HandleFunc("/ws/chat", wsHub.UpgradeHandler())
|
||||
|
||||
|
||||
@@ -1,58 +1,115 @@
|
||||
// Package api provides HTTP routing, middleware, and handlers for the Cyrene Gateway.
|
||||
// Uses only the standard library net/http to minimize dependencies.
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
|
||||
)
|
||||
|
||||
// Router handles HTTP routing with middleware support.
|
||||
type Router struct {
|
||||
mux *http.ServeMux
|
||||
authMgr AuthManager
|
||||
}
|
||||
// --- Interfaces for injected services ---
|
||||
|
||||
// AuthManager is the interface for JWT authentication.
|
||||
// AuthManager validates JWT tokens.
|
||||
type AuthManager interface {
|
||||
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.
|
||||
func New(authMgr AuthManager) *Router {
|
||||
r := &Router{
|
||||
mux: http.NewServeMux(),
|
||||
authMgr: authMgr,
|
||||
}
|
||||
func New(deps Dependencies) *Router {
|
||||
r := &Router{mux: http.NewServeMux(), deps: deps}
|
||||
r.setupRoutes()
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Router) setupRoutes() {
|
||||
// Public endpoints
|
||||
// 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 endpoints
|
||||
// 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)))
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
// CORS
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -60,19 +117,18 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
|
||||
func (r *Router) authMW(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
header := req.Header.Get("Authorization")
|
||||
if header == "" || !strings.HasPrefix(strings.ToLower(header), "bearer ") {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing or invalid authorization"})
|
||||
h := req.Header.Get("Authorization")
|
||||
if h == "" || !strings.HasPrefix(strings.ToLower(h), "bearer ") {
|
||||
writeJSON(w, 401, M{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
token := header[7:]
|
||||
userID, username, role, err := r.authMgr.ValidateToken(token)
|
||||
uid, uname, role, err := r.deps.AuthMgr.ValidateToken(h[7:])
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid token"})
|
||||
writeJSON(w, 401, M{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
req.Header.Set("X-User-ID", userID)
|
||||
req.Header.Set("X-Username", username)
|
||||
req.Header.Set("X-User-ID", uid)
|
||||
req.Header.Set("X-Username", uname)
|
||||
req.Header.Set("X-Role", role)
|
||||
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 {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
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
|
||||
}
|
||||
next(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Public handlers ---
|
||||
// --- Handlers ---
|
||||
|
||||
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) {
|
||||
if req.Method != "POST" {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST required"})
|
||||
writeJSON(w, 405, M{"error": "POST required"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
json.NewDecoder(req.Body).Decode(&body)
|
||||
|
||||
mgr, ok := r.authMgr.(*StubAuthManager)
|
||||
mgr, ok := r.deps.AuthMgr.(*StubAuthManager)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "auth not configured"})
|
||||
writeJSON(w, 500, M{"error": "auth not configured"})
|
||||
return
|
||||
}
|
||||
token, err := mgr.Login(body.Username, body.Password)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||
writeJSON(w, 401, M{"error": "invalid credentials"})
|
||||
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) {
|
||||
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) {
|
||||
switch req.Method {
|
||||
case "GET":
|
||||
writeJSON(w, http.StatusOK, map[string]any{"sessions": []any{}})
|
||||
writeJSON(w, 200, M{"sessions": []any{}})
|
||||
case "POST":
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"session_id": "stub-session"})
|
||||
writeJSON(w, 201, M{"session_id": randID()})
|
||||
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) {
|
||||
id := strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/")
|
||||
if req.Method == "DELETE" {
|
||||
writeJSON(w, http.StatusNoContent, nil)
|
||||
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"})
|
||||
}
|
||||
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) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"dashboard": map[string]any{
|
||||
"status": "running",
|
||||
"version": "2.0.0-dev",
|
||||
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,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- 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)
|
||||
@@ -165,3 +347,14 @@ func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||
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) {
|
||||
r := New(NewStubAuthManager())
|
||||
r := New(Dependencies{AuthMgr: NewStubAuthManager()})
|
||||
srv := httptest.NewServer(r)
|
||||
defer srv.Close()
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestHealth(t *testing.T) {
|
||||
|
||||
func TestLoginAndProtectedEndpoint(t *testing.T) {
|
||||
auth := NewStubAuthManager()
|
||||
r := New(auth)
|
||||
r := New(Dependencies{AuthMgr: auth})
|
||||
srv := httptest.NewServer(r)
|
||||
defer srv.Close()
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestLoginAndProtectedEndpoint(t *testing.T) {
|
||||
|
||||
func TestLoginWrongPassword(t *testing.T) {
|
||||
auth := NewStubAuthManager()
|
||||
r := New(auth)
|
||||
r := New(Dependencies{AuthMgr: auth})
|
||||
srv := httptest.NewServer(r)
|
||||
defer srv.Close()
|
||||
|
||||
@@ -75,7 +75,7 @@ func TestLoginWrongPassword(t *testing.T) {
|
||||
|
||||
func TestProtectedWithoutToken(t *testing.T) {
|
||||
auth := NewStubAuthManager()
|
||||
r := New(auth)
|
||||
r := New(Dependencies{AuthMgr: auth})
|
||||
srv := httptest.NewServer(r)
|
||||
defer srv.Close()
|
||||
|
||||
@@ -115,7 +115,7 @@ func TestStubAuthManager(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCORSHeaders(t *testing.T) {
|
||||
r := New(NewStubAuthManager())
|
||||
r := New(Dependencies{AuthMgr: NewStubAuthManager()})
|
||||
srv := httptest.NewServer(r)
|
||||
defer srv.Close()
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
// Package ws provides WebSocket connection management for Cyrene.
|
||||
// Package ws provides WebSocket connection management.
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
@@ -16,42 +19,50 @@ var upgrader = websocket.Upgrader{
|
||||
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.
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[*Client]bool
|
||||
|
||||
register chan *Client
|
||||
unregister chan *Client
|
||||
broadcast chan []byte
|
||||
mu sync.RWMutex
|
||||
clients map[*Client]bool
|
||||
register chan *Client
|
||||
unregister chan *Client
|
||||
broadcast chan []byte
|
||||
orchestrator Orchestrator
|
||||
}
|
||||
|
||||
// Client represents a single WebSocket connection.
|
||||
type Client struct {
|
||||
hub *Hub
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
UserID string
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
hub *Hub
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
UserID string
|
||||
SessionID string
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Message represents a WebSocket message envelope.
|
||||
type Message struct {
|
||||
// WSMessage represents a client-server message.
|
||||
type WSMessage struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Timestamp int64 `json:"timestamp,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Devices []any `json:"devices,omitempty"`
|
||||
}
|
||||
|
||||
// NewHub creates and starts a new WebSocket hub.
|
||||
func NewHub() *Hub {
|
||||
// NewHub creates a WebSocket hub with an optional orchestrator.
|
||||
func NewHub(orch Orchestrator) *Hub {
|
||||
h := &Hub{
|
||||
clients: make(map[*Client]bool),
|
||||
register: make(chan *Client),
|
||||
unregister: make(chan *Client),
|
||||
broadcast: make(chan []byte, 256),
|
||||
clients: make(map[*Client]bool),
|
||||
register: make(chan *Client),
|
||||
unregister: make(chan *Client),
|
||||
broadcast: make(chan []byte, 256),
|
||||
orchestrator: orch,
|
||||
}
|
||||
go h.run()
|
||||
return h
|
||||
@@ -60,28 +71,27 @@ func NewHub() *Hub {
|
||||
func (h *Hub) run() {
|
||||
for {
|
||||
select {
|
||||
case client := <-h.register:
|
||||
case c := <-h.register:
|
||||
h.mu.Lock()
|
||||
h.clients[client] = true
|
||||
h.clients[c] = true
|
||||
n := len(h.clients)
|
||||
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()
|
||||
if _, ok := h.clients[client]; ok {
|
||||
delete(h.clients, client)
|
||||
close(client.send)
|
||||
if _, ok := h.clients[c]; ok {
|
||||
delete(h.clients, c)
|
||||
close(c.send)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
log.Printf("[ws] client disconnected (%d remaining)", len(h.clients))
|
||||
|
||||
case message := <-h.broadcast:
|
||||
case msg := <-h.broadcast:
|
||||
h.mu.RLock()
|
||||
for client := range h.clients {
|
||||
for c := range h.clients {
|
||||
select {
|
||||
case client.send <- message:
|
||||
case c.send <- msg:
|
||||
default:
|
||||
// Client too slow, skip
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
@@ -97,20 +107,16 @@ func (h *Hub) HandleUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
if userID == "" {
|
||||
userID = "anonymous"
|
||||
}
|
||||
uid := r.URL.Query().Get("user_id")
|
||||
if uid == "" { uid = "anonymous" }
|
||||
|
||||
client := &Client{
|
||||
hub: h,
|
||||
conn: conn,
|
||||
send: make(chan []byte, 64),
|
||||
UserID: userID,
|
||||
send: make(chan []byte, 128),
|
||||
UserID: uid,
|
||||
}
|
||||
|
||||
h.register <- client
|
||||
|
||||
go client.writePump()
|
||||
go client.readPump()
|
||||
}
|
||||
@@ -122,38 +128,77 @@ func (c *Client) readPump() {
|
||||
}()
|
||||
|
||||
for {
|
||||
_, msgBytes, err := c.conn.ReadMessage()
|
||||
_, raw, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
var msg Message
|
||||
if err := json.Unmarshal(msgBytes, &msg); err != nil {
|
||||
var msg WSMessage
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Echo back for now (will route to AI-Core later)
|
||||
reply := Message{
|
||||
Type: "response",
|
||||
Content: "echo: " + msg.Content,
|
||||
SessionID: msg.SessionID,
|
||||
switch msg.Type {
|
||||
case "message":
|
||||
c.handleChatMessage(msg)
|
||||
default:
|
||||
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() {
|
||||
defer c.conn.Close()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) sendJSON(v any) {
|
||||
data, _ := json.Marshal(v)
|
||||
select {
|
||||
case c.send <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
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