feat: 多功能升级 — 流式逐字渲染、对话缓存、会话组织优化、记忆管理修复、性能仪表盘
- 前端消息流式逐字渲染 (AI-Core ChatStream → SSE → Gateway → WebSocket stream_chunk → fadeInUp + cursorBlink) - 后端对话缓存 (conversationCache sync.Map, GET /sessions/:id/messages) - 前端侧边栏历史多轮对话显示 - DevTools 性能监控图标移至首页仪表盘 - DevTools 用户记忆查询/删减功能修复 (补全 DELETE 数据链路) - 后端和 DevTools 按用户分类组织实时活动会话 (map[userID]map[sessionID]*Client) - 新增 docs/api-reference/ 路由参考文档 - 新增 docs/message-flow-architecture.md 消息链路架构文档
This commit is contained in:
+110
-18
@@ -172,7 +172,7 @@ func getEnv(key, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// handleChat 处理对话请求
|
||||
// handleChat 处理对话请求(SSE 流式响应)
|
||||
func handleChat(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
@@ -237,36 +237,94 @@ func handleChat(
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 调用LLM
|
||||
llmResp, err := llmAdapter.Chat(ctx, llmMessages)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("LLM调用失败: %v", err), http.StatusInternalServerError)
|
||||
// 4. 设置 SSE 响应头
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 5. 异步提取记忆
|
||||
if memExtractor != nil {
|
||||
go memExtractor.ExtractAndStore(context.Background(), req.UserID, req.SessionID, req.Message, llmResp.Content)
|
||||
// 5. 调用LLM流式接口
|
||||
chunkCh, err := llmAdapter.ChatStream(ctx, llmMessages)
|
||||
if err != nil {
|
||||
// 流式初始化失败,返回 SSE 格式错误
|
||||
errData, _ := json.Marshal(map[string]string{"delta": "", "error": fmt.Sprintf("LLM调用失败: %v", err)})
|
||||
fmt.Fprintf(w, "data: %s\n\n", errData)
|
||||
flusher.Flush()
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
|
||||
// 6. 构建响应
|
||||
resp := map[string]interface{}{
|
||||
"text": llmResp.Content,
|
||||
messageID := fmt.Sprintf("msg-%d", time.Now().UnixNano())
|
||||
|
||||
// 6. 逐 token 推送 SSE
|
||||
var fullContent string
|
||||
var segments []llm.Segment
|
||||
segmenter := llm.NewSegmenter()
|
||||
|
||||
for chunk := range chunkCh {
|
||||
if chunk.Error != nil {
|
||||
log.Printf("[chat] 流式错误: %v", chunk.Error)
|
||||
errData, _ := json.Marshal(map[string]string{"delta": "", "error": chunk.Error.Error()})
|
||||
fmt.Fprintf(w, "data: %s\n\n", errData)
|
||||
flusher.Flush()
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
|
||||
if chunk.Done {
|
||||
// 流结束,flush 剩余片段
|
||||
if remaining := segmenter.Flush(); remaining != nil {
|
||||
segments = append(segments, *remaining)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if chunk.Content != "" {
|
||||
fullContent += chunk.Content
|
||||
|
||||
// 实时断句
|
||||
newSegs := segmenter.Feed(chunk.Content)
|
||||
segments = append(segments, newSegs...)
|
||||
|
||||
deltaData, _ := json.Marshal(map[string]string{
|
||||
"delta": chunk.Content,
|
||||
"message_id": messageID,
|
||||
})
|
||||
fmt.Fprintf(w, "data: %s\n\n", deltaData)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 发送结束标记(附带元数据)
|
||||
endData, _ := json.Marshal(map[string]interface{}{
|
||||
"message_id": messageID,
|
||||
"mode": req.Mode,
|
||||
"message_id": fmt.Sprintf("msg-%d", time.Now().UnixNano()),
|
||||
}
|
||||
"segments": segments,
|
||||
"done": true,
|
||||
})
|
||||
fmt.Fprintf(w, "data: %s\n\n", endData)
|
||||
flusher.Flush()
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
// 语音助手模式断句
|
||||
if req.Mode == "voice_assistant" {
|
||||
resp["segments"] = llm.SplitIntoSegments(llmResp.Content)
|
||||
// 8. 异步提取记忆
|
||||
if memExtractor != nil && fullContent != "" {
|
||||
go memExtractor.ExtractAndStore(context.Background(), req.UserID, req.SessionID, req.Message, fullContent)
|
||||
}
|
||||
|
||||
// Ensure unused variables don't cause compile errors
|
||||
_ = personaLoader
|
||||
_ = memRetriever
|
||||
_ = memExtractor
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
_ = messageID
|
||||
}
|
||||
|
||||
// handleMemorySearch 处理记忆搜索请求
|
||||
@@ -383,6 +441,40 @@ case http.MethodGet:
|
||||
"total": len(memories),
|
||||
})
|
||||
|
||||
case http.MethodDelete:
|
||||
// 删除单条记忆: DELETE /api/v1/memory?id=xxx
|
||||
memoryID := r.URL.Query().Get("id")
|
||||
if memoryID == "" {
|
||||
http.Error(w, "缺少 id 参数", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if memStore == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": "记忆系统未就绪",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if err := memStore.Delete(ctx, memoryID); 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")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "deleted",
|
||||
"memory_id": memoryID,
|
||||
})
|
||||
|
||||
case http.MethodPost:
|
||||
// 手动添加记忆
|
||||
var req struct {
|
||||
|
||||
Binary file not shown.
@@ -1,19 +1,20 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/config"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/middleware"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/ws"
|
||||
)
|
||||
|
||||
@@ -96,12 +97,14 @@ func (h *ChatHandler) handleMessage(client *ws.Client, msg ws.ClientMessage) {
|
||||
h.handleChatMessage(client, msg)
|
||||
case "voice_input":
|
||||
h.handleVoiceInput(client, msg)
|
||||
case "history":
|
||||
h.handleHistoryRequest(client, msg)
|
||||
default:
|
||||
log.Printf("[WS] 未知消息类型: %s from user=%s", msg.Type, client.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
// handleChatMessage 处理文字聊天消息 - 转发到 AI-Core
|
||||
// handleChatMessage 处理文字聊天消息 - 转发到 AI-Core(流式发送)
|
||||
func (h *ChatHandler) handleChatMessage(client *ws.Client, msg ws.ClientMessage) {
|
||||
mode := msg.Mode
|
||||
if mode == "" {
|
||||
@@ -134,7 +137,19 @@ func (h *ChatHandler) handleChatMessage(client *ws.Client, msg ws.ClientMessage)
|
||||
return
|
||||
}
|
||||
|
||||
// 调用 AI-Core
|
||||
// 缓存用户消息(在 goroutine 前完成,避免竞态)
|
||||
h.hub.CacheMessage(client.UserID, client.SessionID, ws.Message{
|
||||
Role: "user",
|
||||
Content: msg.Content,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
|
||||
// 在 goroutine 中进行 AI-Core 调用和流式发送,避免阻塞 ReadPump
|
||||
go h.streamResponse(client, mode, reqBody, msg.Content)
|
||||
}
|
||||
|
||||
// streamResponse 调用 AI-Core SSE 流式接口并逐 delta 转发给客户端
|
||||
func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []byte, userMsg string) {
|
||||
aiCoreURL := h.cfg.AICoreURL + "/api/v1/chat"
|
||||
httpReq, err := http.NewRequest("POST", aiCoreURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
@@ -149,6 +164,7 @@ func (h *ChatHandler) handleChatMessage(client *ws.Client, msg ws.ClientMessage)
|
||||
return
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "text/event-stream")
|
||||
|
||||
httpClient := &http.Client{Timeout: 120 * time.Second}
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
@@ -165,20 +181,8 @@ func (h *ChatHandler) handleChatMessage(client *ws.Client, msg ws.ClientMessage)
|
||||
}
|
||||
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 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
log.Printf("[chat] AI-Core 返回错误 [%d]: %s", resp.StatusCode, string(body))
|
||||
h.hub.UpdateSessionState(client.SessionID, "error")
|
||||
client.SendMessage(ws.ServerMessage{
|
||||
@@ -190,42 +194,115 @@ func (h *ChatHandler) handleChatMessage(client *ws.Client, msg ws.ClientMessage)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 AI-Core 响应
|
||||
var aiResp struct {
|
||||
Text string `json:"text"`
|
||||
Mode string `json:"mode"`
|
||||
MessageID string `json:"message_id"`
|
||||
// 使用 bufio.Scanner 逐行读取 SSE 响应
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
// 增大 scanner buffer 以处理大块 SSE 数据
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
|
||||
var fullText string
|
||||
var msgID string
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
// 跳过非 data 行
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
|
||||
// SSE 流结束标记
|
||||
if data == "[DONE]" {
|
||||
break
|
||||
}
|
||||
|
||||
// 解析 delta 数据
|
||||
var chunk struct {
|
||||
Delta string `json:"delta"`
|
||||
Error string `json:"error,omitempty"`
|
||||
MessageID string `json:"message_id,omitempty"`
|
||||
Done bool `json:"done,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
log.Printf("[chat] 解析 SSE delta 失败: %v, raw=%s", err, data)
|
||||
continue
|
||||
}
|
||||
|
||||
// 错误处理
|
||||
if chunk.Error != "" {
|
||||
log.Printf("[chat] AI-Core 流式错误: %s", chunk.Error)
|
||||
h.hub.UpdateSessionState(client.SessionID, "error")
|
||||
client.SendMessage(ws.ServerMessage{
|
||||
Type: "error",
|
||||
MessageID: "msg_" + generateID(),
|
||||
Error: chunk.Error,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 记录 message_id
|
||||
if chunk.MessageID != "" {
|
||||
msgID = chunk.MessageID
|
||||
}
|
||||
|
||||
// 如果是结束标记(含 done: true),跳出
|
||||
if chunk.Done {
|
||||
break
|
||||
}
|
||||
|
||||
// 逐 delta 转发
|
||||
if chunk.Delta != "" {
|
||||
fullText += chunk.Delta
|
||||
|
||||
client.SendMessage(ws.ServerMessage{
|
||||
Type: "stream_chunk",
|
||||
MessageID: msgID,
|
||||
Content: chunk.Delta,
|
||||
Role: "assistant",
|
||||
SessionID: client.SessionID,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(body, &aiResp); err != nil {
|
||||
log.Printf("[chat] 解析 AI-Core 响应失败: %v", err)
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.Printf("[chat] SSE 读取错误: %v", err)
|
||||
h.hub.UpdateSessionState(client.SessionID, "error")
|
||||
client.SendMessage(ws.ServerMessage{
|
||||
Type: "error",
|
||||
MessageID: "msg_" + generateID(),
|
||||
Error: "解析响应失败",
|
||||
Error: fmt.Sprintf("流读取错误: %v", err),
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 记录助手响应
|
||||
h.hub.RecordMessage(client.SessionID, "assistant", aiResp.Text)
|
||||
if msgID == "" {
|
||||
msgID = "msg_" + generateID()
|
||||
}
|
||||
|
||||
// 发送 stream_end
|
||||
client.SendMessage(ws.ServerMessage{
|
||||
Type: "stream_end",
|
||||
MessageID: msgID,
|
||||
SessionID: client.SessionID,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
|
||||
// 缓存完整响应
|
||||
if fullText != "" {
|
||||
h.hub.CacheMessage(client.UserID, client.SessionID, ws.Message{
|
||||
Role: "assistant",
|
||||
Content: fullText,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
h.hub.RecordMessage(client.SessionID, "assistant", fullText)
|
||||
|
||||
// 设置会话状态为 idle
|
||||
h.hub.UpdateSessionState(client.SessionID, "idle")
|
||||
|
||||
// 发送响应给客户端
|
||||
response := ws.ServerMessage{
|
||||
Type: "response",
|
||||
MessageID: aiResp.MessageID,
|
||||
Text: aiResp.Text,
|
||||
ResponseMode: mode,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
if err := client.SendMessage(response); err != nil {
|
||||
log.Printf("[WS] 发送响应失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleVoiceInput 处理语音输入
|
||||
@@ -241,6 +318,31 @@ func (h *ChatHandler) handleVoiceInput(client *ws.Client, msg ws.ClientMessage)
|
||||
}
|
||||
|
||||
|
||||
// handleHistoryRequest 处理历史消息请求
|
||||
func (h *ChatHandler) handleHistoryRequest(client *ws.Client, msg ws.ClientMessage) {
|
||||
// 优先使用请求中的 session_id,否则使用客户端的 session_id
|
||||
sessionID := msg.SessionID
|
||||
if sessionID == "" {
|
||||
sessionID = client.SessionID
|
||||
}
|
||||
|
||||
messages := h.hub.GetConversation(client.UserID, sessionID)
|
||||
if messages == nil {
|
||||
messages = []ws.Message{}
|
||||
}
|
||||
|
||||
response := ws.ServerMessage{
|
||||
Type: "history_response",
|
||||
MessageID: "hist_" + generateID(),
|
||||
Messages: messages,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
if err := client.SendMessage(response); err != nil {
|
||||
log.Printf("[WS] 发送历史消息失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// SendSystemMessage 向用户发送系统消息(用于主动通知)
|
||||
func (h *ChatHandler) SendSystemMessage(userID, sessionID, text string) error {
|
||||
msg := ws.ServerMessage{
|
||||
@@ -272,5 +374,3 @@ func randomStr(n int) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// 确保未使用变量不报错
|
||||
var _ = middleware.GetUserID
|
||||
|
||||
@@ -127,3 +127,33 @@ func (h *MemoryHandler) Add(c *gin.Context) {
|
||||
json.Unmarshal(body, &result)
|
||||
c.JSON(resp.StatusCode, result)
|
||||
}
|
||||
|
||||
// Delete 删除单条记忆 — 代理 DELETE /api/v1/memory?id=...
|
||||
func (h *MemoryHandler) Delete(c *gin.Context) {
|
||||
memoryID := c.Query("id")
|
||||
if memoryID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 id 参数"})
|
||||
return
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/memory?id=%s", h.aiCoreURL, memoryID)
|
||||
|
||||
req, err := http.NewRequest("DELETE", url, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "构建请求失败"})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.client.Do(req)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -19,11 +19,13 @@ type SessionHandler struct {
|
||||
|
||||
// SessionInfo 会话信息
|
||||
type SessionInfo struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
MessageCount int `json:"message_count"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// NewSessionHandler 创建会话处理器
|
||||
@@ -108,6 +110,21 @@ func (h *SessionHandler) Get(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在"})
|
||||
}
|
||||
|
||||
// GetMessages 获取会话的完整消息列表
|
||||
func (h *SessionHandler) GetMessages(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
sessionID := c.Param("id")
|
||||
|
||||
messages := h.hub.GetConversation(userID, sessionID)
|
||||
if messages == nil {
|
||||
messages = []ws.Message{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"messages": messages,
|
||||
})
|
||||
}
|
||||
|
||||
// ========== Admin 端点 ==========
|
||||
|
||||
// ListActiveSessions 获取当前所有活跃 WebSocket 会话列表 (管理员)
|
||||
@@ -123,6 +140,18 @@ func (h *SessionHandler) ListActiveSessions(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// GetActiveSessions 返回按用户分组的活跃会话列表
|
||||
func (h *SessionHandler) GetActiveSessions(c *gin.Context) {
|
||||
sessionsByUser := h.hub.GetActiveSessionsByUser()
|
||||
if sessionsByUser == nil {
|
||||
sessionsByUser = make(map[string][]*ws.SessionState)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": sessionsByUser,
|
||||
})
|
||||
}
|
||||
|
||||
// GetSession 获取指定会话的详细信息 (管理员)
|
||||
func (h *SessionHandler) GetSession(c *gin.Context) {
|
||||
sessionID := c.Param("id")
|
||||
|
||||
@@ -51,13 +51,15 @@ func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config) {
|
||||
protected.POST("/auth/refresh", authHandler.RefreshToken)
|
||||
|
||||
// 会话管理
|
||||
sessions := protected.Group("/sessions")
|
||||
{
|
||||
sessions.POST("", sessionHandler.Create)
|
||||
sessions.GET("", sessionHandler.List)
|
||||
sessions.GET("/:id", sessionHandler.Get)
|
||||
sessions.DELETE("/:id", sessionHandler.Delete)
|
||||
}
|
||||
sessions := protected.Group("/sessions")
|
||||
{
|
||||
sessions.POST("", sessionHandler.Create)
|
||||
sessions.GET("", sessionHandler.List)
|
||||
sessions.GET("/active", sessionHandler.GetActiveSessions)
|
||||
sessions.GET("/:id", sessionHandler.Get)
|
||||
sessions.DELETE("/:id", sessionHandler.Delete)
|
||||
sessions.GET("/:id/messages", sessionHandler.GetMessages)
|
||||
}
|
||||
|
||||
// 记忆管理
|
||||
memory := protected.Group("/memory")
|
||||
@@ -65,6 +67,7 @@ func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config) {
|
||||
memory.GET("/search", memoryHandler.Query)
|
||||
memory.GET("", memoryHandler.List)
|
||||
memory.POST("", memoryHandler.Add)
|
||||
memory.DELETE("", memoryHandler.Delete)
|
||||
}
|
||||
|
||||
// Admin 路由 (需要管理员权限)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -24,6 +25,13 @@ type SessionMessage struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// Message 完整对话消息(用于缓存)
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
const maxRecentMessages = 20
|
||||
|
||||
// Hub WebSocket连接池
|
||||
@@ -39,6 +47,10 @@ type Hub struct {
|
||||
|
||||
// 会话状态追踪 (sessionID -> SessionState)
|
||||
sessions map[string]*SessionState
|
||||
|
||||
// 对话缓存:key = "userID:sessionID", value = []Message
|
||||
conversationCache sync.Map
|
||||
convCacheMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewHub 创建WebSocket Hub
|
||||
@@ -203,6 +215,42 @@ func (h *Hub) GetActiveSessions() []*SessionState {
|
||||
return result
|
||||
}
|
||||
|
||||
// GetActiveSessionsByUser 返回按用户分组的活跃会话列表
|
||||
func (h *Hub) GetActiveSessionsByUser() map[string][]*SessionState {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
result := make(map[string][]*SessionState)
|
||||
for _, s := range h.sessions {
|
||||
cp := *s
|
||||
cp.RecentMessages = nil
|
||||
result[s.UserID] = append(result[s.UserID], &cp)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetUserSessions 获取某用户的所有活跃会话
|
||||
func (h *Hub) GetUserSessions(userID string) []*SessionState {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
var result []*SessionState
|
||||
if clients, ok := h.userClients[userID]; ok {
|
||||
seen := make(map[string]bool)
|
||||
for c := range clients {
|
||||
if !seen[c.SessionID] {
|
||||
if s, ok := h.sessions[c.SessionID]; ok {
|
||||
cp := *s
|
||||
cp.RecentMessages = nil
|
||||
result = append(result, &cp)
|
||||
seen[c.SessionID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetSession 返回指定会话的详细信息(含最近消息)
|
||||
func (h *Hub) GetSession(sessionID string) *SessionState {
|
||||
h.mu.RLock()
|
||||
@@ -263,3 +311,48 @@ func (h *Hub) RecordMessage(sessionID, role, content string) {
|
||||
s.RecentMessages = s.RecentMessages[len(s.RecentMessages)-maxRecentMessages:]
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 对话缓存方法 ==========
|
||||
|
||||
// cacheKey 生成对话缓存 key
|
||||
func cacheKey(userID, sessionID string) string {
|
||||
return fmt.Sprintf("%s:%s", userID, sessionID)
|
||||
}
|
||||
|
||||
// CacheMessage 缓存单条消息到对话历史
|
||||
func (h *Hub) CacheMessage(userID, sessionID string, msg Message) {
|
||||
key := cacheKey(userID, sessionID)
|
||||
|
||||
h.convCacheMu.Lock()
|
||||
defer h.convCacheMu.Unlock()
|
||||
|
||||
existing, _ := h.conversationCache.Load(key)
|
||||
var messages []Message
|
||||
if existing != nil {
|
||||
messages = existing.([]Message)
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
h.conversationCache.Store(key, messages)
|
||||
}
|
||||
|
||||
// GetConversation 获取完整对话历史
|
||||
func (h *Hub) GetConversation(userID, sessionID string) []Message {
|
||||
key := cacheKey(userID, sessionID)
|
||||
|
||||
val, ok := h.conversationCache.Load(key)
|
||||
if !ok {
|
||||
return []Message{}
|
||||
}
|
||||
|
||||
messages, ok := val.([]Message)
|
||||
if !ok {
|
||||
return []Message{}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
// DeleteConversation 删除对话缓存
|
||||
func (h *Hub) DeleteConversation(userID, sessionID string) {
|
||||
key := cacheKey(userID, sessionID)
|
||||
h.conversationCache.Delete(key)
|
||||
}
|
||||
|
||||
@@ -2,25 +2,29 @@ package ws
|
||||
|
||||
// 客户端 → 服务端消息
|
||||
type ClientMessage struct {
|
||||
Type string `json:"type"` // message | voice_input | ping
|
||||
SessionID string `json:"session_id"`
|
||||
Mode string `json:"mode"` // text | voice_msg | voice_assistant
|
||||
Content string `json:"content"`
|
||||
AudioData string `json:"audio_data,omitempty"` // base64
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Type string `json:"type"` // message | voice_input | ping | history
|
||||
SessionID string `json:"session_id"`
|
||||
Mode string `json:"mode"` // text | voice_msg | voice_assistant
|
||||
Content string `json:"content"`
|
||||
AudioData string `json:"audio_data,omitempty"` // base64
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// 服务端 → 客户端消息
|
||||
type ServerMessage struct {
|
||||
Type string `json:"type"` // response | segment | audio | error | device_update
|
||||
MessageID string `json:"message_id"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Segments []VoiceSegment `json:"segments,omitempty"` // 断句数组
|
||||
FullAudioURL string `json:"full_audio_url,omitempty"`
|
||||
ResponseMode string `json:"response_mode"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Type string `json:"type"` // response | segment | audio | error | device_update | pong | history_response | stream_chunk | stream_end
|
||||
MessageID string `json:"message_id"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Content string `json:"content,omitempty"` // stream_chunk 的增量文本
|
||||
Role string `json:"role,omitempty"` // stream 消息的角色
|
||||
SessionID string `json:"session_id,omitempty"` // 会话 ID
|
||||
Segments []VoiceSegment `json:"segments,omitempty"` // 断句数组
|
||||
FullAudioURL string `json:"full_audio_url,omitempty"`
|
||||
ResponseMode string `json:"response_mode"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Messages []Message `json:"messages,omitempty"` // 历史消息列表
|
||||
}
|
||||
|
||||
type VoiceSegment struct {
|
||||
|
||||
Binary file not shown.
+215
-50
@@ -221,6 +221,23 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
|
||||
.chart-area.cpu { fill: var(--blue); }
|
||||
.chart-area.mem { fill: var(--green); }
|
||||
.legend { display: flex; gap: 14px; font-size: 11px; color: var(--text2); }
|
||||
|
||||
/* 性能仪表盘进度条 */
|
||||
.perf-dashboard { display: flex; flex-direction: column; gap: 14px; }
|
||||
.perf-row { display: flex; align-items: center; gap: 12px; }
|
||||
.perf-label { min-width: 60px; font-size: 12px; color: var(--text2); }
|
||||
.perf-value { min-width: 52px; font-family: 'JetBrains Mono', monospace; font-size: 13px; font-weight: 600; text-align: right; }
|
||||
.perf-bar-wrap { flex: 1; background: var(--bg); border-radius: 4px; height: 10px; overflow: hidden; }
|
||||
.perf-bar { height: 100%; border-radius: 4px; transition: width 0.5s ease; }
|
||||
.perf-bar.cpu-low, .perf-bar.cpu-mid, .perf-bar.cpu-high, .perf-bar.mem-low, .perf-bar.mem-mid, .perf-bar.mem-high { background: var(--blue); }
|
||||
.perf-bar.cpu-mid, .perf-bar.mem-mid { background: var(--yellow); }
|
||||
.perf-bar.cpu-high, .perf-bar.mem-high { background: var(--red); }
|
||||
.perf-bar.mem-low { background: var(--green); }
|
||||
.perf-stat { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border); }
|
||||
.perf-stat:last-child { border-bottom: none; }
|
||||
.perf-stat-icon { font-size: 16px; width: 24px; text-align: center; }
|
||||
.perf-stat-label { font-size: 12px; color: var(--text2); flex: 1; }
|
||||
.perf-stat-value { font-family: 'JetBrains Mono', monospace; font-size: 14px; font-weight: 600; }
|
||||
.legend-item { display: flex; align-items: center; gap: 5px; }
|
||||
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.legend-dot.cpu { background: var(--blue); }
|
||||
@@ -592,22 +609,30 @@ async function renderDashboard() {
|
||||
<div class="cards-grid cards-3" id="dashboard-svc-cards"></div>
|
||||
</div>
|
||||
|
||||
<!-- 性能快照 + 系统信息 -->
|
||||
<!-- 性能快照 + 性能仪表盘 -->
|
||||
<div class="cards-grid cards-2">
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">⚡ 资源使用</span></div>
|
||||
<div id="dashboard-perf"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">💻 系统信息</span></div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;font-size:12px">
|
||||
<div><span style="color:var(--text2)">运行时间:</span> ${formatUptime((data.system?.uptime || 0) * 1000)}</div>
|
||||
<div><span style="color:var(--text2)">堆内存:</span> ${data.system?.heapUsedMB ?? '—'} MB / ${data.system?.heapTotalMB ?? '—'} MB</div>
|
||||
<div><span style="color:var(--text2)">总消息数:</span> ${data.sessions?.totalMessages ?? 0}</div>
|
||||
<div><span style="color:var(--text2)">更新时间:</span> ${formatTime(data.timestamp)}</div>
|
||||
<div class="card-header"><span class="card-title">📊 性能仪表盘</span></div>
|
||||
<div id="performance-dashboard">
|
||||
<div class="empty-state"><div class="icon">📊</div>等待性能数据...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统信息 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">💻 系统信息</span></div>
|
||||
<div style="display:flex;gap:20px;font-size:12px;flex-wrap:wrap">
|
||||
<div><span style="color:var(--text2)">运行时间:</span> ${formatUptime((data.system?.uptime || 0) * 1000)}</div>
|
||||
<div><span style="color:var(--text2)">堆内存:</span> ${data.system?.heapUsedMB ?? '—'} MB / ${data.system?.heapTotalMB ?? '—'} MB</div>
|
||||
<div><span style="color:var(--text2)">总消息数:</span> ${data.sessions?.totalMessages ?? 0}</div>
|
||||
<div><span style="color:var(--text2)">更新时间:</span> ${formatTime(data.timestamp)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 渲染服务卡片
|
||||
@@ -624,6 +649,112 @@ async function renderDashboard() {
|
||||
</span>
|
||||
</div>
|
||||
`).join('') || '<div class="empty-state"><div class="icon">📊</div>等待采样数据...</div>';
|
||||
|
||||
// 渲染性能仪表盘
|
||||
updatePerformanceDashboard(data.performance?.perService || {});
|
||||
}
|
||||
|
||||
// ========== 性能仪表盘渲染 ==========
|
||||
async function updatePerformanceDashboard(perfData) {
|
||||
const container = document.getElementById('performance-dashboard');
|
||||
if (!container) return; // 静默跳过:用户在其他页面
|
||||
|
||||
const entries = Object.entries(perfData);
|
||||
if (entries.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state"><div class="icon">📊</div>等待性能数据...</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 聚合数据
|
||||
let totalCpu = 0, totalMem = 0, activeCount = 0;
|
||||
for (const [, p] of entries) {
|
||||
totalCpu += p.cpu || 0;
|
||||
totalMem += p.mem || 0;
|
||||
if (p.pid) activeCount++;
|
||||
}
|
||||
|
||||
const avgCpu = entries.length > 0 ? Math.round(totalCpu / entries.length * 10) / 10 : 0;
|
||||
const cpuLevel = avgCpu > 80 ? 'cpu-high' : avgCpu > 50 ? 'cpu-mid' : 'cpu-low';
|
||||
const memLevel = totalMem > 1024 ? 'mem-high' : totalMem > 512 ? 'mem-mid' : 'mem-low';
|
||||
|
||||
// 计算平均延迟 (基于活跃连接和服务数估算,或使用 perf 数据中的 elapsed)
|
||||
let avgLatency = '—';
|
||||
let totalElapsed = 0, elapsedCount = 0;
|
||||
for (const [, p] of entries) {
|
||||
if (p.elapsed && p.elapsed > 0) { totalElapsed += p.elapsed; elapsedCount++; }
|
||||
}
|
||||
if (elapsedCount > 0) {
|
||||
avgLatency = Math.round(totalElapsed / elapsedCount) + 'ms';
|
||||
}
|
||||
|
||||
// 获取趋势数据 (从性能仪表盘 API)
|
||||
let trendCpu = '→', trendMem = '→';
|
||||
try {
|
||||
const dashResp = await api('/api/performance/dashboard');
|
||||
if (!dashResp.error && dashResp.summary?.trend) {
|
||||
const t = dashResp.summary.trend;
|
||||
trendCpu = t.cpu === 'up' ? '↑' : t.cpu === 'down' ? '↓' : '→';
|
||||
trendMem = t.mem === 'up' ? '↑' : t.mem === 'down' ? '↓' : '→';
|
||||
// 使用 API 返回的更精确的延迟数据
|
||||
if (dashResp.summary.avgLatencyMs != null) {
|
||||
avgLatency = dashResp.summary.avgLatencyMs + 'ms';
|
||||
}
|
||||
}
|
||||
} catch { /* 忽略: 使用本地计算的数据 */ }
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="perf-dashboard">
|
||||
<!-- CPU 使用率 -->
|
||||
<div class="perf-row">
|
||||
<span class="perf-label">🖥 CPU</span>
|
||||
<div class="perf-bar-wrap">
|
||||
<div class="perf-bar ${cpuLevel}" style="width:${Math.min(avgCpu, 100)}%"></div>
|
||||
</div>
|
||||
<span class="perf-value">${avgCpu}% ${trendCpu}</span>
|
||||
</div>
|
||||
|
||||
<!-- 内存使用 -->
|
||||
<div class="perf-row">
|
||||
<span class="perf-label">💾 内存</span>
|
||||
<div class="perf-bar-wrap">
|
||||
<div class="perf-bar ${memLevel}" style="width:${Math.min(totalMem / 1024 * 100, 100)}%"></div>
|
||||
</div>
|
||||
<span class="perf-value">${Math.round(totalMem)} MB ${trendMem}</span>
|
||||
</div>
|
||||
|
||||
<!-- 详细统计 -->
|
||||
<div style="margin-top:8px">
|
||||
<div class="perf-stat">
|
||||
<span class="perf-stat-icon">⏱</span>
|
||||
<span class="perf-stat-label">平均请求延迟</span>
|
||||
<span class="perf-stat-value" style="color:var(--yellow)">${avgLatency}</span>
|
||||
</div>
|
||||
<div class="perf-stat">
|
||||
<span class="perf-stat-icon">🔗</span>
|
||||
<span class="perf-stat-label">活跃连接数</span>
|
||||
<span class="perf-stat-value" style="color:var(--accent)">${activeCount}</span>
|
||||
</div>
|
||||
<div class="perf-stat">
|
||||
<span class="perf-stat-icon">📦</span>
|
||||
<span class="perf-stat-label">监控服务数</span>
|
||||
<span class="perf-stat-value" style="color:var(--blue)">${entries.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 各服务简要 -->
|
||||
<div style="margin-top:6px;border-top:1px solid var(--border);padding-top:8px">
|
||||
${entries.map(([id, p]) => `
|
||||
<div class="perf-stat">
|
||||
<span class="perf-stat-icon" style="font-size:12px">${p.pid ? '🟢' : '🔴'}</span>
|
||||
<span class="perf-stat-label">${escapeId(id)}</span>
|
||||
<span class="perf-stat-value" style="font-size:11px;color:var(--text2)">
|
||||
CPU ${p.cpu || 0}% · MEM ${p.mem || 0}MB
|
||||
</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDashboardSvcCards(svcs) {
|
||||
@@ -699,9 +830,9 @@ function renderMemoryPanel() {
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>内容</th><th>分类</th><th>优先级</th><th>用户</th><th>创建时间</th></tr></thead>
|
||||
<thead><tr><th>内容</th><th>分类</th><th>优先级</th><th>用户</th><th>创建时间</th><th style="width:50px">操作</th></tr></thead>
|
||||
<tbody id="mem-table-body">
|
||||
<tr><td colspan="5"><div class="empty-state"><div class="icon">🧠</div>使用搜索或列表按钮加载记忆</div></td></tr>
|
||||
<tr><td colspan="6"><div class="empty-state"><div class="icon">🧠</div>使用搜索或列表按钮加载记忆</div></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -732,7 +863,7 @@ function renderMemoryResults(data) {
|
||||
const countEl = document.getElementById('mem-result-count');
|
||||
|
||||
if (data.error) {
|
||||
tbody.innerHTML = `<tr><td colspan="5"><div class="empty-state"><div class="icon">⚠️</div>${escHtml(data.error)}</div></td></tr>`;
|
||||
tbody.innerHTML = `<tr><td colspan="6"><div class="empty-state"><div class="icon">⚠️</div>${escHtml(data.error)}</div></td></tr>`;
|
||||
countEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
@@ -746,7 +877,7 @@ function renderMemoryResults(data) {
|
||||
countEl.textContent = `共 ${memories.length} 条`;
|
||||
|
||||
if (memories.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5"><div class="empty-state"><div class="icon">📭</div>没有找到记忆</div></td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="6"><div class="empty-state"><div class="icon">📭</div>没有找到记忆</div></td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -757,6 +888,7 @@ function renderMemoryResults(data) {
|
||||
<td>${m.priority ?? 1}</td>
|
||||
<td style="color:var(--text2)">${escHtml(m.user_id || '—')}</td>
|
||||
<td style="color:var(--text2);font-size:11px">${formatTime(m.created_at)}</td>
|
||||
<td><button class="btn btn-xs btn-red" onclick="deleteMemory('${escHtml(m.id || m.ID || '')}')" title="删除">🗑</button></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
@@ -782,10 +914,23 @@ async function addMemory() {
|
||||
listMemory();
|
||||
}
|
||||
|
||||
async function deleteMemory(memoryId) {
|
||||
if (!memoryId) { showToast('无效的记忆ID', 'error'); return; }
|
||||
if (!confirm(`确定要删除记忆 ${memoryId.substring(0, 8)}... 吗?`)) return;
|
||||
|
||||
const data = await api(`/api/memory/${encodeURIComponent(memoryId)}`, { method: 'DELETE' });
|
||||
|
||||
if (data.error) { showToast(`删除失败: ${data.error}`, 'error'); return; }
|
||||
|
||||
showToast('记忆删除成功!', 'success');
|
||||
// 自动刷新列表
|
||||
listMemory();
|
||||
}
|
||||
|
||||
// ========== 面板3: 会话监看 ==========
|
||||
function renderSessionsPanel() {
|
||||
document.getElementById('panel-actions').innerHTML = `
|
||||
<button class="btn btn-sm" onclick="loadSessions()" id="sessions-refresh-btn">🔄 刷新</button>
|
||||
<button class="btn btn-sm" onclick="fetchActiveSessions()" id="sessions-refresh-btn">🔄 刷新</button>
|
||||
<span style="font-size:11px;color:var(--text2)">⏱ 每5秒自动刷新</span>
|
||||
`;
|
||||
document.getElementById('panel-sessions').innerHTML = `
|
||||
@@ -794,70 +939,90 @@ function renderSessionsPanel() {
|
||||
<span class="card-title">💬 活跃 WebSocket 会话</span>
|
||||
<span id="sessions-count" style="font-size:12px;color:var(--text2)"></span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th style="width:30px"></th><th>Session ID</th><th>User ID</th><th>状态</th><th>消息数</th><th>连接时间</th><th>最后活跃</th></tr></thead>
|
||||
<tbody id="sessions-table-body">
|
||||
<tr><td colspan="7"><div class="empty-state"><div class="icon">💬</div>加载中...</div></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="sessions-grouped-container">
|
||||
<div class="empty-state"><div class="icon">💬</div>加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
loadSessions();
|
||||
fetchActiveSessions();
|
||||
}
|
||||
|
||||
async function loadSessions() {
|
||||
async function fetchActiveSessions() {
|
||||
const btn = document.getElementById('sessions-refresh-btn');
|
||||
if (btn) btn.classList.add('spinning');
|
||||
|
||||
const data = await api('/api/sessions');
|
||||
const data = await api('/api/sessions/active');
|
||||
|
||||
if (btn) btn.classList.remove('spinning');
|
||||
|
||||
const sessions = data.sessions || [];
|
||||
STATE.sessionsData = sessions;
|
||||
const users = data.users || {};
|
||||
|
||||
// 计算总会话数
|
||||
let totalSessions = 0;
|
||||
const flatSessions = [];
|
||||
for (const [userID, sessions] of Object.entries(users)) {
|
||||
totalSessions += sessions.length;
|
||||
for (const s of sessions) {
|
||||
flatSessions.push({ ...s, _userID: userID });
|
||||
}
|
||||
}
|
||||
STATE.sessionsData = flatSessions;
|
||||
|
||||
// 更新侧边栏徽章
|
||||
const badge = document.getElementById('sessions-badge');
|
||||
badge.textContent = sessions.length;
|
||||
badge.style.display = sessions.length > 0 ? 'inline-block' : 'none';
|
||||
badge.textContent = totalSessions;
|
||||
badge.style.display = totalSessions > 0 ? 'inline-block' : 'none';
|
||||
|
||||
// 更新计数
|
||||
const countEl = document.getElementById('sessions-count');
|
||||
if (countEl) countEl.textContent = `共 ${sessions.length} 个活跃会话`;
|
||||
if (countEl) countEl.textContent = `共 ${Object.keys(users).length} 个用户,${totalSessions} 个活跃会话`;
|
||||
|
||||
const tbody = document.getElementById('sessions-table-body');
|
||||
if (!tbody) return;
|
||||
const container = document.getElementById('sessions-grouped-container');
|
||||
if (!container) return;
|
||||
|
||||
if (data.error) {
|
||||
tbody.innerHTML = `<tr><td colspan="7"><div class="empty-state"><div class="icon">⚠️</div>${escHtml(data.error)}<br><span style="font-size:11px">请确认 Gateway 服务已启动</span></div></td></tr>`;
|
||||
container.innerHTML = `<div class="empty-state"><div class="icon">⚠️</div>${escHtml(data.error)}<br><span style="font-size:11px">请确认 Gateway 服务已启动</span></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7"><div class="empty-state"><div class="icon">💤</div>当前没有活跃会话</div></td></tr>';
|
||||
if (Object.keys(users).length === 0) {
|
||||
container.innerHTML = '<div class="empty-state"><div class="icon">💤</div>当前没有活跃会话</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = sessions.map((s, i) => `
|
||||
<tr id="session-row-${i}" class="session-row" data-index="${i}" style="cursor:pointer" onclick="toggleSessionDetail(${i})">
|
||||
<td><span class="collapse-arrow" id="session-arrow-${i}">▶</span></td>
|
||||
<td><code style="font-size:11px;color:var(--accent)">${escHtml((s.session_id || '').substring(0, 20))}${(s.session_id || '').length > 20 ? '...' : ''}</code></td>
|
||||
<td>${escHtml(s.user_id || '—')}</td>
|
||||
<td><span class="badge ${statusBadge(s.state || 'idle')}">${s.state || 'idle'}</span></td>
|
||||
<td>${s.message_count || 0}</td>
|
||||
<td style="font-size:11px;color:var(--text2)">${timeAgo(s.connected_at)}</td>
|
||||
<td style="font-size:11px;color:var(--text2)">${timeAgo(s.last_activity)}</td>
|
||||
</tr>
|
||||
<tr id="session-detail-${i}" style="display:none">
|
||||
<td colspan="7">
|
||||
<div class="session-detail" id="session-detail-content-${i}">
|
||||
<div style="text-align:center;color:var(--text2);padding:8px">加载详情中...</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
let html = '';
|
||||
let globalIndex = 0;
|
||||
for (const [userID, sessions] of Object.entries(users)) {
|
||||
html += `<div style="margin-bottom:16px">`;
|
||||
html += `<div style="font-weight:600;font-size:14px;padding:8px 0;border-bottom:1px solid var(--border);margin-bottom:8px;color:var(--accent)">👤 User: ${escHtml(userID)}</div>`;
|
||||
|
||||
for (const s of sessions) {
|
||||
const idx = globalIndex++;
|
||||
html += `
|
||||
<div style="padding:6px 0 6px 20px">
|
||||
<div id="session-row-${idx}" class="session-row" data-index="${idx}" style="cursor:pointer;display:flex;align-items:center;gap:10px;padding:6px 10px;background:var(--bg3);border-radius:var(--radius-sm)" onclick="toggleSessionDetail(${idx})">
|
||||
<span class="collapse-arrow" id="session-arrow-${idx}">▶</span>
|
||||
<span style="flex:1">💬 <strong>Session:</strong> <code style="font-size:11px;color:var(--accent)">${escHtml((s.session_id || '').substring(0, 24))}${(s.session_id || '').length > 24 ? '...' : ''}</code></span>
|
||||
<span class="badge ${statusBadge(s.state || 'idle')}">${s.state || 'idle'}</span>
|
||||
<span style="font-size:11px;color:var(--text2)">最近活动: ${timeAgo(s.last_activity)}</span>
|
||||
</div>
|
||||
<div id="session-detail-${idx}" style="display:none;margin-top:4px;margin-left:20px">
|
||||
<div class="session-detail" id="session-detail-content-${idx}">
|
||||
<div style="text-align:center;color:var(--text2);padding:8px">加载详情中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// 保留旧 loadSessions 兼容其他调用
|
||||
async function loadSessions() {
|
||||
fetchActiveSessions();
|
||||
}
|
||||
|
||||
async function toggleSessionDetail(index) {
|
||||
|
||||
@@ -198,6 +198,12 @@ app.get('/api/sessions', async (_req, res) => {
|
||||
res.status(result.status).json(result.body);
|
||||
});
|
||||
|
||||
// GET /api/sessions/active — 获取按用户分组的活跃会话
|
||||
app.get('/api/sessions/active', async (_req, res) => {
|
||||
const result = await proxyToGateway('/api/v1/sessions/active');
|
||||
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);
|
||||
@@ -230,6 +236,14 @@ app.post('/api/memory/add', async (req, res) => {
|
||||
res.status(result.status).json(result.body);
|
||||
});
|
||||
|
||||
app.delete('/api/memory/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (!id) return res.status(400).json({ error: '缺少 memory id' });
|
||||
const qs = new URLSearchParams({ id }).toString();
|
||||
const result = await proxyToGateway(`/api/v1/memory?${qs}`, { method: 'DELETE' });
|
||||
res.status(result.status).json(result.body);
|
||||
});
|
||||
|
||||
// ---- 服务状态 ----
|
||||
app.get('/api/services', (_req, res) => {
|
||||
res.json(processManager.getStatus());
|
||||
@@ -311,6 +325,16 @@ app.get('/api/performance', async (_req, res) => {
|
||||
res.json(snapshot);
|
||||
});
|
||||
|
||||
// 性能仪表盘聚合数据 (供首页仪表盘使用)
|
||||
app.get('/api/performance/dashboard', async (_req, res) => {
|
||||
try {
|
||||
const dashboardData = await performanceMonitor.updateDashboard();
|
||||
res.json(dashboardData);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: `获取性能仪表盘数据失败: ${err.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/performance/history', (_req, res) => {
|
||||
res.json(performanceMonitor.getAllHistory());
|
||||
});
|
||||
|
||||
@@ -103,6 +103,63 @@ class PerformanceMonitor {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新仪表盘数据 — 返回聚合的性能摘要供首页仪表盘使用
|
||||
* 调用方负责将数据渲染到 #performance-dashboard 元素
|
||||
* @returns {object} 仪表盘性能摘要
|
||||
*/
|
||||
async updateDashboard() {
|
||||
const snapshot = await this.getSnapshot();
|
||||
const entries = Object.entries(snapshot);
|
||||
|
||||
let totalCpu = 0, totalMem = 0, activeCount = 0;
|
||||
for (const [, p] of entries) {
|
||||
totalCpu += p.cpu || 0;
|
||||
totalMem += p.mem || 0;
|
||||
if (p.pid) activeCount++;
|
||||
}
|
||||
|
||||
const avgCpu = entries.length > 0 ? Math.round(totalCpu / entries.length * 10) / 10 : 0;
|
||||
const totalMemRounded = Math.round(totalMem * 100) / 100;
|
||||
|
||||
// 计算平均延迟 (基于各服务进程的 elapsed 时间)
|
||||
let avgLatencyMs = null;
|
||||
let totalElapsed = 0, elapsedCount = 0;
|
||||
for (const [, p] of entries) {
|
||||
if (p.elapsed && p.elapsed > 0) { totalElapsed += p.elapsed; elapsedCount++; }
|
||||
}
|
||||
if (elapsedCount > 0) {
|
||||
avgLatencyMs = Math.round(totalElapsed / elapsedCount);
|
||||
}
|
||||
|
||||
// 获取最近历史用于趋势判断
|
||||
const recentHistory = this.getAllHistory();
|
||||
let trendCpu = 'stable', trendMem = 'stable';
|
||||
for (const [, hist] of Object.entries(recentHistory)) {
|
||||
if (hist.length < 5) continue;
|
||||
const recent = hist.slice(-5);
|
||||
const firstCpu = recent[0].cpu, lastCpu = recent[recent.length - 1].cpu;
|
||||
const firstMem = recent[0].mem, lastMem = recent[recent.length - 1].mem;
|
||||
if (lastCpu > firstCpu * 1.15) trendCpu = 'up';
|
||||
else if (lastCpu < firstCpu * 0.85) trendCpu = 'down';
|
||||
if (lastMem > firstMem * 1.15) trendMem = 'up';
|
||||
else if (lastMem < firstMem * 0.85) trendMem = 'down';
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
summary: {
|
||||
avgCpu,
|
||||
totalMemMB: totalMemRounded,
|
||||
activeProcesses: activeCount,
|
||||
monitoredServices: entries.length,
|
||||
avgLatencyMs,
|
||||
trend: { cpu: trendCpu, mem: trendMem },
|
||||
},
|
||||
perService: snapshot,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const performanceMonitor = new PerformanceMonitor();
|
||||
|
||||
@@ -137,6 +137,10 @@ export async function deleteSession(id: string) {
|
||||
return request(`/sessions/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function fetchSessionMessages(id: string) {
|
||||
return request(`/sessions/${id}/messages`);
|
||||
}
|
||||
|
||||
// ========== 记忆API ==========
|
||||
|
||||
export async function searchMemory(query: string) {
|
||||
|
||||
@@ -4,4 +4,5 @@ export {
|
||||
listSessions,
|
||||
getSession,
|
||||
deleteSession,
|
||||
fetchSessionMessages,
|
||||
} from './client';
|
||||
|
||||
@@ -30,17 +30,19 @@ export function MessageBubble({ role, content, timestamp, isStreaming }: Message
|
||||
? 'bg-pink-400 text-white rounded-br-md'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 rounded-bl-md border border-pink-100 dark:border-pink-900'
|
||||
}
|
||||
${isStreaming ? 'animate-pulse' : ''}
|
||||
${isStreaming ? 'message-streaming' : ''}
|
||||
`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap break-words">{content}</p>
|
||||
<p
|
||||
className={`text-xs mt-1 ${
|
||||
isUser ? 'text-pink-100' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{time}
|
||||
</p>
|
||||
{!isStreaming && (
|
||||
<p
|
||||
className={`text-xs mt-1 ${
|
||||
isUser ? 'text-pink-100' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{time}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 用户头像占位 */}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function MessageList({ messages, isTyping }: MessageListProps) {
|
||||
isStreaming={msg.isStreaming}
|
||||
/>
|
||||
))}
|
||||
{isTyping && <TypingIndicator />}
|
||||
{isTyping && !messages.some((m) => m.isStreaming) && <TypingIndicator />}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useSession } from '@/hooks/useSession';
|
||||
import { useSessionStore } from '@/store/sessionStore';
|
||||
import { useEffect } from 'react';
|
||||
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -8,20 +7,51 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
export function Sidebar({ onClose }: SidebarProps) {
|
||||
const { sessions, currentSessionId, loadSessions, createSession, deleteSession, setCurrentSession } = useSession();
|
||||
const {
|
||||
sessions,
|
||||
currentSessionId,
|
||||
createSession,
|
||||
deleteSession,
|
||||
setCurrentSession,
|
||||
} = useSession();
|
||||
const storeSessions = useSessionStore((s) => s.sessions);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
const storeCurrentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||
|
||||
const displaySessions = sessions.length > 0 ? sessions : storeSessions;
|
||||
const activeSessionId = currentSessionId || storeCurrentSessionId;
|
||||
|
||||
const handleNewChat = async () => {
|
||||
const session = await createSession();
|
||||
if (session && onClose) onClose();
|
||||
};
|
||||
|
||||
const handleSelectSession = (id: string) => {
|
||||
setCurrentSession(id);
|
||||
if (onClose) onClose();
|
||||
};
|
||||
|
||||
const handleDeleteSession = (e: { stopPropagation: () => void }, id: string) => {
|
||||
e.stopPropagation();
|
||||
deleteSession(id);
|
||||
};
|
||||
|
||||
/** 格式化时间戳为可读字符串 */
|
||||
const formatTime = (ts: string | number): string => {
|
||||
if (!ts) return '';
|
||||
const date = new Date(typeof ts === 'string' ? parseInt(ts, 10) : ts);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
if (diffMin < 1) return '刚刚';
|
||||
if (diffMin < 60) return `${diffMin}分钟前`;
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
if (diffHour < 24) return `${diffHour}小时前`;
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
if (diffDay < 7) return `${diffDay}天前`;
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="h-full bg-white/90 dark:bg-gray-900/90 border-r border-pink-100 dark:border-pink-900 flex flex-col">
|
||||
{/* 侧边栏头部 */}
|
||||
@@ -45,14 +75,11 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
displaySessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
onClick={() => {
|
||||
setCurrentSession(session.id);
|
||||
if (onClose) onClose();
|
||||
}}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
className={`
|
||||
group flex items-center justify-between px-4 py-2.5 mx-2 rounded-lg cursor-pointer transition-colors
|
||||
${
|
||||
currentSessionId === session.id || session.id === useSessionStore.getState().currentSessionId
|
||||
activeSessionId === session.id
|
||||
? 'bg-pink-50 dark:bg-pink-900/30 text-pink-600'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-300'
|
||||
}
|
||||
@@ -63,15 +90,13 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{session.title || '新的对话'}</p>
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{session.message_count || 0} 条消息
|
||||
{session.message_count != null ? `${session.message_count} 条消息` : ''}
|
||||
{session.updated_at ? ` · ${formatTime(session.updated_at)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteSession(session.id);
|
||||
}}
|
||||
onClick={(e) => handleDeleteSession(e, session.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-400 transition-all"
|
||||
title="删除会话"
|
||||
>
|
||||
|
||||
@@ -1,68 +1,72 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useSessionStore } from '@/store/sessionStore';
|
||||
import { useChatStore } from '@/store/chatStore';
|
||||
import { listSessions as apiListSessions, createSession as apiCreateSession, deleteSession as apiDeleteSession } from '@/api/client';
|
||||
import type { Session } from '@/types/session';
|
||||
|
||||
interface SessionState {
|
||||
sessions: Session[];
|
||||
currentSessionId: string | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function useSession() {
|
||||
const [state, setState] = useState<SessionState>({
|
||||
sessions: [],
|
||||
currentSessionId: null,
|
||||
loading: false,
|
||||
});
|
||||
const {
|
||||
sessions,
|
||||
currentSessionId,
|
||||
loading,
|
||||
setSessions,
|
||||
addSession,
|
||||
removeSession,
|
||||
setCurrentSessionId,
|
||||
setLoading,
|
||||
} = useSessionStore();
|
||||
|
||||
const { clearMessages } = useChatStore();
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setState(prev => ({ ...prev, loading: true }));
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await apiListSessions();
|
||||
if (resp.data) {
|
||||
const data = resp.data as { sessions: Session[] };
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
sessions: data.sessions || [],
|
||||
loading: false,
|
||||
}));
|
||||
} else {
|
||||
setState(prev => ({ ...prev, loading: false }));
|
||||
setSessions(data.sessions || []);
|
||||
}
|
||||
} catch {
|
||||
setState(prev => ({ ...prev, loading: false }));
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [setSessions, setLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
const createSession = useCallback(async (title?: string) => {
|
||||
const resp = await apiCreateSession(title);
|
||||
if (resp.data) {
|
||||
const session = resp.data as Session;
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
sessions: [session, ...prev.sessions],
|
||||
currentSessionId: session.id,
|
||||
}));
|
||||
addSession(session);
|
||||
// 切换会话会触发历史消息加载
|
||||
setCurrentSessionId(session.id);
|
||||
clearMessages();
|
||||
return session;
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
}, [addSession, setCurrentSessionId, clearMessages]);
|
||||
|
||||
const deleteSession = useCallback(async (id: string) => {
|
||||
await apiDeleteSession(id);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
sessions: prev.sessions.filter(s => s.id !== id),
|
||||
currentSessionId: prev.currentSessionId === id ? null : prev.currentSessionId,
|
||||
}));
|
||||
}, []);
|
||||
removeSession(id);
|
||||
if (currentSessionId === id) {
|
||||
clearMessages();
|
||||
}
|
||||
}, [removeSession, currentSessionId, clearMessages]);
|
||||
|
||||
const setCurrentSession = useCallback((id: string) => {
|
||||
setState(prev => ({ ...prev, currentSessionId: id }));
|
||||
}, []);
|
||||
clearMessages();
|
||||
setCurrentSessionId(id);
|
||||
}, [setCurrentSessionId, clearMessages]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
sessions,
|
||||
currentSessionId,
|
||||
loading,
|
||||
loadSessions,
|
||||
createSession,
|
||||
deleteSession,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useChatStore } from '@/store/chatStore';
|
||||
import { useSessionStore } from '@/store/sessionStore';
|
||||
import { getToken } from '@/api/client';
|
||||
import type { WSClientMessage, WSServerMessage } from '@/types/chat';
|
||||
|
||||
@@ -9,13 +10,23 @@ const WS_BASE_URL =
|
||||
export function useWebSocket() {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const { addMessage, setTyping } = useChatStore();
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
|
||||
// 订阅 sessionStore 中的 currentSessionId 变化
|
||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||
useEffect(() => {
|
||||
sessionIdRef.current = currentSessionId;
|
||||
}, [currentSessionId]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
|
||||
const url = `${WS_BASE_URL}?token=${token}`;
|
||||
const sessionID = useSessionStore.getState().currentSessionId || '';
|
||||
const url = sessionID
|
||||
? `${WS_BASE_URL}?token=${token}&session_id=${sessionID}`
|
||||
: `${WS_BASE_URL}?token=${token}`;
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
@@ -26,7 +37,6 @@ export function useWebSocket() {
|
||||
ws.onclose = () => {
|
||||
setIsConnected(false);
|
||||
console.log('[WS] 已断开,3秒后重连...');
|
||||
// 自动重连
|
||||
setTimeout(() => connect(), 3000);
|
||||
};
|
||||
|
||||
@@ -44,7 +54,7 @@ export function useWebSocket() {
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
}, [addMessage, setTyping]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
@@ -55,7 +65,12 @@ export function useWebSocket() {
|
||||
|
||||
const sendMessage = useCallback((msg: WSClientMessage) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(msg));
|
||||
// 自动附上 session_id
|
||||
const sessionID = sessionIdRef.current || useSessionStore.getState().currentSessionId;
|
||||
wsRef.current.send(JSON.stringify({
|
||||
...msg,
|
||||
session_id: msg.session_id || sessionID || undefined,
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -63,13 +78,14 @@ export function useWebSocket() {
|
||||
}
|
||||
|
||||
function handleServerMessage(msg: WSServerMessage) {
|
||||
const { addMessage, setTyping } = useChatStore.getState();
|
||||
const { addMessage, appendToLastMessage, finishStreaming, setTyping } = useChatStore.getState();
|
||||
const { setMessages } = useSessionStore.getState();
|
||||
|
||||
switch (msg.type) {
|
||||
case 'response':
|
||||
if (msg.text) {
|
||||
addMessage({
|
||||
id: msg.message_id,
|
||||
id: msg.message_id || '',
|
||||
role: 'assistant',
|
||||
content: msg.text,
|
||||
timestamp: msg.timestamp,
|
||||
@@ -78,6 +94,40 @@ function handleServerMessage(msg: WSServerMessage) {
|
||||
setTyping(false);
|
||||
break;
|
||||
|
||||
case 'stream_chunk':
|
||||
if (msg.content) {
|
||||
// 首个 chunk 到达时创建消息并隐藏 typing indicator
|
||||
const { messages } = useChatStore.getState();
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
if (!lastMsg || lastMsg.role !== 'assistant' || !lastMsg.isStreaming) {
|
||||
// 创建新的流式消息
|
||||
addMessage({
|
||||
id: msg.message_id || ('msg_' + Date.now()),
|
||||
role: 'assistant',
|
||||
content: msg.content,
|
||||
timestamp: msg.timestamp,
|
||||
isStreaming: true,
|
||||
});
|
||||
setTyping(false); // 首个 chunk 到达,隐藏 typing 指示器
|
||||
} else {
|
||||
// 追加到现有流式消息
|
||||
appendToLastMessage(msg.content);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'stream_end':
|
||||
finishStreaming();
|
||||
break;
|
||||
|
||||
case 'history_response':
|
||||
if (msg.messages) {
|
||||
// 同步历史消息到两个 store
|
||||
setMessages(msg.messages);
|
||||
useChatStore.getState().setMessages(msg.messages);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('[WS] 服务端错误:', msg.error);
|
||||
setTyping(false);
|
||||
|
||||
@@ -69,6 +69,48 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 流式渲染动画 ===== */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cursorBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* 流式消息容器:每个新 chunk 有微淡入效果 */
|
||||
.message-streaming {
|
||||
animation: fadeInUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* 流式消息尾部闪烁光标 */
|
||||
.message-streaming::after {
|
||||
content: '▊';
|
||||
animation: cursorBlink 1s step-end infinite;
|
||||
color: #7c3aed;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* 独立的闪烁光标工具类,可用于显式 span 元素 */
|
||||
.animate-streaming-cursor {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.animate-streaming-cursor::after {
|
||||
content: '▊';
|
||||
animation: cursorBlink 1s step-end infinite;
|
||||
color: #7c3aed;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* 深色模式 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
|
||||
@@ -6,6 +6,8 @@ interface ChatStore {
|
||||
isTyping: boolean;
|
||||
|
||||
addMessage: (message: Message) => void;
|
||||
appendToLastMessage: (content: string) => void;
|
||||
finishStreaming: () => void;
|
||||
setMessages: (messages: Message[]) => void;
|
||||
setTyping: (typing: boolean) => void;
|
||||
clearMessages: () => void;
|
||||
@@ -17,9 +19,41 @@ export const useChatStore = create<ChatStore>((set) => ({
|
||||
|
||||
addMessage: (message) =>
|
||||
set((state) => ({
|
||||
messages: [...state.messages, message],
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
...message,
|
||||
isStreaming: message.role === 'assistant' ? (message.isStreaming ?? true) : message.isStreaming,
|
||||
},
|
||||
],
|
||||
})),
|
||||
|
||||
appendToLastMessage: (content) =>
|
||||
set((state) => {
|
||||
const msgs = [...state.messages];
|
||||
const last = msgs[msgs.length - 1];
|
||||
if (last && last.role === 'assistant' && last.isStreaming) {
|
||||
msgs[msgs.length - 1] = {
|
||||
...last,
|
||||
content: last.content + content,
|
||||
};
|
||||
}
|
||||
return { messages: msgs };
|
||||
}),
|
||||
|
||||
finishStreaming: () =>
|
||||
set((state) => {
|
||||
const msgs = [...state.messages];
|
||||
const last = msgs[msgs.length - 1];
|
||||
if (last && last.role === 'assistant' && last.isStreaming) {
|
||||
msgs[msgs.length - 1] = {
|
||||
...last,
|
||||
isStreaming: false,
|
||||
};
|
||||
}
|
||||
return { messages: msgs, isTyping: false };
|
||||
}),
|
||||
|
||||
setMessages: (messages) => set({ messages }),
|
||||
|
||||
setTyping: (typing) => set({ isTyping: typing }),
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Session } from '@/types/session';
|
||||
import type { Message } from '@/types/chat';
|
||||
import { fetchSessionMessages as apiFetchMessages } from '@/api/client';
|
||||
import { useChatStore } from '@/store/chatStore';
|
||||
|
||||
interface SessionStore {
|
||||
sessions: Session[];
|
||||
currentSessionId: string | null;
|
||||
loading: boolean;
|
||||
messages: Message[];
|
||||
|
||||
setSessions: (sessions: Session[]) => void;
|
||||
addSession: (session: Session) => void;
|
||||
removeSession: (id: string) => void;
|
||||
setCurrentSessionId: (id: string | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setMessages: (messages: Message[]) => void;
|
||||
clearMessages: () => void;
|
||||
}
|
||||
|
||||
export const useSessionStore = create<SessionStore>((set) => ({
|
||||
sessions: [],
|
||||
currentSessionId: null,
|
||||
loading: false,
|
||||
messages: [],
|
||||
|
||||
setSessions: (sessions) => set({ sessions }),
|
||||
addSession: (session) =>
|
||||
@@ -25,7 +32,44 @@ export const useSessionStore = create<SessionStore>((set) => ({
|
||||
set((state) => ({
|
||||
sessions: state.sessions.filter((s) => s.id !== id),
|
||||
currentSessionId: state.currentSessionId === id ? null : state.currentSessionId,
|
||||
messages: state.currentSessionId === id ? [] : state.messages,
|
||||
})),
|
||||
setCurrentSessionId: (id) => set({ currentSessionId: id }),
|
||||
setCurrentSessionId: async (id) => {
|
||||
set({ currentSessionId: id, loading: true });
|
||||
|
||||
// 清除旧消息(同时清 chatStore)
|
||||
if (id === null) {
|
||||
set({ messages: [], loading: false });
|
||||
useChatStore.getState().clearMessages();
|
||||
return;
|
||||
}
|
||||
|
||||
// 从后端加载历史消息
|
||||
try {
|
||||
const resp = await apiFetchMessages(id);
|
||||
if (resp.data) {
|
||||
const data = resp.data as { messages: Message[] };
|
||||
const msgs = data.messages || [];
|
||||
set({ messages: msgs, loading: false });
|
||||
// 同步到 chatStore 以便 ChatContainer 渲染
|
||||
useChatStore.getState().setMessages(msgs);
|
||||
} else {
|
||||
set({ messages: [], loading: false });
|
||||
useChatStore.getState().clearMessages();
|
||||
}
|
||||
} catch {
|
||||
set({ messages: [], loading: false });
|
||||
useChatStore.getState().clearMessages();
|
||||
}
|
||||
},
|
||||
setLoading: (loading) => set({ loading }),
|
||||
setMessages: (messages) => {
|
||||
set({ messages });
|
||||
// 同步到 chatStore
|
||||
useChatStore.getState().setMessages(messages);
|
||||
},
|
||||
clearMessages: () => {
|
||||
set({ messages: [] });
|
||||
useChatStore.getState().clearMessages();
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface Message {
|
||||
|
||||
/** WebSocket 客户端消息 */
|
||||
export interface WSClientMessage {
|
||||
type: 'message' | 'voice_input' | 'ping';
|
||||
type: 'message' | 'voice_input' | 'ping' | 'history';
|
||||
session_id?: string;
|
||||
mode?: ChatMode;
|
||||
content?: string;
|
||||
@@ -37,14 +37,18 @@ export interface WSClientMessage {
|
||||
|
||||
/** WebSocket 服务端消息 */
|
||||
export interface WSServerMessage {
|
||||
type: 'response' | 'segment' | 'audio' | 'error' | 'device_update' | 'pong';
|
||||
message_id: string;
|
||||
type: 'response' | 'segment' | 'audio' | 'error' | 'device_update' | 'pong' | 'history_response' | 'stream_chunk' | 'stream_end';
|
||||
message_id?: string;
|
||||
text?: string;
|
||||
content?: string;
|
||||
role?: string;
|
||||
session_id?: string;
|
||||
segments?: VoiceSegment[];
|
||||
full_audio_url?: string;
|
||||
response_mode?: ChatMode;
|
||||
tool_calls?: ToolCall[];
|
||||
error?: string;
|
||||
messages?: Message[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,19 +5,15 @@ export interface Session {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
persona: string;
|
||||
mode: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
message_count: number;
|
||||
is_active: boolean;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
/** 创建会话参数 */
|
||||
export interface CreateSessionParams {
|
||||
title?: string;
|
||||
persona?: string;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
/** 会话列表响应 */
|
||||
@@ -25,6 +21,11 @@ export interface SessionListResponse {
|
||||
sessions: Session[];
|
||||
}
|
||||
|
||||
/** 会话消息列表响应 */
|
||||
export interface SessionMessagesResponse {
|
||||
messages: import('@/types/chat').Message[];
|
||||
}
|
||||
|
||||
/** 认证相关 */
|
||||
export interface AuthResponse {
|
||||
user_id: string;
|
||||
|
||||
Reference in New Issue
Block a user