feat: DevTools综合升级 — 记忆查询 + 会话监看 + WebUI侧边栏重构

- docs: 17个文件重命名为 YYYY-MM-DD.HH-mm-SS-内容.md 格式
- config: 管理员凭据移至 backend/.env (ADMIN_USERNAME/PASSWORD)
- gateway: 新增 SessionState 会话追踪 + GET /api/v1/admin/sessions
- devtools: 新增7个代理端点 (dashboard/sessions/memory)
- devtools: WebUI重构为侧边栏 + 5面板 (仪表盘/记忆/会话/服务/性能)
This commit is contained in:
2026-05-16 15:02:44 +08:00
parent cd60b01cf3
commit d15acf587c
24 changed files with 1934 additions and 347 deletions
+6
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
"github.com/yourname/cyrene-ai/gateway/internal/config"
"github.com/yourname/cyrene-ai/gateway/internal/middleware"
"github.com/yourname/cyrene-ai/gateway/internal/router"
@@ -17,6 +18,11 @@ import (
)
func main() {
// 自动加载 .env 文件(来自 backend/.env
if err := godotenv.Load("../.env"); err != nil {
log.Println("ℹ 未找到 .env 文件,将使用环境变量或默认值")
}
// 加载配置
cfg := config.Load()
+1
View File
@@ -6,6 +6,7 @@ require (
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
)
require (
+2
View File
@@ -32,6 +32,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+112 -22
View File
@@ -1,7 +1,10 @@
package handler
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"
@@ -98,25 +101,128 @@ func (h *ChatHandler) handleMessage(client *ws.Client, msg ws.ClientMessage) {
}
}
// handleChatMessage 处理文字聊天消息
// handleChatMessage 处理文字聊天消息 - 转发到 AI-Core
func (h *ChatHandler) handleChatMessage(client *ws.Client, msg ws.ClientMessage) {
mode := msg.Mode
if mode == "" {
mode = "text"
}
// MVP阶段:生成模拟回复(后续对接AI-Core)
// 实际部署时,这里应转发消息到AI-Core并等待响应
// 记录用户消息
h.hub.RecordMessage(client.SessionID, "user", msg.Content)
// 设置会话状态为 thinking
h.hub.UpdateSessionState(client.SessionID, "thinking")
// 构建 AI-Core 请求
aiReq := map[string]string{
"user_id": client.UserID,
"session_id": client.SessionID,
"message": msg.Content,
"mode": mode,
}
reqBody, err := json.Marshal(aiReq)
if err != nil {
log.Printf("[chat] 序列化请求失败: %v", err)
h.hub.UpdateSessionState(client.SessionID, "error")
client.SendMessage(ws.ServerMessage{
Type: "error",
MessageID: "msg_" + generateID(),
Error: "内部错误,请稍后重试",
Timestamp: time.Now().UnixMilli(),
})
return
}
// 调用 AI-Core
aiCoreURL := h.cfg.AICoreURL + "/api/v1/chat"
httpReq, err := http.NewRequest("POST", aiCoreURL, bytes.NewReader(reqBody))
if err != nil {
log.Printf("[chat] 创建 AI-Core 请求失败: %v", err)
h.hub.UpdateSessionState(client.SessionID, "error")
client.SendMessage(ws.ServerMessage{
Type: "error",
MessageID: "msg_" + generateID(),
Error: "服务暂不可用",
Timestamp: time.Now().UnixMilli(),
})
return
}
httpReq.Header.Set("Content-Type", "application/json")
httpClient := &http.Client{Timeout: 120 * time.Second}
resp, err := httpClient.Do(httpReq)
if err != nil {
log.Printf("[chat] AI-Core 调用失败: %v", err)
h.hub.UpdateSessionState(client.SessionID, "error")
client.SendMessage(ws.ServerMessage{
Type: "error",
MessageID: "msg_" + generateID(),
Error: fmt.Sprintf("AI-Core 调用失败: %v", err),
Timestamp: time.Now().UnixMilli(),
})
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("[chat] 读取 AI-Core 响应失败: %v", err)
h.hub.UpdateSessionState(client.SessionID, "error")
client.SendMessage(ws.ServerMessage{
Type: "error",
MessageID: "msg_" + generateID(),
Error: "读取响应失败",
Timestamp: time.Now().UnixMilli(),
})
return
}
if resp.StatusCode != http.StatusOK {
log.Printf("[chat] AI-Core 返回错误 [%d]: %s", resp.StatusCode, string(body))
h.hub.UpdateSessionState(client.SessionID, "error")
client.SendMessage(ws.ServerMessage{
Type: "error",
MessageID: "msg_" + generateID(),
Error: fmt.Sprintf("AI-Core 错误 (%d)", resp.StatusCode),
Timestamp: time.Now().UnixMilli(),
})
return
}
// 解析 AI-Core 响应
var aiResp struct {
Text string `json:"text"`
Mode string `json:"mode"`
MessageID string `json:"message_id"`
}
if err := json.Unmarshal(body, &aiResp); err != nil {
log.Printf("[chat] 解析 AI-Core 响应失败: %v", err)
h.hub.UpdateSessionState(client.SessionID, "error")
client.SendMessage(ws.ServerMessage{
Type: "error",
MessageID: "msg_" + generateID(),
Error: "解析响应失败",
Timestamp: time.Now().UnixMilli(),
})
return
}
// 记录助手响应
h.hub.RecordMessage(client.SessionID, "assistant", aiResp.Text)
// 设置会话状态为 idle
h.hub.UpdateSessionState(client.SessionID, "idle")
// 发送响应给客户端
response := ws.ServerMessage{
Type: "response",
MessageID: "msg_" + generateID(),
Text: h.generateMockResponse(msg.Content, mode),
MessageID: aiResp.MessageID,
Text: aiResp.Text,
ResponseMode: mode,
Timestamp: time.Now().UnixMilli(),
}
// 发送响应给客户端
if err := client.SendMessage(response); err != nil {
log.Printf("[WS] 发送响应失败: %v", err)
}
@@ -134,22 +240,6 @@ func (h *ChatHandler) handleVoiceInput(client *ws.Client, msg ws.ClientMessage)
client.SendMessage(response)
}
// generateMockResponse 生成模拟回复
func (h *ChatHandler) generateMockResponse(content, mode string) string {
// MVP阶段:没有对接AI-Core时的默认回复
responses := []string{
"嗯嗯,人家听到了哦♪ 开拓者想和昔涟聊些什么呢?",
"嘻嘻,开拓者说的话真有趣呢♪ 让我想想怎么回答……",
"啊,这个问题很有意思呢!虽然人家现在还在学习阶段,但我很乐意倾听开拓者说的每一句话哦♡",
}
// 简单hash选一条
hash := 0
for _, c := range content {
hash += int(c)
}
return responses[hash%len(responses)]
}
// SendSystemMessage 向用户发送系统消息(用于主动通知)
func (h *ChatHandler) SendSystemMessage(userID, sessionID, text string) error {
@@ -1,16 +1,20 @@
package handler
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/yourname/cyrene-ai/gateway/internal/middleware"
)
// MemoryHandler 记忆查询处理器
// MemoryHandler 记忆查询处理器 — 代理到 AI-Core
type MemoryHandler struct {
// MVP阶段:直接透传到AI-Core,Gateway本身不需要记忆存储
aiCoreURL string
client *http.Client
}
@@ -19,42 +23,59 @@ type MemoryHandler struct {
func NewMemoryHandler(aiCoreURL string) *MemoryHandler {
return &MemoryHandler{
aiCoreURL: aiCoreURL,
client: &http.Client{},
client: &http.Client{
Timeout: 15 * time.Second,
},
}
}
// Query 查询用户记忆
// Query 搜索用户记忆 — 代理 GET /api/v1/memory/search?user_id=...&q=...
func (h *MemoryHandler) Query(c *gin.Context) {
userID := middleware.GetUserID(c)
query := c.Query("q")
if query == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "查询参数q不能为空"})
return
}
// MVP阶段:返回简单的内存数据
// 后续将请求转发到AI-Core的记忆API
c.JSON(http.StatusOK, gin.H{
"user_id": userID,
"query": query,
"memories": []gin.H{},
"message": "记忆查询功能将在后续版本中接入AI-Core",
})
url := fmt.Sprintf("%s/api/v1/memory/search?user_id=%s&q=%s",
h.aiCoreURL, userID, query)
resp, err := h.client.Get(url)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("AI-Core 不可达: %v", err)})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result interface{}
json.Unmarshal(body, &result)
c.JSON(resp.StatusCode, result)
}
// List 列出用户所有记忆
// List 列出用户所有记忆 — 代理 GET /api/v1/memory?user_id=...
func (h *MemoryHandler) List(c *gin.Context) {
userID := middleware.GetUserID(c)
c.JSON(http.StatusOK, gin.H{
"user_id": userID,
"memories": []gin.H{},
"message": "记忆列表功能将在后续版本中接入AI-Core",
})
url := fmt.Sprintf("%s/api/v1/memory?user_id=%s", h.aiCoreURL, userID)
resp, err := h.client.Get(url)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("AI-Core 不可达: %v", err)})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result interface{}
json.Unmarshal(body, &result)
c.JSON(resp.StatusCode, result)
}
// Add 手动添加记忆
// Add 手动添加记忆 — 代理 POST /api/v1/memory
func (h *MemoryHandler) Add(c *gin.Context) {
userID := middleware.GetUserID(c)
@@ -76,13 +97,33 @@ func (h *MemoryHandler) Add(c *gin.Context) {
req.Priority = 1
}
// MVP阶段:返回成功但暂不持久化
c.JSON(http.StatusCreated, gin.H{
"status": "accepted",
// 转发到 AI-Core
aiReq := map[string]interface{}{
"user_id": userID,
"content": req.Content,
"category": req.Category,
"priority": req.Priority,
"message": "记忆手动添加功能将在后续版本中接入AI-Core",
})
}
reqBody, _ := json.Marshal(aiReq)
url := fmt.Sprintf("%s/api/v1/memory", h.aiCoreURL)
httpReq, err := http.NewRequest("POST", url, bytes.NewReader(reqBody))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "构建请求失败"})
return
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := h.client.Do(httpReq)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("AI-Core 不可达: %v", err)})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result interface{}
json.Unmarshal(body, &result)
c.JSON(resp.StatusCode, result)
}
@@ -2,16 +2,19 @@ package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/yourname/cyrene-ai/gateway/internal/middleware"
"github.com/yourname/cyrene-ai/gateway/internal/ws"
)
// SessionHandler 会话管理处理器
type SessionHandler struct {
// MVP阶段使用内存存储,后续迁移到PostgreSQL
sessions map[string][]SessionInfo // userID -> sessions
hub *ws.Hub
}
// SessionInfo 会话信息
@@ -24,9 +27,10 @@ type SessionInfo struct {
}
// NewSessionHandler 创建会话处理器
func NewSessionHandler() *SessionHandler {
func NewSessionHandler(hub *ws.Hub) *SessionHandler {
return &SessionHandler{
sessions: make(map[string][]SessionInfo),
hub: hub,
}
}
@@ -49,8 +53,8 @@ func (h *SessionHandler) Create(c *gin.Context) {
ID: "session_" + randomID(12),
UserID: userID,
Title: req.Title,
CreatedAt: nowMillis(),
UpdatedAt: nowMillis(),
CreatedAt: time.Now().UnixMilli(),
UpdatedAt: time.Now().UnixMilli(),
}
h.sessions[userID] = append([]SessionInfo{session}, h.sessions[userID]...)
@@ -104,6 +108,38 @@ func (h *SessionHandler) Get(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在"})
}
// ========== Admin 端点 ==========
// ListActiveSessions 获取当前所有活跃 WebSocket 会话列表 (管理员)
func (h *SessionHandler) ListActiveSessions(c *gin.Context) {
sessions := h.hub.GetActiveSessions()
if sessions == nil {
sessions = []*ws.SessionState{}
}
c.JSON(http.StatusOK, gin.H{
"sessions": sessions,
"total": len(sessions),
})
}
// GetSession 获取指定会话的详细信息 (管理员)
func (h *SessionHandler) GetSession(c *gin.Context) {
sessionID := c.Param("id")
if sessionID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少会话ID"})
return
}
session := h.hub.GetSession(sessionID)
if session == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在"})
return
}
c.JSON(http.StatusOK, session)
}
// 简单的工具函数
func randomID(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
@@ -114,8 +150,3 @@ func randomID(n int) string {
// 使用纳秒时间戳增加唯一性
return string(b)
}
func nowMillis() int64 {
// 避免引入time包,直接返回一个值
return 0
}
+25 -1
View File
@@ -1,6 +1,9 @@
package router
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/yourname/cyrene-ai/gateway/internal/config"
@@ -16,7 +19,7 @@ func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config) {
// 初始化处理器
authHandler := handler.NewAuthHandler(cfg)
sessionHandler := handler.NewSessionHandler()
sessionHandler := handler.NewSessionHandler(hub)
memoryHandler := handler.NewMemoryHandler(cfg.AICoreURL)
chatHandler := handler.NewChatHandler(cfg, hub)
@@ -63,6 +66,14 @@ func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config) {
memory.GET("", memoryHandler.List)
memory.POST("", memoryHandler.Add)
}
// Admin 路由 (需要管理员权限)
admin := protected.Group("/admin")
admin.Use(adminAuth())
{
admin.GET("/sessions", sessionHandler.ListActiveSessions)
admin.GET("/sessions/:id", sessionHandler.GetSession)
}
}
// ========== WebSocket路由 ==========
@@ -81,3 +92,16 @@ func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config) {
})
}
}
// adminAuth 管理员权限中间件 (检查 userID 是否以 "admin_" 开头)
func adminAuth() gin.HandlerFunc {
return func(c *gin.Context) {
userID := middleware.GetUserID(c)
if userID == "" || !strings.HasPrefix(userID, "admin_") {
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
c.Abort()
return
}
c.Next()
}
}
+128
View File
@@ -3,8 +3,29 @@ package ws
import (
"log"
"sync"
"time"
)
// SessionState 会话状态
type SessionState struct {
SessionID string `json:"session_id"`
UserID string `json:"user_id"`
State string `json:"state"` // idle, thinking, streaming, error
ConnectedAt time.Time `json:"connected_at"`
LastActivity time.Time `json:"last_activity"`
MessageCount int `json:"message_count"`
RecentMessages []SessionMessage `json:"recent_messages,omitempty"`
}
// SessionMessage 会话消息记录
type SessionMessage struct {
Role string `json:"role"` // user, assistant, system
Content string `json:"content"` // 截断到 100 字符
Timestamp int64 `json:"timestamp"`
}
const maxRecentMessages = 20
// Hub WebSocket连接池
type Hub struct {
mu sync.RWMutex
@@ -15,6 +36,9 @@ type Hub struct {
// 按用户ID索引的客户端映射
userClients map[string]map[*Client]bool
// 会话状态追踪 (sessionID -> SessionState)
sessions map[string]*SessionState
}
// NewHub 创建WebSocket Hub
@@ -25,6 +49,7 @@ func NewHub() *Hub {
register: make(chan *Client),
unregister: make(chan *Client),
userClients: make(map[string]map[*Client]bool),
sessions: make(map[string]*SessionState),
}
}
@@ -41,6 +66,18 @@ func (h *Hub) Run() {
h.userClients[client.UserID] = make(map[*Client]bool)
}
h.userClients[client.UserID][client] = true
// 会话状态追踪:如果该session尚未存在则创建
if _, exists := h.sessions[client.SessionID]; !exists {
h.sessions[client.SessionID] = &SessionState{
SessionID: client.SessionID,
UserID: client.UserID,
State: "idle",
ConnectedAt: time.Now(),
LastActivity: time.Now(),
MessageCount: 0,
}
}
h.mu.Unlock()
log.Printf("[WS] 客户端连接: user=%s session=%s (当前连接数: %d)",
@@ -59,6 +96,20 @@ func (h *Hub) Run() {
delete(h.userClients, client.UserID)
}
}
// 检查该session是否还有其他连接,没有则移除会话状态
hasOtherConn := false
if clients, ok := h.userClients[client.UserID]; ok {
for c := range clients {
if c.SessionID == client.SessionID {
hasOtherConn = true
break
}
}
}
if !hasOtherConn {
delete(h.sessions, client.SessionID)
}
}
h.mu.Unlock()
@@ -135,3 +186,80 @@ func (h *Hub) UserClientCount(userID string) int {
}
return 0
}
// GetActiveSessions 返回所有活跃会话的列表
func (h *Hub) GetActiveSessions() []*SessionState {
h.mu.RLock()
defer h.mu.RUnlock()
result := make([]*SessionState, 0, len(h.sessions))
for _, s := range h.sessions {
// 返回副本避免外部修改
cp := *s
// 不包含 recent_messages 在列表接口中
cp.RecentMessages = nil
result = append(result, &cp)
}
return result
}
// GetSession 返回指定会话的详细信息(含最近消息)
func (h *Hub) GetSession(sessionID string) *SessionState {
h.mu.RLock()
defer h.mu.RUnlock()
s, ok := h.sessions[sessionID]
if !ok {
return nil
}
// 返回副本
cp := *s
if s.RecentMessages != nil {
cp.RecentMessages = make([]SessionMessage, len(s.RecentMessages))
copy(cp.RecentMessages, s.RecentMessages)
}
return &cp
}
// UpdateSessionState 更新会话状态
func (h *Hub) UpdateSessionState(sessionID, state string) {
h.mu.Lock()
defer h.mu.Unlock()
if s, ok := h.sessions[sessionID]; ok {
s.State = state
s.LastActivity = time.Now()
}
}
// RecordMessage 记录消息到会话
func (h *Hub) RecordMessage(sessionID, role, content string) {
h.mu.Lock()
defer h.mu.Unlock()
s, ok := h.sessions[sessionID]
if !ok {
return
}
s.MessageCount++
s.LastActivity = time.Now()
// 截断内容到 100 字符
runes := []rune(content)
if len(runes) > 100 {
content = string(runes[:100]) + "..."
}
s.RecentMessages = append(s.RecentMessages, SessionMessage{
Role: role,
Content: content,
Timestamp: time.Now().UnixMilli(),
})
// 只保留最近 N 条消息
if len(s.RecentMessages) > maxRecentMessages {
s.RecentMessages = s.RecentMessages[len(s.RecentMessages)-maxRecentMessages:]
}
}
BIN
View File
Binary file not shown.