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>
This commit is contained in:
2026-05-23 15:25:12 +08:00
parent b123a36aae
commit 87214b9441
86 changed files with 3085 additions and 582 deletions
+5 -5
View File
@@ -2,7 +2,7 @@ package ws
import (
"encoding/json"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"time"
"github.com/gorilla/websocket"
@@ -60,7 +60,7 @@ func (c *Client) ReadPump(onMessage func(client *Client, msg ClientMessage)) {
_, rawMessage, err := c.Conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure, websocket.CloseAbnormalClosure) {
log.Printf("[WS] 读取错误: user=%s err=%v", c.UserID, err)
logger.Printf("[WS] 读取错误: user=%s err=%v", c.UserID, err)
}
break
}
@@ -68,7 +68,7 @@ func (c *Client) ReadPump(onMessage func(client *Client, msg ClientMessage)) {
// 解析消息
var msg ClientMessage
if err := json.Unmarshal(rawMessage, &msg); err != nil {
log.Printf("[WS] 消息解析失败: user=%s err=%v", c.UserID, err)
logger.Printf("[WS] 消息解析失败: user=%s err=%v", c.UserID, err)
continue
}
@@ -109,7 +109,7 @@ func (c *Client) WritePump() {
}
if err := c.Conn.WriteMessage(websocket.TextMessage, message); err != nil {
log.Printf("[WS] 写入错误: user=%s err=%v", c.UserID, err)
logger.Printf("[WS] 写入错误: user=%s err=%v", c.UserID, err)
return
}
@@ -134,7 +134,7 @@ func (c *Client) SendMessage(msg ServerMessage) error {
return nil
default:
// 通道满:记录警告并返回错误(避免静默丢弃)
log.Printf("[WS] 发送通道已满,丢弃消息: type=%s user=%s", msg.Type, c.UserID)
logger.Printf("[WS] 发送通道已满,丢弃消息: type=%s user=%s", msg.Type, c.UserID)
return nil
}
}
+93 -15
View File
@@ -3,9 +3,10 @@ package ws
import (
"encoding/json"
"fmt"
"log"
"github.com/yourname/cyrene-ai/pkg/logger"
"net/http"
"os"
"strings"
"sync"
"time"
@@ -70,6 +71,11 @@ type Hub struct {
// 闲置超时时间
idleTimeout time.Duration
// Phase 2: 离线主动消息队列 + 在线状态通知
pendingProactive map[string][]json.RawMessage // userID -> queued messages
aiCoreURL string
internalToken string
}
// SetStore 设置持久化存储 (可选)
@@ -93,6 +99,7 @@ func NewHub() *Hub {
sessions: make(map[string]*SessionState),
iotStopCh: make(chan struct{}),
idleTimeout: 30 * time.Minute, // 默认30分钟
pendingProactive: make(map[string][]json.RawMessage),
}
}
@@ -102,7 +109,7 @@ func (h *Hub) StartIdleCleanup() {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[WS] 闲置会话清理 panic 恢复: %v", r)
logger.Printf("[WS] 闲置会话清理 panic 恢复: %v", r)
}
}()
ticker := time.NewTicker(5 * time.Minute)
@@ -112,7 +119,7 @@ func (h *Hub) StartIdleCleanup() {
h.cleanupIdleSessions()
}
}()
log.Printf("[WS] 闲置会话清理已启动 (超时: %v)", h.idleTimeout)
logger.Printf("[WS] 闲置会话清理已启动 (超时: %v)", h.idleTimeout)
}
// cleanupIdleSessions 标记超时会话为 idle(不删除状态)
@@ -148,7 +155,7 @@ func (h *Hub) cleanupIdleSessions() {
}
if idleCount > 0 {
log.Printf("[WS] 闲置清理: %d 个会话标记为 idle", idleCount)
logger.Printf("[WS] 闲置清理: %d 个会话标记为 idle", idleCount)
}
}
@@ -170,6 +177,58 @@ func (h *Hub) GetAllActiveSessions() []*SessionState {
return result
}
// SetAICoreConfig sets the ai-core URL and internal token for presence notifications.
func (h *Hub) SetAICoreConfig(url, token string) {
h.aiCoreURL = url
h.internalToken = token
}
// QueueProactiveMessage queues a proactive message for offline delivery.
func (h *Hub) QueueProactiveMessage(userID string, msg json.RawMessage) {
h.mu.Lock()
defer h.mu.Unlock()
h.pendingProactive[userID] = append(h.pendingProactive[userID], msg)
// Keep only the most recent 3 messages
if len(h.pendingProactive[userID]) > 3 {
h.pendingProactive[userID] = h.pendingProactive[userID][1:]
}
}
// FlushPendingProactive returns and clears queued proactive messages for a user.
func (h *Hub) FlushPendingProactive(userID string) []json.RawMessage {
h.mu.Lock()
defer h.mu.Unlock()
msgs := h.pendingProactive[userID]
delete(h.pendingProactive, userID)
return msgs
}
// notifyAICorePresence sends a presence update to ai-core.
func (h *Hub) notifyAICorePresence(userID, status, sessionID string) {
if h.aiCoreURL == "" || h.internalToken == "" {
return
}
body, _ := json.Marshal(map[string]string{
"user_id": userID,
"status": status,
"session_id": sessionID,
"timestamp": fmt.Sprintf("%d", time.Now().Unix()),
})
go func() {
req, _ := http.NewRequest("POST", h.aiCoreURL+"/api/v1/internal/presence", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Internal-Token", h.internalToken)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
logger.Printf("[presence] 通知 ai-core 失败: %v", err)
return
}
resp.Body.Close()
logger.Printf("[presence] 通知 ai-core: user=%s status=%s", userID, status)
}()
}
// Run 启动Hub主循环
func (h *Hub) Run() {
for {
@@ -195,10 +254,29 @@ func (h *Hub) Run() {
MessageCount: 0,
}
}
h.mu.Unlock()
// Phase 2: 检测是否为重连 (之前处于离线状态)
wasOffline := len(h.userClients[client.UserID]) == 1 // 刚加入,之前为0
h.mu.Unlock()
log.Printf("[WS] 客户端连接: user=%s session=%s (当前连接数: %d)",
client.UserID, client.SessionID, len(h.clients))
// 重连后推送积压的主动消息
if wasOffline {
pending := h.FlushPendingProactive(client.UserID)
if len(pending) > 0 {
logger.Printf("[proactive] 推送 %d 条积压消息给重连用户 %s", len(pending), client.UserID)
// 只推送最新的一条
go func() {
// small delay for WS connection to stabilize
time.Sleep(500 * time.Millisecond)
h.SendToUser(client.UserID, pending[len(pending)-1])
}()
}
}
// 通知 ai-core 用户上线
h.notifyAICorePresence(client.UserID, "online", client.SessionID)
logger.Printf("[WS] 客户端连接: user=%s session=%s (当前连接数: %d)",
client.UserID, client.SessionID, len(h.clients))
case client := <-h.unregister:
h.mu.Lock()
@@ -233,7 +311,7 @@ func (h *Hub) Run() {
}
h.mu.Unlock()
log.Printf("[WS] 客户端断开: user=%s session=%s (当前连接数: %d)",
logger.Printf("[WS] 客户端断开: user=%s session=%s (当前连接数: %d)",
client.UserID, client.SessionID, len(h.clients))
case message := <-h.broadcast:
@@ -287,7 +365,7 @@ func (h *Hub) Run() {
}
h.mu.Unlock()
log.Printf("[WS] 广播清理 %d 个失效客户端 (当前连接数: %d)",
logger.Printf("[WS] 广播清理 %d 个失效客户端 (当前连接数: %d)",
len(staleClients), len(h.clients))
}
}
@@ -504,12 +582,12 @@ func (h *Hub) StartIoTBroadcast(iotServiceURL string) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[IoT广播] 轮询循环 panic 恢复: %v", r)
logger.Printf("[IoT广播] 轮询循环 panic 恢复: %v", r)
}
}()
h.iotPollLoop()
}()
log.Printf("[IoT广播] 已启动 (IoT服务地址: %s)", iotServiceURL)
logger.Printf("[IoT广播] 已启动 (IoT服务地址: %s)", iotServiceURL)
}
// StopIoTBroadcast 停止 IoT 设备广播
@@ -522,7 +600,7 @@ func (h *Hub) StopIoTBroadcast() {
}
close(h.iotStopCh)
h.iotPollRunning = false
log.Println("[IoT广播] 已停止")
logger.Println("[IoT广播] 已停止")
}
// iotPollLoop IoT 设备轮询循环
@@ -563,7 +641,7 @@ func (h *Hub) pollAndBroadcastIoT() {
devices, err := fetchIoTDevices(url)
if err != nil {
log.Printf("[IoT广播] 获取设备失败: %v", err)
logger.Printf("[IoT广播] 获取设备失败: %v", err)
// 即使失败也发送空列表,让前端知道 IoT 服务状态
devices = []IotDeviceInfo{}
}
@@ -576,7 +654,7 @@ func (h *Hub) pollAndBroadcastIoT() {
data, err := json.Marshal(msg)
if err != nil {
log.Printf("[IoT广播] 消息序列化失败: %v", err)
logger.Printf("[IoT广播] 消息序列化失败: %v", err)
return
}
@@ -586,7 +664,7 @@ func (h *Hub) pollAndBroadcastIoT() {
for _, d := range devices {
deviceNames = append(deviceNames, d.Name)
}
log.Printf("[IoT广播] 已推送 %d 个设备状态到 %d 个客户端: %v", len(devices), h.ClientCount(), deviceNames)
logger.Printf("[IoT广播] 已推送 %d 个设备状态到 %d 个客户端: %v", len(devices), h.ClientCount(), deviceNames)
}
// fetchIoTDevices 从 IoT 调试服务获取设备列表
+40 -20
View File
@@ -25,31 +25,51 @@ type ClientMessage struct {
// ReviewMessage 审查后的结构化消息(动作/聊天分离)
type ReviewMessage struct {
Type string `json:"type"` // "action" | "chat"
Type string `json:"type"` // "action" | "chat"
Content string `json:"content"`
DelayMs int `json:"delay_ms,omitempty"` // ms to wait before sending (0 = immediate)
}
// 服务端 → 客户端消息
type ServerMessage struct {
Type string `json:"type"` // response | segment | audio | error | device_update | pong | history_response | stream_chunk | stream_end | background_thinking | notification | multi_message | stream_segments | review
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"` // 历史消息列表
Devices []IotDeviceInfo `json:"devices,omitempty"` // IoT 设备状态
ThinkingStatus string `json:"thinking_status,omitempty"` // 后台思考状态
Notification *NotificationInfo `json:"notification,omitempty"` // 通知推送
MultiMessage *MultiMessagePayload `json:"multi_message,omitempty"` // 多条消息批量发
ReviewMessages []ReviewMessage `json:"review_messages,omitempty"` // 审查后的结构化消息列表
MsgType string `json:"msg_type,omitempty"` // 消息展示类型: action | chat
Type string `json:"type"` // response | segment | audio | error | device_update | pong | history_response | stream_chunk | stream_end | background_thinking | notification | multi_message | stream_segments | review | thinking | tool_progress | system_info
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"` // 历史消息列表
Devices []IotDeviceInfo `json:"devices,omitempty"` // IoT 设备状态
ThinkingStatus string `json:"thinking_status,omitempty"` // 后台思考状态
ThinkingContent string `json:"thinking_content,omitempty"` // 思考内容 (thinking 类型)
Notification *NotificationInfo `json:"notification,omitempty"` // 通知推
MultiMessage *MultiMessagePayload `json:"multi_message,omitempty"` // 多条消息批量发送
ReviewMessages []ReviewMessage `json:"review_messages,omitempty"` // 审查后的结构化消息列表
MsgType string `json:"msg_type,omitempty"` // 消息展示类型: action | chat | thinking | tool_progress | system_info
ToolProgress *ToolProgressInfo `json:"tool_progress,omitempty"` // 工具执行进度
SystemInfo *SystemInfoPayload `json:"system_info,omitempty"` // 系统通知信息
ProtocolVersion int `json:"protocol_version,omitempty"` // 协议版本
}
// ToolProgressInfo 工具执行进度
type ToolProgressInfo struct {
ToolName string `json:"tool_name"`
Status string `json:"status"` // started, running, completed, failed
Progress float64 `json:"progress"`
Message string `json:"message"`
}
// SystemInfoPayload 系统信息负载
type SystemInfoPayload struct {
Level string `json:"level"` // info, warning, error
Message string `json:"message"`
Action string `json:"action,omitempty"`
}
// MultiMessagePayload 多条消息的容器 (对应昔涟的多消息回复风格)