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:
@@ -7,6 +7,10 @@ data/
|
||||
docs/
|
||||
.DS_Store
|
||||
chat-session.md
|
||||
scripts/tunnel.sh
|
||||
|
||||
# Test scripts
|
||||
test/
|
||||
|
||||
# DevTools
|
||||
devtools/node_modules/
|
||||
|
||||
@@ -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.
@@ -0,0 +1,8 @@
|
||||
你要经常在 ./docs/progress/ 目录下新建进度 md 文件以便于后续对话继承开发进度。
|
||||
你要经常在 ./docs/decisions/ 目录下新建决策 md 文件以便于后续对话继承开发决策。
|
||||
你要经常在 ./docs/tasks/ 目录下新建每次任务 md 文件以便于后续对话回顾开发任务。
|
||||
|
||||
你同样要经常在 ./docs/ 目录下按照规范新建一些便于后续开发参考的 md 文件或文件夹(文件命名要求统一格式)。
|
||||
每次开启新对话或处理新任务前可以看看这些文件。
|
||||
你可以在思考过程中或任务过程中随时新建/修改/删除这些文件,动作可以频繁一点。
|
||||
在你觉得用户要求的某个功能已经完全修复或编写并验证成功后,可以向当前分支(如 dev)推送。
|
||||
+1052
-277
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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());
|
||||
|
||||
@@ -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:
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Generated
+34
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"ws": "^8.20.1"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user