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:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
Executable
BIN
Binary file not shown.
@@ -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()
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:]
|
||||
}
|
||||
}
|
||||
|
||||
Executable
BIN
Binary file not shown.
Reference in New Issue
Block a user