This repository has been archived on 2026-08-12. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Cyrene/backend/gateway/internal/handler/chat_handler.go
T
AskaEth 87214b9441 feat: Phase 1+2 架构进化 — 连续思考链/主动消息决策/情感状态机/离线自主思考 (86文件)
Phase 1 (基础设施):
- ThinkChain 思考链连续性 + 差异化思考提示词 (persistent)
- AutonomousToolPolicy 工具安全策略 (safe/unsafe/conditional)
- MessageScheduler 自适应消息节奏 (Idle/Available/Busy)
- SessionEnrichmentStore 渐进式上下文丰富 (5层)
- ConversationBus 事件总线 + ResponseCache (dedup)
- pkg/logger 统一日志 + 所有 handler 替换 fmt.Printf
- NPE 守卫/链路优化/数据库表修复/Go workspace

Phase 2 (人格交互):
- EmotionState/EmotionTracker 情感状态机 (5种心情, 情绪衰减)
- ProactiveGuard 主动消息多维决策 (静默时段/紧急度/频率/校验)
- Gateway↔ai-core 在线状态感知链路 (presence notification)
- 离线思考频率控制 + 重连问候 + 离线消息排队

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 15:25:12 +08:00

641 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"bufio"
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"github.com/yourname/cyrene-ai/pkg/logger"
"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/store"
"github.com/yourname/cyrene-ai/gateway/internal/ws"
)
// ChatHandler 聊天处理器
type ChatHandler struct {
cfg *config.Config
hub *ws.Hub
sessionStore *store.SessionStore
upgrader websocket.Upgrader
}
// NewChatHandler 创建聊天处理器
func NewChatHandler(cfg *config.Config, hub *ws.Hub, sessionStore *store.SessionStore) *ChatHandler {
return &ChatHandler{
cfg: cfg,
hub: hub,
sessionStore: sessionStore,
upgrader: websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // 开发阶段允许所有来源
},
},
}
}
// HandleWebSocket 处理WebSocket升级和消息路由
func (h *ChatHandler) HandleWebSocket(c *gin.Context) {
// 从query参数获取token和session_id
token := c.Query("token")
sessionID := c.Query("session_id")
if token == "" {
// 也尝试从Authorization头读取
authHeader := c.GetHeader("Authorization")
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
token = authHeader[7:]
}
}
if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "需要认证令牌"})
return
}
// 验证token
userID, err := h.cfg.ValidateToken(token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "认证令牌无效"})
return
}
// 主对话仅限管理员访问
if userID != "admin" {
c.JSON(http.StatusForbidden, gin.H{
"error": "主对话仅限管理员使用",
"errorType": "admin_only",
"hint": "请使用管理员账号登录以访问主对话功能",
})
return
}
if sessionID == "" {
sessionID = "session_" + generateID()
}
// 升级WebSocket连接
conn, err := h.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
logger.Printf("[WS] 升级连接失败: %v", err)
return
}
// 创建客户端
client := ws.NewClient(h.hub, conn, userID, sessionID)
// 注册到Hub
h.hub.Register(client)
// 启动读写协程
go client.WritePump()
go client.ReadPump(func(client *ws.Client, msg ws.ClientMessage) {
h.handleMessage(client, msg)
})
}
// handleMessage 处理WebSocket消息
func (h *ChatHandler) handleMessage(client *ws.Client, msg ws.ClientMessage) {
switch msg.Type {
case "message":
h.handleChatMessage(client, msg)
case "voice_input":
h.handleVoiceInput(client, msg)
case "history":
h.handleHistoryRequest(client, msg)
default:
logger.Printf("[WS] 未知消息类型: %s from user=%s", msg.Type, client.UserID)
}
}
// handleChatMessage 处理文字聊天消息 - 转发到 AI-Core(流式发送)
func (h *ChatHandler) handleChatMessage(client *ws.Client, msg ws.ClientMessage) {
mode := msg.Mode
if mode == "" {
mode = "text"
}
// 持久化用户消息到数据库(在 WebSocket 发送之前)
if h.sessionStore != nil && h.sessionStore.IsAvailable() {
if err := h.sessionStore.AddMessage(client.SessionID, "user", "chat", msg.Content); err != nil {
logger.Printf("[chat] 持久化用户消息失败: %v", err)
}
}
// 记录用户消息
h.hub.RecordMessage(client.SessionID, "user", msg.Content)
// 设置会话状态为 thinking
h.hub.UpdateSessionState(client.SessionID, "thinking")
// 构建 AI-Core 请求
aiReq := map[string]interface{}{
"user_id": client.UserID,
"session_id": client.SessionID,
"message": msg.Content,
"mode": mode,
}
if len(msg.Attachments) > 0 {
aiReq["attachments"] = msg.Attachments
}
reqBody, err := json.Marshal(aiReq)
if err != nil {
logger.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
}
// 缓存用户消息(在 goroutine 前完成,避免竞态)
userMsg := ws.Message{
ID: "msg_" + generateID(),
Role: "user",
Content: msg.Content,
Timestamp: time.Now().UnixMilli(),
}
if len(msg.Attachments) > 0 {
userMsg.Attachments = msg.Attachments
}
h.hub.CacheMessage(client.UserID, client.SessionID, userMsg)
// 在 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 {
logger.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")
httpReq.Header.Set("Accept", "text/event-stream")
httpClient := &http.Client{Timeout: 120 * time.Second}
resp, err := httpClient.Do(httpReq)
if err != nil {
logger.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()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
logger.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
}
// 使用 bufio.Scanner 逐行读取 SSE 响应
scanner := bufio.NewScanner(resp.Body)
// 增大 scanner buffer 以处理大块 SSE 数据
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
// 通知前端 AI 开始生成回复
client.SendMessage(ws.ServerMessage{
Type: "stream_start",
MessageID: "msg_" + generateID(),
SessionID: client.SessionID,
Timestamp: time.Now().UnixMilli(),
})
var fullText string
var msgID string
var hasReview bool // 是否有审查消息(避免重复持久化)
var segments []ws.VoiceSegment // 收集断句信息
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"`
Mode string `json:"mode,omitempty"`
Done bool `json:"done,omitempty"`
// 断句相关 (来自 AI-Core 新格式)
Segments []struct {
Index int `json:"index"`
Text string `json:"text"`
} `json:"segments,omitempty"`
// 审查后的结构化消息
ReviewMessages []ws.ReviewMessage `json:"review_messages,omitempty"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
logger.Printf("[chat] 解析 SSE delta 失败: %v, raw=%s", err, data)
continue
}
// 错误处理
if chunk.Error != "" {
logger.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
}
// 处理审查后的结构化消息 (review)
if len(chunk.ReviewMessages) > 0 {
for i, rm := range chunk.ReviewMessages {
role := "assistant"
msgType := "chat"
if rm.Type == "action" {
role = "action"
msgType = "action"
}
reviewMsgID := fmt.Sprintf("%s_r%d", msgID, i)
// 持久化每条审查消息
if h.sessionStore != nil && h.sessionStore.IsAvailable() {
if err := h.sessionStore.AddMessage(client.SessionID, role, msgType, rm.Content); err != nil {
logger.Printf("[chat] 持久化审查消息失败: %v", err)
}
}
h.hub.CacheMessage(client.UserID, client.SessionID, ws.Message{
ID: reviewMsgID,
Role: role,
Content: rm.Content,
Timestamp: time.Now().UnixMilli(),
})
client.SendMessage(ws.ServerMessage{
Type: "response",
MessageID: reviewMsgID,
Content: rm.Content,
Role: role,
MsgType: msgType,
SessionID: client.SessionID,
Timestamp: time.Now().UnixMilli(),
})
// 使用 MessageScheduler 计算的 per-message 延迟
if rm.DelayMs > 0 {
time.Sleep(time.Duration(rm.DelayMs) * time.Millisecond)
}
}
hasReview = true
continue
}
// 处理断句事件 (stream_segments)
if len(chunk.Segments) > 0 {
for _, seg := range chunk.Segments {
segments = append(segments, ws.VoiceSegment{
Index: seg.Index,
Text: seg.Text,
})
}
// 发送断句事件给前端
client.SendMessage(ws.ServerMessage{
Type: "stream_segments",
MessageID: msgID,
Segments: segments,
SessionID: client.SessionID,
Timestamp: time.Now().UnixMilli(),
})
continue
}
// 逐 delta 积累(不再逐块转发,由审查消息代替)
if chunk.Delta != "" {
fullText += chunk.Delta
}
}
if err := scanner.Err(); err != nil {
logger.Printf("[chat] SSE 读取错误: %v", err)
h.hub.UpdateSessionState(client.SessionID, "error")
client.SendMessage(ws.ServerMessage{
Type: "error",
MessageID: "msg_" + generateID(),
Error: fmt.Sprintf("流读取错误: %v", err),
Timestamp: time.Now().UnixMilli(),
})
return
}
if msgID == "" {
msgID = "msg_" + generateID()
}
// 检测是否为多消息格式(包含空行分隔的多条消息)
// 如果已有审查消息则跳过,避免与 review_messages 重复
multiParts := parseMultiMessage(fullText)
if !hasReview && len(multiParts) > 1 {
// 发送 multi_message 事件
var items []ws.MultiMessageItem
for i, part := range multiParts {
items = append(items, ws.MultiMessageItem{
Index: i,
Content: part,
})
}
client.SendMessage(ws.ServerMessage{
Type: "multi_message",
MessageID: msgID,
SessionID: client.SessionID,
MultiMessage: &ws.MultiMessagePayload{
Messages: items,
},
Timestamp: time.Now().UnixMilli(),
})
}
// 发送 stream_end
client.SendMessage(ws.ServerMessage{
Type: "stream_end",
MessageID: msgID,
SessionID: client.SessionID,
Timestamp: time.Now().UnixMilli(),
})
// 持久化 AI 回复到数据库
// 如果有审查消息,每条已单独持久化,跳过 fullText 以避免重复
if !hasReview && fullText != "" {
if h.sessionStore != nil && h.sessionStore.IsAvailable() {
if err := h.sessionStore.AddMessage(client.SessionID, "assistant", "chat", fullText); err != nil {
logger.Printf("[chat] 持久化 AI 回复失败: %v", err)
}
}
h.hub.CacheMessage(client.UserID, client.SessionID, ws.Message{
ID: msgID,
Role: "assistant",
Content: fullText,
Timestamp: time.Now().UnixMilli(),
})
}
// RecordMessage 使用不带 [review] 标记的文本
recordText := fullText
if hasReview {
recordText = strings.ReplaceAll(fullText, "[review]", "")
}
h.hub.RecordMessage(client.SessionID, "assistant", recordText)
// 设置会话状态为 idle
h.hub.UpdateSessionState(client.SessionID, "idle")
}
// handleVoiceInput 处理语音输入
func (h *ChatHandler) handleVoiceInput(client *ws.Client, msg ws.ClientMessage) {
// MVP阶段:返回提示
response := ws.ServerMessage{
Type: "error",
MessageID: "msg_" + generateID(),
Error: "语音处理功能将在后续版本中启用",
Timestamp: time.Now().UnixMilli(),
}
client.SendMessage(response)
}
// 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 len(messages) == 0 && h.sessionStore != nil && h.sessionStore.IsAvailable() {
dbMessages, err := h.sessionStore.GetMessages(sessionID, 50, 0)
if err == nil && len(dbMessages) > 0 {
logger.Printf("[history] 从数据库恢复会话历史: session=%s, %d 条消息", sessionID, len(dbMessages))
// 恢复到内存缓存
for _, dbMsg := range dbMessages {
messages = append(messages, ws.Message{
ID: fmt.Sprintf("db_%d", dbMsg.ID),
Role: dbMsg.Role,
MsgType: dbMsg.MsgType,
Content: dbMsg.Content,
Timestamp: dbMsg.CreatedAt.UnixMilli(),
})
h.hub.CacheMessage(client.UserID, sessionID, ws.Message{
ID: fmt.Sprintf("db_%d", dbMsg.ID),
Role: dbMsg.Role,
MsgType: dbMsg.MsgType,
Content: dbMsg.Content,
Timestamp: dbMsg.CreatedAt.UnixMilli(),
})
}
}
}
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 {
logger.Printf("[WS] 发送历史消息失败: %v", err)
}
}
// SendSystemMessage 向用户发送系统消息(用于主动通知)
func (h *ChatHandler) SendSystemMessage(userID, sessionID, text string) error {
msg := ws.ServerMessage{
Type: "response",
MessageID: "sys_" + generateID(),
Text: text,
Timestamp: time.Now().UnixMilli(),
}
data, err := json.Marshal(msg)
if err != nil {
return err
}
h.hub.SendToSession(userID, sessionID, data)
return nil
}
// HandleProactiveMessage 处理来自 AI-Core 后台思考的主动消息
// POST /api/v1/internal/proactive-message
func (h *ChatHandler) HandleProactiveMessage(c *gin.Context) {
var req struct {
UserID string `json:"user_id" binding:"required"`
Content string `json:"content" binding:"required"`
SessionID string `json:"session_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效: " + err.Error()})
return
}
// 检查用户是否在线
onlineCount := h.hub.UserClientCount(req.UserID)
if onlineCount == 0 {
// Phase 2: 离线时排队,等待用户重连后推送
data, _ := json.Marshal(ws.ServerMessage{
Type: "response",
MessageID: "proactive_" + generateID(),
Content: req.Content,
Role: "assistant",
MsgType: "proactive",
SessionID: req.SessionID,
Timestamp: time.Now().UnixMilli(),
})
h.hub.QueueProactiveMessage(req.UserID, data)
logger.Printf("[proactive] 用户离线,消息已排队: user=%s", req.UserID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"reason": "queued",
"message": "用户离线,消息已排队等待重连后推送",
})
return
}
// 构建主动消息
msgID := "proactive_" + generateID()
msg := ws.ServerMessage{
Type: "response",
MessageID: msgID,
Content: req.Content,
Role: "assistant",
MsgType: "proactive",
SessionID: req.SessionID,
Timestamp: time.Now().UnixMilli(),
}
data, err := json.Marshal(msg)
if err != nil {
logger.Printf("[proactive] 序列化消息失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "内部错误"})
return
}
h.hub.SendToUser(req.UserID, data)
// 同时缓存到对话历史(使用 admin 的主 session
sessionID := req.SessionID
if sessionID == "" {
sessionID = "session_admin_main"
}
h.hub.CacheMessage(req.UserID, sessionID, ws.Message{
ID: msgID,
Role: "assistant",
Content: req.Content,
Timestamp: time.Now().UnixMilli(),
})
h.hub.RecordMessage(sessionID, "assistant", req.Content)
logger.Printf("[proactive] 主动消息已推送: user=%s, online=%d, content_len=%d", req.UserID, onlineCount, len(req.Content))
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "消息已推送",
"delivered": onlineCount,
})
}
func generateID() string {
return time.Now().Format("20060102150405") + randomStr(6)
}
func randomStr(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
// fallback: deterministic but hard to predict
for i := range b {
b[i] = byte(time.Now().UnixNano()%256)
}
}
return hex.EncodeToString(b)[:n]
}
// parseMultiMessage 检测并解析多消息格式
// 如果文本包含空行分隔的多条消息,拆分为多条;否则返回单条
func parseMultiMessage(text string) []string {
if text == "" {
return nil
}
// 按双换行(空行)分割
parts := strings.Split(text, "\n\n")
// 过滤空字符串并去除首尾空白
var result []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
result = append(result, p)
}
}
// 如果只有一条,返回 nil 表示不是多消息格式
if len(result) <= 1 {
return nil
}
return result
}