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
+4
View File
@@ -7,6 +7,10 @@ data/
docs/
.DS_Store
chat-session.md
scripts/tunnel.sh
# Test scripts
test/
# DevTools
devtools/node_modules/
+4
View File
@@ -32,6 +32,10 @@ MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=cyrene-assets
# ========== 管理员账户 (开发阶段使用) ==========
ADMIN_USERNAME=admin
ADMIN_PASSWORD=your-admin-password
# ========== JWT ==========
JWT_SECRET=your-secret-key-change-in-production
JWT_EXPIRY_HOURS=720
+198 -3
View File
@@ -11,6 +11,8 @@ import (
"syscall"
"time"
"github.com/joho/godotenv"
ctxbuild "github.com/yourname/cyrene-ai/ai-core/internal/context"
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
"github.com/yourname/cyrene-ai/ai-core/internal/memory"
@@ -20,6 +22,11 @@ import (
)
func main() {
// 自动加载 .env 文件(来自 backend/.env
if err := godotenv.Load("../.env"); err != nil {
log.Println("ℹ 未找到 .env 文件,将使用环境变量或默认值")
}
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("🧠 AI-Core 服务启动中...")
@@ -85,6 +92,14 @@ func main() {
handleChat(w, r, orch, ctxBuilder, llmAdapter, personaLoader, memRetriever, memExtractor)
})
// 注册记忆API端点
mux.HandleFunc("/api/v1/memory/search", func(w http.ResponseWriter, r *http.Request) {
handleMemorySearch(w, r, memRetriever)
})
mux.HandleFunc("/api/v1/memory", func(w http.ResponseWriter, r *http.Request) {
handleMemoryCRUD(w, r, memStore, memExtractor)
})
mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok","service":"ai-core","model":"` + llmAdapter.ModelName() + `"}`))
@@ -250,8 +265,188 @@ func handleChat(
_ = personaLoader
_ = memRetriever
_ = memExtractor
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// handleMemorySearch 处理记忆搜索请求
func handleMemorySearch(
w http.ResponseWriter,
r *http.Request,
memRetriever *memory.Retriever,
) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
userID := r.URL.Query().Get("user_id")
if userID == "" {
http.Error(w, "缺少 user_id 参数", http.StatusBadRequest)
return
}
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "缺少 q 参数", http.StatusBadRequest)
return
}
if memRetriever == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"user_id": userID,
"query": query,
"memories": []interface{}{},
"message": "记忆系统未就绪",
})
return
}
ctx := r.Context()
memories, err := memRetriever.Retrieve(ctx, userID, query)
if err != nil {
log.Printf("[memory] 检索失败: %v", err)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"user_id": userID,
"query": query,
"memories": []interface{}{},
"error": "检索失败",
})
return
}
if memories == nil {
memories = []memory.MemoryEntry{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"user_id": userID,
"query": query,
"memories": memories,
"total": len(memories),
})
}
// handleMemoryCRUD 处理记忆的 CRUD 操作
func handleMemoryCRUD(
w http.ResponseWriter,
r *http.Request,
memStore *memory.Store,
memExtractor *memory.Extractor,
) {
switch r.Method {
case http.MethodGet:
// 列出用户的所有记忆
userID := r.URL.Query().Get("user_id")
if userID == "" {
http.Error(w, "缺少 user_id 参数", http.StatusBadRequest)
return
}
if memStore == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"user_id": userID,
"memories": []interface{}{},
"message": "记忆系统未就绪",
})
return
}
ctx := r.Context()
memories, err := memStore.Query(ctx, model.MemoryQuery{
UserID: userID,
Limit: 50,
})
if err != nil {
log.Printf("[memory] 查询失败: %v", err)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"user_id": userID,
"memories": []interface{}{},
"error": "查询失败",
})
return
}
if memories == nil {
memories = []model.MemoryEntry{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"user_id": userID,
"memories": memories,
"total": len(memories),
})
case http.MethodPost:
// 手动添加记忆
var req struct {
UserID string `json:"user_id"`
Content string `json:"content"`
Category string `json:"category"`
Priority int `json:"priority"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的请求体", http.StatusBadRequest)
return
}
if req.UserID == "" || req.Content == "" {
http.Error(w, "缺少 user_id 或 content", http.StatusBadRequest)
return
}
if req.Category == "" {
req.Category = "other"
}
if req.Priority <= 0 {
req.Priority = 1
}
if memStore == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "记忆系统未就绪",
})
return
}
entry := &model.MemoryEntry{
UserID: req.UserID,
Content: req.Content,
Category: model.MemoryCategory(req.Category),
Priority: model.MemoryPriority(req.Priority),
}
ctx := r.Context()
if err := memStore.Save(ctx, entry); err != nil {
log.Printf("[memory] 保存失败: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "保存失败",
})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "saved",
"memory": entry,
})
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
// Ensure unused variables don't cause compile errors
_ = memExtractor
}
+2 -1
View File
@@ -3,6 +3,7 @@ module github.com/yourname/cyrene-ai/ai-core
go 1.26.2
require (
gopkg.in/yaml.v3 v3.0.1
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
gopkg.in/yaml.v3 v3.0.1
)
+8
View File
@@ -0,0 +1,8 @@
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
BIN
View File
Binary file not shown.
+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.
+8
View File
@@ -0,0 +1,8 @@
你要经常在 ./docs/progress/ 目录下新建进度 md 文件以便于后续对话继承开发进度。
你要经常在 ./docs/decisions/ 目录下新建决策 md 文件以便于后续对话继承开发决策。
你要经常在 ./docs/tasks/ 目录下新建每次任务 md 文件以便于后续对话回顾开发任务。
你同样要经常在 ./docs/ 目录下按照规范新建一些便于后续开发参考的 md 文件或文件夹(文件命名要求统一格式)。
每次开启新对话或处理新任务前可以看看这些文件。
你可以在思考过程中或任务过程中随时新建/修改/删除这些文件,动作可以频繁一点。
在你觉得用户要求的某个功能已经完全修复或编写并验证成功后,可以向当前分支(如 dev)推送。
+1052 -277
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -12,6 +12,9 @@ const ROOT = path.resolve(__dirname, '../..');
export const DEVTOOLS_PORT = process.env.DEVTOOLS_PORT || 9090;
export const LOGS_DIR = path.resolve(__dirname, '../logs');
export const GATEWAY_URL = process.env.GATEWAY_URL || 'http://localhost:8080';
export const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'cyrene-dev-admin';
export const SERVICES = {
'ai-core': {
@@ -20,9 +23,6 @@ export const SERVICES = {
command: './main',
env: {
AI_CORE_PORT: '8081',
LLM_API_URL: process.env.LLM_API_URL || 'https://api.openai.com/v1',
LLM_API_KEY: process.env.LLM_API_KEY || '',
LLM_MODEL: process.env.LLM_MODEL || 'gpt-4o',
PERSONA_DIR: './internal/persona',
},
healthUrl: 'http://localhost:8081/api/v1/health',
+157 -1
View File
@@ -16,7 +16,7 @@ import { fileURLToPath } from 'url';
import { processManager } from './process-manager.js';
import { performanceMonitor } from './performance.js';
import { SERVICES, DEVTOOLS_PORT, LOGS_DIR, logFile } from './config.js';
import { SERVICES, DEVTOOLS_PORT, LOGS_DIR, logFile, GATEWAY_URL, ADMIN_USERNAME, ADMIN_PASSWORD } from './config.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -62,6 +62,67 @@ processManager.on('log', (serviceId, stream, text) => {
}
});
// ========== Gateway 代理辅助函数 ==========
/** 缓存的 JWT token 和过期时间 */
let cachedToken = null;
let tokenExpiry = 0;
/**
* 获取 Gateway JWT token (通过 admin 凭据登录,缓存直到过期)
*/
async function getGatewayToken() {
if (cachedToken && Date.now() < tokenExpiry - 60000) {
return cachedToken;
}
try {
const resp = await fetch(`${GATEWAY_URL}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: ADMIN_USERNAME, password: ADMIN_PASSWORD }),
signal: AbortSignal.timeout(5000),
});
if (!resp.ok) {
console.error('[Gateway代理] 登录失败:', resp.status);
return null;
}
const data = await resp.json();
cachedToken = data.token;
tokenExpiry = data.expires ? data.expires * 1000 : Date.now() + 3600000;
return cachedToken;
} catch (err) {
console.error('[Gateway代理] 登录异常:', err.message);
return null;
}
}
/**
* 代理请求到 Gateway,自动携带 JWT token
* @param {string} path - Gateway API 路径 (如 /api/v1/memory/search?user_id=...)
* @param {object} opts - fetch 选项
*/
async function proxyToGateway(path, opts = {}) {
const token = await getGatewayToken();
if (!token) {
return { status: 502, body: { error: '无法连接到 Gateway 认证服务' } };
}
const url = `${GATEWAY_URL}${path}`;
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
...opts.headers,
};
try {
const resp = await fetch(url, { ...opts, headers, signal: AbortSignal.timeout(15000) });
const body = await resp.json().catch(() => null);
return { status: resp.status, body };
} catch (err) {
return { status: 502, body: { error: `Gateway 不可达: ${err.message}` } };
}
}
// ========== REST API 路由 ==========
// ---- 健康检查 ----
@@ -74,6 +135,101 @@ app.get('/api/health', (_req, res) => {
});
});
// ---- 仪表盘数据 (必须在 /api/services/:id 之前以避免路由冲突) ----
app.get('/api/dashboard', async (_req, res) => {
try {
const [services, perfSnapshot, sessionsResult] = await Promise.all([
Promise.resolve(processManager.getStatus()),
performanceMonitor.getSnapshot(),
proxyToGateway('/api/v1/admin/sessions').catch(() => ({ status: 502, body: { sessions: [], total: 0 } })),
]);
let runningCount = 0, totalCpu = 0, totalMem = 0;
for (const svc of Object.values(services)) {
if (svc.status === 'running') runningCount++;
}
for (const p of Object.values(perfSnapshot)) {
totalCpu += p.cpu || 0;
totalMem += p.mem || 0;
}
const sessionsData = sessionsResult.body || {};
const activeSessions = sessionsData.total || sessionsData.sessions?.length || 0;
let totalMessages = 0;
if (sessionsData.sessions) {
for (const s of sessionsData.sessions) {
totalMessages += (s.message_count || 0);
}
}
let memoryCount = null;
try {
const token = await getGatewayToken();
if (token) {
const memResp = await fetch(`${GATEWAY_URL}/api/v1/memory?user_id=admin_admin`, {
headers: { 'Authorization': `Bearer ${token}` },
signal: AbortSignal.timeout(5000),
});
if (memResp.ok) {
const memData = await memResp.json();
memoryCount = Array.isArray(memData) ? memData.length : (memData.memories ? memData.memories.length : null);
}
}
} catch { /* 忽略 */ }
const sysMem = process.memoryUsage();
res.json({
timestamp: Date.now(),
services: { total: Object.keys(services).length, running: runningCount, list: services },
performance: { totalCpu: Math.round(totalCpu * 100) / 100, totalMem: Math.round(totalMem * 100) / 100, perService: perfSnapshot },
sessions: { active: activeSessions, totalMessages },
memory: { total: memoryCount },
system: { heapUsedMB: Math.round(sysMem.heapUsed / 1024 / 1024 * 100) / 100, heapTotalMB: Math.round(sysMem.heapTotal / 1024 / 1024 * 100) / 100, uptime: process.uptime() },
});
} catch (err) {
res.status(500).json({ error: `获取仪表盘数据失败: ${err.message}` });
}
});
// ---- 会话监看代理 (必须在 /api/services/:id 之前) ----
app.get('/api/sessions', async (_req, res) => {
const result = await proxyToGateway('/api/v1/admin/sessions');
res.status(result.status).json(result.body);
});
app.get('/api/sessions/:id', async (req, res) => {
const result = await proxyToGateway(`/api/v1/admin/sessions/${req.params.id}`);
res.status(result.status).json(result.body);
});
// ---- 记忆管理代理 (必须在 /api/services/:id 之前) ----
app.get('/api/memory/search', async (req, res) => {
const { user_id, q } = req.query;
if (!user_id || !q) return res.status(400).json({ error: '缺少 user_id 或 q 参数' });
const qs = new URLSearchParams({ user_id, q }).toString();
const result = await proxyToGateway(`/api/v1/memory/search?${qs}`);
res.status(result.status).json(result.body);
});
app.get('/api/memory/list', async (req, res) => {
const { user_id } = req.query;
if (!user_id) return res.status(400).json({ error: '缺少 user_id 参数' });
const qs = new URLSearchParams({ user_id }).toString();
const result = await proxyToGateway(`/api/v1/memory?${qs}`);
res.status(result.status).json(result.body);
});
app.post('/api/memory/add', async (req, res) => {
const { user_id, content, category, priority } = req.body;
if (!user_id || !content) return res.status(400).json({ error: '缺少 user_id 或 content' });
const result = await proxyToGateway('/api/v1/memory', {
method: 'POST',
body: JSON.stringify({ user_id, content, category: category || 'other', priority: priority || 1 }),
});
res.status(result.status).json(result.body);
});
// ---- 服务状态 ----
app.get('/api/services', (_req, res) => {
res.json(processManager.getStatus());
+74
View File
@@ -0,0 +1,74 @@
# docker-compose.dev.db.yml (开发环境 - 仅运行数据库基础设施)
# 所有容器名以 cyrene_ 开头,通过 SSH 隧道访问
version: '3.8'
services:
# ========== PostgreSQL + pgvector ==========
postgres:
image: pgvector/pgvector:pg16
container_name: cyrene_postgres
environment:
POSTGRES_USER: cyrene
POSTGRES_PASSWORD: change_me
POSTGRES_DB: cyrene_ai
ports:
- "5432:5432"
volumes:
- cyrene_pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U cyrene -d cyrene_ai"]
interval: 10s
timeout: 3s
retries: 5
# ========== Redis ==========
redis:
image: redis:7-alpine
container_name: cyrene_redis
ports:
- "6379:6379"
volumes:
- cyrene_redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
# ========== Qdrant 向量数据库 ==========
qdrant:
image: qdrant/qdrant:latest
container_name: cyrene_qdrant
ports:
- "6333:6333"
- "6334:6334"
volumes:
- cyrene_qdrant_data:/qdrant/storage
# ========== MinIO 对象存储 ==========
minio:
image: minio/minio:latest
container_name: cyrene_minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
volumes:
- cyrene_minio_data:/data
# ========== NATS 消息队列 ==========
nats:
image: nats:2-alpine
container_name: cyrene_nats
ports:
- "4222:4222"
- "8222:8222"
volumes:
cyrene_pg_data:
cyrene_redis_data:
cyrene_qdrant_data:
cyrene_minio_data:
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>昔涟 - Cyrene AI</title>
<title>昔涟 - Cyrene</title>
</head>
<body>
<div id="root"></div>
+5 -5
View File
@@ -21,7 +21,7 @@ export default function App() {
}
};
// 登录页面 (开发阶段禁用注册)
// 登录页面 (开发阶段暂时禁用注册)
if (!isLoggedIn) {
return (
<div className="min-h-screen bg-[#FFFAF5] dark:bg-[#1a1a2e] flex items-center justify-center p-4">
@@ -31,14 +31,14 @@ export default function App() {
<div className="text-6xl mb-4">🌸</div>
<h1 className="text-2xl font-bold text-pink-500 mb-2"></h1>
<p className="text-sm text-gray-400">
AI伙伴
</p>
</div>
{/* 表单 */}
{/* 登录表单 */}
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-lg p-6 space-y-4 border border-pink-100 dark:border-pink-900">
<div className="text-center mb-2">
<span className="text-sm font-medium text-pink-500"></span>
<span className="text-sm font-medium text-pink-500"></span>
</div>
<input
@@ -72,7 +72,7 @@ export default function App() {
</button>
<p className="text-xs text-gray-400 text-center">
· 管理员: admin / cyrene-dev-admin
· 管理员凭据: admin / cyrene-dev-admin
</p>
</div>
</div>
+34
View File
@@ -0,0 +1,34 @@
{
"name": "Cyrene",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"ws": "^8.20.1"
}
},
"node_modules/ws": {
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"devDependencies": {
"ws": "^8.20.1"
}
}