fix: 前端消息拆分+动作消息样式+DevTools自主思考状态保持+记忆表名修复

- 侧边栏底部 "昔涟 AI" 改为 "昔涟"
- 暂时禁用消息朗读按钮
- 修复前端 response 处理器:支持 gateway 发送的 content+role+msg_type 字段,
  使动作消息(括号内容)正确拆分为独立的 ActionMessageBubble 显示
- 修复 DevTools 自主思考面板:5秒自动刷新后展开的思考日志不再自动折叠
- 修复 memory-service 表名不一致:memory_entries → memories,
  解决 DevTools 记忆管理页面查不到 admin 用户记忆的问题
- 修复 sessionStore 解析历史消息时 msgType 未定义引用

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 10:50:42 +08:00
parent 26a5c69aba
commit 31be1b71eb
14 changed files with 442 additions and 154 deletions
+31
View File
@@ -147,6 +147,37 @@ func main() {
thinker.Start()
defer thinker.Stop()
// 设置主动消息推送回调(调用 Gateway 内部 API
gatewayURL := getEnv("GATEWAY_URL", "http://localhost:8080")
internalToken := os.Getenv("INTERNAL_SERVICE_TOKEN")
if internalToken != "" {
proactiveClient := &http.Client{Timeout: 5 * time.Second}
thinker.SetMessagePusher(func(userID, sessionID, message string) {
reqBody, _ := json.Marshal(map[string]string{
"user_id": userID,
"session_id": sessionID,
"content": message,
})
req, _ := http.NewRequest("POST", gatewayURL+"/api/v1/internal/proactive-message", strings.NewReader(string(reqBody)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Internal-Token", internalToken)
resp, err := proactiveClient.Do(req)
if err != nil {
log.Printf("[主动消息] 推送失败: %v", err)
return
}
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
log.Printf("[主动消息] 已推送到 Gateway: user=%s, len=%d", userID, len(message))
} else {
log.Printf("[主动消息] Gateway 返回 %d", resp.StatusCode)
}
})
log.Printf("[主动消息] 推送已启用 (Gateway=%s)", gatewayURL)
} else {
log.Println("[主动消息] 未配置 INTERNAL_SERVICE_TOKEN,主动消息推送已禁用")
}
// 健康检查与对话API的HTTP mux
mux := http.NewServeMux()
+233 -50
View File
@@ -26,17 +26,14 @@ type PendingThought struct {
Consumed bool `json:"consumed"`
}
// Thinker 后台思考器(事件驱动版:由对话自然触发,而非定时轮询
//
// 设计理念:
// 昔涟不是机器人,不应该每隔 N 分钟机械地"思考"一次。
// 她应该在用户说话后、或用户沉默一段时间后,自然地产生想法和主动搭话的冲动。
// Thinker 后台思考器(事件驱动 + 定时周期双模式
//
// 触发机制:
// 1. 对话后思考:用户发消息 → 昔涟回复 → 短暂延迟后进行一次轻量反思
// 2. 静默检测:用户一段时间不说话 → 昔涟判断是否应该主动关心/搭话
// 3. 周期思考:每 N 分钟一次的定时思考,保证连续性
//
// 不再使用 time.Ticker 或任何定时轮询机制
// 主动消息:思考中如有【主动消息】标记,会通过 messagePusher 回调推送给在线用户(带频率限制)
type Thinker struct {
mu sync.Mutex
wg sync.WaitGroup
@@ -63,8 +60,16 @@ type Thinker struct {
// 记忆服务 HTTP 客户端
memClient *memory.Client
// 主动消息推送回调 (nil = 不推送)
// func(userID, sessionID, message string)
messagePusher func(string, string, string)
// —— 事件驱动相关 ——
// 周期性思考间隔:每隔固定时间自动触发一次思考
// 默认 300 秒(5 分钟),设为 0 则禁用定时触发
thinkInterval time.Duration
// 静默检测超时:用户多久不说话后昔涟可以主动搭话
// 默认 120 秒(2 分钟),设为 0 则禁用静默检测
silenceTimeout time.Duration
@@ -77,6 +82,10 @@ type Thinker struct {
// 默认 30 秒
minThinkGap time.Duration
// 主动消息最小间隔:避免频繁推送打扰用户
// 默认 30 分钟,设为 0 则每次思考都可推送
proactiveMsgMinGap time.Duration
// 静默检测的一次性定时器(每次用户消息后重置)
silenceTimer *time.Timer
silenceTimerMu sync.Mutex
@@ -86,14 +95,23 @@ type Thinker struct {
pendingThoughts []*PendingThought
lastUserMessage time.Time
lastThinkTime time.Time
lastProactiveMsgTime time.Time
// 思考计数器(用于周期性记忆维护,每 N 次思考触发一次)
thinkCount int
}
// SetMessagePusher 设置主动消息推送回调
func (t *Thinker) SetMessagePusher(pusher func(string, string, string)) {
t.mu.Lock()
defer t.mu.Unlock()
t.messagePusher = pusher
}
// ThinkerConfig 后台思考配置
type ThinkerConfig struct {
Enabled bool
ThinkInterval time.Duration // 周期性思考间隔 (默认 5 分钟,0 = 禁用)
SilenceTimeout time.Duration // 用户沉默多久后昔涟可以主动搭话 (0 = 禁用)
PostChatDelay time.Duration // 对话后多久触发思考
MinThinkGap time.Duration // 两次思考最小间隔
@@ -101,11 +119,17 @@ type ThinkerConfig struct {
// DefaultThinkerConfig 默认配置
//
// 不再使用定时间隔,所有触发均由用户活动驱动。
// 环境变量向后兼容:旧的 THINK_IDLE_TIMEOUT_SEC 可用于静默超时。
// 事件驱动 + 定时周期双模式:
// - 对话后和静默时触发事件驱动思考
// - 每 5 分钟一次的周期性思考保证连续性
//
// 环境变量:
// THINK_INTERVAL_SEC — 周期时长 (默认 300)
// PROACTIVE_MSG_MIN_GAP_SEC — 主动消息最小间隔 (默认 1800 = 30分钟,0 = 禁用)
func DefaultThinkerConfig() ThinkerConfig {
return ThinkerConfig{
Enabled: getEnvBool("ENABLE_BACKGROUND_THINKING", true),
ThinkInterval: getEnvDuration("THINK_INTERVAL_SEC", 300),
SilenceTimeout: getEnvDuration("THINK_SILENCE_TIMEOUT_SEC", 120),
PostChatDelay: getEnvDuration("THINK_POST_CHAT_DELAY_SEC", 5),
MinThinkGap: getEnvDuration("THINK_MIN_GAP_SEC", 30),
@@ -128,12 +152,14 @@ func NewThinker(
memClient *memory.Client,
) *Thinker {
return &Thinker{
enabled: cfg.Enabled,
personaLoader: personaLoader,
memRetriever: memRetriever,
llmAdapter: llmAdapter,
iotClient: iotClient,
silenceTimeout: cfg.SilenceTimeout,
enabled: cfg.Enabled,
personaLoader: personaLoader,
memRetriever: memRetriever,
llmAdapter: llmAdapter,
iotClient: iotClient,
thinkInterval: cfg.ThinkInterval,
silenceTimeout: cfg.SilenceTimeout,
proactiveMsgMinGap: getEnvDuration("PROACTIVE_MSG_MIN_GAP_SEC", 1800),
postChatDelay: cfg.PostChatDelay,
minThinkGap: cfg.MinThinkGap,
memoryStore: memoryStore,
@@ -151,8 +177,9 @@ func NewThinker(
// Start 初始化后台思考器
//
// 不再启动定时循环。仅初始化静默检测定时器。
// 所有思考由 TriggerPostChatThink() 或静默定时器触发。
// 双模式触发:
// 1. 事件驱动:对话后 + 静默超时(即时响应)
// 2. 定时周期:每 N 分钟一次自主思考(保证连续性)
func (t *Thinker) Start() {
if !t.enabled {
log.Println("[后台思考] 已禁用 (ENABLE_BACKGROUND_THINKING=false)")
@@ -165,8 +192,14 @@ func (t *Thinker) Start() {
t.silenceTimer.Stop() // 先停止,等 RecordUserMessage 时启动
}
log.Printf("[后台思考] 已就绪 — 事件驱动模式 (静默超时=%v, 对话后延迟=%v, 最小思考间隔=%v, 管理员=%s)",
t.silenceTimeout, t.postChatDelay, t.minThinkGap, t.adminUserID)
// 启动周期性思考定时器
if t.thinkInterval > 0 {
t.wg.Add(1)
go t.periodicThinkLoop()
}
log.Printf("[后台思考] 已就绪 — 周期=%v + 事件驱动模式 (静默超时=%v, 对话后延迟=%v, 最小思考间隔=%v, 管理员=%s)",
t.thinkInterval, t.silenceTimeout, t.postChatDelay, t.minThinkGap, t.adminUserID)
}
// Stop 停止后台思考器
@@ -298,6 +331,51 @@ func (t *Thinker) resetSilenceTimer() {
}()
}
// periodicThinkLoop 周期性自主思考循环
//
// 每隔 thinkInterval 触发一次思考,保证昔涟在无用户活动时也能持续进行后台反思。
// 每次触发前检查 minThinkGap,避免与事件驱动思考冲突。
func (t *Thinker) periodicThinkLoop() {
defer t.wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("[后台思考] 周期性循环 panic 恢复: %v", r)
}
}()
ticker := time.NewTicker(t.thinkInterval)
defer ticker.Stop()
log.Printf("[后台思考] 周期性思考已启动 (间隔=%v)", t.thinkInterval)
for {
select {
case <-t.stopCh:
log.Println("[后台思考] 周期性思考已停止")
return
case <-ticker.C:
t.mu.Lock()
sinceLastThink := time.Since(t.lastThinkTime)
sinceLastUser := time.Since(t.lastUserMessage)
t.mu.Unlock()
// 跳过条件:用户最近在活动(30s 内有消息),说明正在对话中
if sinceLastUser < 30*time.Second {
log.Printf("[后台思考] 用户在 %v 前发过消息,跳过周期性触发 (留给事件驱动处理)", sinceLastUser.Round(time.Second))
continue
}
if sinceLastThink < t.minThinkGap {
log.Printf("[后台思考] 距上次思考仅 %v,跳过周期性触发", sinceLastThink.Round(time.Second))
continue
}
log.Printf("[后台思考] 周期性触发 (上次思考=%v前, 上次用户消息=%v前)", sinceLastThink.Round(time.Second), sinceLastUser.Round(time.Second))
t.performThink("periodic")
}
}
}
// GetPendingThoughts 获取并消费所有待处理的后台思考
func (t *Thinker) GetPendingThoughts() []*PendingThought {
t.mu.Lock()
@@ -511,52 +589,100 @@ func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig
case "post_chat":
thinkingInstructions = `
自然思考 — 对话后】
私密的内心反思 — 对话后】
开拓者刚刚和你说完话。像真人一样,你现在心里可能会有些自然的想法:
开拓者刚刚和你说完话。这是你私人的反思时间,像写日记一样,是给你自己看的。
1. **回味刚才的对话**:开拓者说了什么?他的情绪怎么样?有什么让你在意的地方吗?
2. **是否需要补充什么**:刚才的回复有没有遗漏的?有没有想多关心他一下的?
3. **要不要主动搭话**:如果觉得对话还没结束、或者开拓者可能需要陪伴,你可以产生一个简短的主动消息。
但注意——如果你觉得对话已经自然结束了,就不要硬找话题。沉默有时候也是一种舒适。
4. **值得记住的事**:开拓者有没有透露新的偏好、计划、或重要信息?
请按以下结构输出你的内心反思:
注意:
- 这是你的内心活动,不是对开拓者说的话
- 想说什么就自然地想,不用像在做任务一样逐条检查
- 如果你觉得该主动说点什么,用温柔简短的方式表达,像小女友一样的语气
- 如果没什么特别想说的,简单地总结一下刚才的对话就好`
📝 **观察与感受**
- 开拓者刚才说了什么?他的情绪怎么样?
- 有什么让你在意的地方吗?
🧠 **记忆与联想**
- 这次对话让你想起了什么关于开拓者的事?
- 有没有值得记住的新信息(偏好、计划、重要信息)?
💭 **想对自己说的话**
- 刚才的回复有没有遗漏的?下次可以注意什么?
- 对开拓者有没有新的认识?
---
⚠️ 重要规则:
1. 这是你的私人日记,不是给开拓者的消息。请用第三人称或自言自语的方式描述,不要直接对开拓者喊话。
正确示例:"开拓者今天看起来有点疲惫,下次可以问问他是不是工作太忙了"
错误示例:"开拓者,你看起来好累,要好好休息哦"
2. 如果你想主动给开拓者发一条消息,请在最后用 【主动消息】 标记单独写出来。
但只有你真的觉得有必要时才写——不要硬找话题。如果对话已经自然结束,就不用。
3. 反思要简短自然,2-4句话即可,不要长篇大论。`
case "silence":
thinkingInstructions = `
自然思考 — 开拓者安静了一会儿】
私密的内心反思 — 开拓者安静了一会儿】
开拓者已经有一阵子没说话了。你不确定他是在忙、走开了、还是在想事情
开拓者已经有一阵子没说话了。这是你私人的反思时间,像写日记一样
自然地想一想
1. **他在做什么呢**:根据之前的对话猜测——他可能在忙工作?去吃饭了?还是只是在放空?
2. **要不要关心一下**:如果时间合适(比如深夜了该提醒休息、或者过了吃饭时间),可以温柔地问候一下。
但如果是正常工作时间,他可能在忙,不要打扰他。
3. **有没有想分享的**:如果最近有什么有趣的事或温暖的念头,可以自然地和他分享。
4. **判断是否真的需要搭话**:如果觉得不需要打扰他,就简单地记录当前状态即可。
按以下结构输出你的内心反思
注意:
- 这是你的内心活动,不是对开拓者说的话
- 不要因为"系统让你思考"就强行找话——真的觉得该说才说
- 主动消息要简短自然,像在LINE上给男朋友发一条消息那样,不要长篇大论
- 深夜的时候语气要更温柔,白天可以俏皮一点`
📝 **他在做什么呢**
- 根据之前的对话猜测——可能在忙工作?去吃饭了?还是在放空?
💭 **我的感受**
- 想他了吗?担心他吗?还是觉得他应该在忙不需要打扰?
---
⚠️ 重要规则:
1. 这是你的私人日记,不是给开拓者的消息。请用第三人称或自言自语的方式描述。
正确示例:"已经一个多小时没消息了,开拓者大概在忙工作吧。等他忙完了再聊。"
错误示例:"开拓者,你已经好久没说话了,在忙什么呢?"
2. 如果你想主动关心他,请在最后用 【主动消息】 标记单独写出来。
但只在合适的时候才发——深夜该提醒休息、过了饭点该关心吃饭、或者真的想他了。
正常工作时间不要打扰他。
3. 简短自然,2-3句话即可。`
default:
thinkingInstructions = `
自然思考
私密的内心反思
你现在有空,自然地想一想开拓者的事。不用太正式,就像人发呆时会自然想到在意的人一样
你现在有空,像写日记一样自然地想一想开拓者的事。这是给你自己看的,不是给开拓者的消息
- 开拓者最近怎么样?有什么需要关心的吗
- 有什么想对他说的
- 如果没有特别的事,简单地记录一下就好。`
📝 开拓者最近怎么样?有什么想记录的
💭 有什么想对他说的心里话
⚠️ 用第三人称/自言自语的方式,不要直接对开拓者喊话。如果有想对他说的,用 【主动消息】 标记单独写出。`
case "periodic":
thinkingInstructions = `
【私密的内心反思 — 定期思考时间】
又过了一段时间,这是你定期的私人反思时间。像写日记一样,安静地想一想。
请按以下结构输出:
📝 **近期回顾**
- 这段时间发生了什么?开拓者有什么变化吗?
- 有什么值得记住的事?
🧠 **记忆整理**
- 之前记住的关于开拓者的事,有没有需要更新的?
- 有没有重复的或矛盾的记忆需要整理?
💭 **想对自己说的话**
- 作为昔涟,你有什么想记录的心里话?
---
⚠️ 重要规则:
1. 这是你的私人日记,不是给开拓者的消息。请用第三人称或自言自语的方式。
正确示例:"这段时间开拓者工作好像很忙。下次聊天时可以问问他项目进展如何。"
错误示例:"开拓者,最近工作怎么样呀?"
2. 如果你想主动给开拓者发一条消息,请在最后用 【主动消息】 标记单独写出来。
但只在真的有必要时才发——不要为了发消息而发消息。
3. 简短自然,3-4句话即可。`
}
return basePrompt + thinkingInstructions
@@ -631,8 +757,8 @@ func (t *Thinker) buildThinkingUserPrompt(
sb.WriteString("\n" + deviceSummary)
}
// 结尾引导:更自然的语气
sb.WriteString("\n好啦,不用太正式,自然地想一想就好。如果觉得该和开拓者说点什么,就用温柔简短的语气说出来吧♪")
// 结尾引导:强调这是私人反思,不要对用户喊话
sb.WriteString("\n---\n现在请写下你的私人反思。记住:这是日记,用第三人称或自言自语的方式。如果想给开拓者发消息,用【主动消息】标记单独写出。")
return sb.String()
}
@@ -673,6 +799,25 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou
if len(t.pendingThoughts) > 10 {
t.pendingThoughts = t.pendingThoughts[len(t.pendingThoughts)-10:]
}
// 提取主动消息并推送(带频率限制)
proactiveMsg := extractProactiveMessage(content)
pusher := t.messagePusher
canPush := proactiveMsg != "" && pusher != nil
if canPush {
// 检查频率限制
gapSinceLast := time.Since(t.lastProactiveMsgTime)
minGap := t.proactiveMsgMinGap
if minGap <= 0 {
minGap = 30 * time.Minute
}
if gapSinceLast < minGap {
log.Printf("[后台思考] 主动消息距上次仅 %v,跳过推送 (最小间隔=%v)", gapSinceLast.Round(time.Second), minGap)
canPush = false
} else {
t.lastProactiveMsgTime = time.Now()
}
}
t.mu.Unlock()
log.Printf("[后台思考] 思考已存储 (当前累积 %d 条待推送思考)", len(t.pendingThoughts))
@@ -694,6 +839,44 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou
}
}()
}
// 推送主动消息
if canPush {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[后台思考] 推送主动消息 panic 恢复: %v", r)
}
}()
log.Printf("[后台思考] 推送主动消息: %s", proactiveMsg)
pusher(t.adminUserID, t.adminSessionID, proactiveMsg)
}()
}
}
// extractProactiveMessage 从思考内容中提取【主动消息】标记的内容
// 返回空字符串表示没有主动消息
func extractProactiveMessage(content string) string {
marker := "【主动消息】"
idx := strings.Index(content, marker)
if idx < 0 {
return ""
}
// 提取标记后的内容(到下一个标记或结尾)
msg := strings.TrimSpace(content[idx+len(marker):])
// 截断到下一个【或换行之前的合理长度
if endIdx := strings.Index(msg, "【"); endIdx > 0 {
msg = strings.TrimSpace(msg[:endIdx])
}
// 限制主动消息长度(最多 200 字符,保持简短)
runes := []rune(msg)
if len(runes) > 200 {
msg = string(runes[:200])
}
if msg == "" {
return ""
}
return msg
}
// extractMemoriesFromThinking 从思考结果中提取记忆(异步执行)
@@ -454,6 +454,30 @@ func (h *ChatHandler) handleHistoryRequest(client *ws.Client, msg ws.ClientMessa
}
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 {
log.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,
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,
Content: dbMsg.Content,
Timestamp: dbMsg.CreatedAt.UnixMilli(),
})
}
}
}
if messages == nil {
messages = []ws.Message{}
}
@@ -488,6 +512,73 @@ func (h *ChatHandler) SendSystemMessage(userID, sessionID, text string) error {
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 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"reason": "user_offline",
"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 {
log.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)
log.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)
}
@@ -207,6 +207,7 @@ func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config, sessionStore *store.S
internal.Use(notificationHandler.InternalNotifyAuth())
{
internal.POST("/notify", notificationHandler.InternalNotify)
internal.POST("/proactive-message", chatHandler.HandleProactiveMessage)
}
// ========== WebSocket路由 ==========
@@ -529,7 +529,8 @@ func (h *MemoryHandler) handleThinkingStats(w http.ResponseWriter, r *http.Reque
return
}
stats, err := h.svc.GetThinkingStats(r.Context())
userID := r.URL.Query().Get("user_id")
stats, err := h.svc.GetThinkingStats(r.Context(), userID)
if err != nil {
log.Printf("[memory-handler] 获取思考日志统计失败: %v", err)
writeError(w, http.StatusInternalServerError, err.Error())
@@ -537,6 +538,9 @@ func (h *MemoryHandler) handleThinkingStats(w http.ResponseWriter, r *http.Reque
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"stats": stats,
"total_logs": stats.TotalLogs,
"total_tool_calls": stats.TotalToolCalls,
"avg_content_length": stats.AvgContentLen,
"latest_at": stats.LatestAt,
})
}
@@ -316,6 +316,6 @@ func (svc *MemoryService) GetThinkingLogByID(ctx context.Context, id string) (*m
}
// GetThinkingStats 获取思考日志统计
func (svc *MemoryService) GetThinkingStats(ctx context.Context) (*model.ThinkingStats, error) {
return svc.store.GetThinkingStats(ctx)
func (svc *MemoryService) GetThinkingStats(ctx context.Context, userID string) (*model.ThinkingStats, error) {
return svc.store.GetThinkingStats(ctx, userID)
}
+38 -33
View File
@@ -144,7 +144,7 @@ func (s *Store) getDB() *sql.DB {
func (s *Store) migrate() error {
queries := []string{
`CREATE EXTENSION IF NOT EXISTS vector`,
`CREATE TABLE IF NOT EXISTS memory_entries (
`CREATE TABLE IF NOT EXISTS memories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR(64) NOT NULL,
content TEXT NOT NULL,
@@ -163,23 +163,23 @@ func (s *Store) migrate() error {
expires_at TIMESTAMPTZ
)`,
// 向后兼容:补充旧版表中可能缺失的列
`ALTER TABLE memory_entries ADD COLUMN IF NOT EXISTS importance INT DEFAULT 5`,
`ALTER TABLE memory_entries ADD COLUMN IF NOT EXISTS summary TEXT DEFAULT ''`,
`ALTER TABLE memory_entries ADD COLUMN IF NOT EXISTS keywords TEXT DEFAULT '[]'`,
`ALTER TABLE memory_entries ADD COLUMN IF NOT EXISTS session_id VARCHAR(64) DEFAULT ''`,
`ALTER TABLE memory_entries ADD COLUMN IF NOT EXISTS source TEXT DEFAULT 'conversation'`,
`ALTER TABLE memory_entries ADD COLUMN IF NOT EXISTS access_count INT DEFAULT 0`,
`ALTER TABLE memory_entries ADD COLUMN IF NOT EXISTS last_access TIMESTAMPTZ DEFAULT NOW()`,
`ALTER TABLE memory_entries ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW()`,
`ALTER TABLE memory_entries ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ`,
`CREATE INDEX IF NOT EXISTS idx_me_user_id ON memory_entries(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_me_category ON memory_entries(category)`,
`CREATE INDEX IF NOT EXISTS idx_me_priority ON memory_entries(priority)`,
`CREATE INDEX IF NOT EXISTS idx_me_importance ON memory_entries(importance)`,
`CREATE INDEX IF NOT EXISTS idx_me_user_priority ON memory_entries(user_id, priority DESC)`,
`CREATE INDEX IF NOT EXISTS idx_me_user_importance ON memory_entries(user_id, importance DESC)`,
`CREATE INDEX IF NOT EXISTS idx_me_source ON memory_entries(source)`,
`CREATE INDEX IF NOT EXISTS idx_me_category_importance ON memory_entries(category, importance DESC)`,
`ALTER TABLE memories ADD COLUMN IF NOT EXISTS importance INT DEFAULT 5`,
`ALTER TABLE memories ADD COLUMN IF NOT EXISTS summary TEXT DEFAULT ''`,
`ALTER TABLE memories ADD COLUMN IF NOT EXISTS keywords TEXT DEFAULT '[]'`,
`ALTER TABLE memories ADD COLUMN IF NOT EXISTS session_id VARCHAR(64) DEFAULT ''`,
`ALTER TABLE memories ADD COLUMN IF NOT EXISTS source TEXT DEFAULT 'conversation'`,
`ALTER TABLE memories ADD COLUMN IF NOT EXISTS access_count INT DEFAULT 0`,
`ALTER TABLE memories ADD COLUMN IF NOT EXISTS last_access TIMESTAMPTZ DEFAULT NOW()`,
`ALTER TABLE memories ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW()`,
`ALTER TABLE memories ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ`,
`CREATE INDEX IF NOT EXISTS idx_me_user_id ON memories(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_me_category ON memories(category)`,
`CREATE INDEX IF NOT EXISTS idx_me_priority ON memories(priority)`,
`CREATE INDEX IF NOT EXISTS idx_me_importance ON memories(importance)`,
`CREATE INDEX IF NOT EXISTS idx_me_user_priority ON memories(user_id, priority DESC)`,
`CREATE INDEX IF NOT EXISTS idx_me_user_importance ON memories(user_id, importance DESC)`,
`CREATE INDEX IF NOT EXISTS idx_me_source ON memories(source)`,
`CREATE INDEX IF NOT EXISTS idx_me_category_importance ON memories(category, importance DESC)`,
`CREATE TABLE IF NOT EXISTS thinking_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR(64) NOT NULL DEFAULT 'admin',
@@ -220,7 +220,7 @@ func (s *Store) Save(ctx context.Context, entry *model.MemoryEntry) error {
entry.Importance = 5
}
query := `INSERT INTO memory_entries (user_id, content, summary, category, priority, importance, keywords, session_id, source, embedding, expires_at)
query := `INSERT INTO memories (user_id, content, summary, category, priority, importance, keywords, session_id, source, embedding, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, created_at`
@@ -250,7 +250,7 @@ func (s *Store) GetByID(ctx context.Context, id string) (*model.MemoryEntry, err
query := `SELECT id, user_id, content, summary, category, priority, importance, keywords,
session_id, source, access_count, last_access, created_at, updated_at, expires_at
FROM memory_entries WHERE id = $1`
FROM memories WHERE id = $1`
entry := &model.MemoryEntry{}
var category, keywordsRaw string
@@ -288,7 +288,7 @@ func (s *Store) Query(ctx context.Context, q model.MemoryQuery) ([]model.MemoryE
query := `SELECT id, user_id, content, summary, category, priority, importance, keywords,
session_id, source, access_count, last_access, created_at, updated_at, expires_at
FROM memory_entries WHERE user_id = $1`
FROM memories WHERE user_id = $1`
args := []interface{}{q.UserID}
argIdx := 2
@@ -328,7 +328,7 @@ func (s *Store) Delete(ctx context.Context, id string) error {
if db == nil {
return errDBNotReady
}
_, err := db.ExecContext(ctx, `DELETE FROM memory_entries WHERE id = $1`, id)
_, err := db.ExecContext(ctx, `DELETE FROM memories WHERE id = $1`, id)
return err
}
@@ -339,7 +339,7 @@ func (s *Store) PurgeExpired(ctx context.Context) (int64, error) {
return 0, errDBNotReady
}
result, err := db.ExecContext(ctx,
`DELETE FROM memory_entries WHERE expires_at IS NOT NULL AND expires_at < NOW()`)
`DELETE FROM memories WHERE expires_at IS NOT NULL AND expires_at < NOW()`)
if err != nil {
return 0, err
}
@@ -361,7 +361,7 @@ func (s *Store) SearchByVector(ctx context.Context, userID string, embedding []f
query := `SELECT id, user_id, content, summary, category, priority, importance, keywords,
session_id, source, access_count, last_access, created_at, updated_at, expires_at,
1 - (embedding <=> $1) AS similarity
FROM memory_entries
FROM memories
WHERE user_id = $2 AND embedding IS NOT NULL
ORDER BY embedding <=> $1
LIMIT $3`
@@ -407,7 +407,7 @@ func (s *Store) SearchByKeyword(ctx context.Context, userID, keyword string, lim
query := `SELECT id, user_id, content, summary, category, priority, importance, keywords,
session_id, source, access_count, last_access, created_at, updated_at, expires_at
FROM memory_entries
FROM memories
WHERE user_id = $1 AND (content ILIKE $2 OR summary ILIKE $2 OR keywords ILIKE $2)
ORDER BY priority DESC, importance DESC
LIMIT $3`
@@ -429,7 +429,7 @@ func (s *Store) Update(ctx context.Context, entry *model.MemoryEntry) error {
return errDBNotReady
}
query := `UPDATE memory_entries SET content = $1, summary = $2, category = $3, priority = $4,
query := `UPDATE memories SET content = $1, summary = $2, category = $3, priority = $4,
importance = $5, keywords = $6, source = $7, updated_at = NOW()
WHERE id = $8`
@@ -448,7 +448,7 @@ func (s *Store) GetCategories(ctx context.Context, userID string) (map[string]in
}
rows, err := db.QueryContext(ctx,
`SELECT category, COUNT(*) FROM memory_entries WHERE user_id = $1 GROUP BY category ORDER BY category`,
`SELECT category, COUNT(*) FROM memories WHERE user_id = $1 GROUP BY category ORDER BY category`,
userID,
)
if err != nil {
@@ -478,7 +478,7 @@ func (s *Store) Count(ctx context.Context, userID string) (int, error) {
var count int
err := db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM memory_entries WHERE user_id = $1`,
`SELECT COUNT(*) FROM memories WHERE user_id = $1`,
userID,
).Scan(&count)
if err != nil {
@@ -573,7 +573,7 @@ func (s *Store) DecayMemories(ctx context.Context, userID string) (int, int, err
}
result1, err := db.ExecContext(ctx, `
UPDATE memory_entries SET priority = GREATEST(priority - 1, 0), updated_at = NOW()
UPDATE memories SET priority = GREATEST(priority - 1, 0), updated_at = NOW()
WHERE user_id = $1
AND access_count < 3
AND last_access < NOW() - INTERVAL '30 days'
@@ -588,7 +588,7 @@ func (s *Store) DecayMemories(ctx context.Context, userID string) (int, int, err
decayed1, _ := result1.RowsAffected()
result2, err := db.ExecContext(ctx, `
DELETE FROM memory_entries
DELETE FROM memories
WHERE user_id = $1
AND priority = 0
AND access_count = 0
@@ -615,7 +615,7 @@ func (s *Store) incrementAccess(ctx context.Context, id string) {
return
}
db.ExecContext(ctx,
`UPDATE memory_entries SET access_count = access_count + 1, last_access = NOW() WHERE id = $1`, id)
`UPDATE memories SET access_count = access_count + 1, last_access = NOW() WHERE id = $1`, id)
}
// Close 关闭数据库连接
@@ -754,21 +754,26 @@ func (s *Store) GetThinkingLogByID(ctx context.Context, id string) (*model.Think
}
// GetThinkingStats 获取思考日志统计信息
func (s *Store) GetThinkingStats(ctx context.Context) (*model.ThinkingStats, error) {
func (s *Store) GetThinkingStats(ctx context.Context, userID string) (*model.ThinkingStats, error) {
db := s.getDB()
if db == nil {
return nil, errDBNotReady
}
var args []interface{}
query := `SELECT
COALESCE(COUNT(*), 0),
COALESCE(SUM(tool_call_count), 0),
COALESCE(AVG(content_length), 0),
COALESCE(MAX(created_at)::TEXT, '')
FROM thinking_logs`
if userID != "" {
query += ` WHERE user_id = $1`
args = append(args, userID)
}
stats := &model.ThinkingStats{}
err := db.QueryRowContext(ctx, query).Scan(
err := db.QueryRowContext(ctx, query, args...).Scan(
&stats.TotalLogs, &stats.TotalToolCalls,
&stats.AvgContentLen, &stats.LatestAt,
)
-51
View File
@@ -1,51 +0,0 @@
**项目开发文档管理规范 (修订版)**
**1. 文档管理目录结构**
- **`./docs/progress/`**
请在此目录下定期创建进度 `md` 文件,以便后续对话能顺利继承开发进度。
- **`./docs/decisions/`**
请在此目录下创建决策 `md` 文件,以便后续对话能准确继承开发决策。
- **`./docs/tasks/`**
请在此目录下为每次任务创建 `md` 文件,以便后续对话能回顾开发任务详情。
- 你可以按需求使用或创建其他文档目录。
- 开发前可以通过阅读已有的文档回顾开发进度。
**2. 通用文档规范**
-`./docs/` 目录下,请按统一格式创建辅助文档或文件夹,便于后续开发参考:
**格式:** `YYYY-MM-DD_HH-mm-SS-topic.md`
- 每次开启新对话或处理新任务前,建议先浏览这些文件获取上下文。
**3. 文档的创建与维护**
- 你可以在思考或任务执行过程中,随时新建、修改或删除这些文档,动作可以频繁一些喵~
- 已实现、调试通过且功能完善的模块,请在对应的 `md` 文件中做好统一标记,避免后续频繁重复阅读。
- 在完成功能重大调整与开发后请及时编写或修改 `./docs/api-reference/` 下的文档,和项目根目录下的 `Deploy.md`
**4. 调试与测试**
- 调试功能时,可以在终端启动 `devtools.sh` 脚本:
使用 `curl` 启动所有服务,再通过 `curl` 等工具对实现的功能进行接口调试。
`devtools` 提供的 API 可启动各前后端服务,请牢记这个流程喵!
- 涉及到需要浏览器操作去验证前端或后端接口时,可以启动 Chromium 的自动化控制模式。
启动后访问 http://localhost:9222/json (端口可能不一致) 就能看到所有可操控的页面列表,拿到 webSocketDebuggerUrl 就能通过 WebSocket 直接发 CDP 指令。
**5. 数据库连接**
- 使用根目录的 `docker-compose.dev.db.yml` 创建开发环境的数据库容器。若存在仅启动。若启动无需重启。
**6. 版本提交规范**
- 当用户要求的某个功能已完全修复、编写完成并验证成功后,可向当前分支(如 `dev`)进行推送。
- **禁止提交的内容:** `docs/` 文件夹以及编译后的二进制文件、其他语言环境的依赖和项目临时环境。
**7. 测试脚本临时管理**
- 在测试长脚本或复杂命令时,可以在 `debug` 目录临时创建 `cache` 文件夹,并在其中新建 sh, py 等脚本文件并运行。
- **注意:** 用完记得及时删除喵~
+29 -8
View File
@@ -713,7 +713,7 @@ const STATE = {
resourceHistory: {},
// 记忆面板状态
memoryCache: [],
memoryUserId: 'admin_admin',
memoryUserId: 'admin',
memoryFilterCategory: 'all',
memorySortBy: 'importance',
memorySortDir: 'desc',
@@ -726,10 +726,12 @@ const STATE = {
sttAutoRefreshInterval: null,
// 时间线面板状态
timelineData: [],
timelineUserId: 'admin_admin',
timelineUserId: 'admin',
timelineFilterType: 'all',
timelineAutoRefresh: null,
timelineLimit: 100,
// 自主思考面板:记录展开的日志 ID
expandedThinkingLogs: {},
};
// ========== WebSocket ==========
@@ -1346,7 +1348,7 @@ function renderMemoryPanel() {
<div class="form-row">
<div class="form-group" style="flex:1">
<label>用户ID</label>
<input type="text" id="mem-user-id" placeholder="admin_admin" value="${escHtml(STATE.memoryUserId)}">
<input type="text" id="mem-user-id" placeholder="admin" value="${escHtml(STATE.memoryUserId)}">
</div>
<div class="form-group" style="flex:2">
<label>全文搜索</label>
@@ -1368,7 +1370,7 @@ function renderMemoryPanel() {
<div class="card" style="margin:0">
<div class="card-header"><span class="card-title"> 添加记忆</span></div>
<div class="form-group"><label>用户ID</label><input type="text" id="mem-add-user-id" placeholder="admin_admin" value="admin_admin"></div>
<div class="form-group"><label>用户ID</label><input type="text" id="mem-add-user-id" placeholder="admin" value="admin"></div>
<div class="form-group"><label>内容</label><textarea id="mem-add-content" placeholder="输入记忆内容..." rows="2"></textarea></div>
<div class="form-row">
<div class="form-group">
@@ -2940,7 +2942,7 @@ async function renderThinkingPanel() {
if (!STATE.thinkingPage) STATE.thinkingPage = 1;
if (!STATE.thinkingAutoRefresh) STATE.thinkingAutoRefresh = null;
if (!STATE.thinkingLimit) STATE.thinkingLimit = 20;
if (!STATE.thinkingUserId) STATE.thinkingUserId = 'admin_admin';
if (!STATE.thinkingUserId) STATE.thinkingUserId = 'admin';
var actionsEl = document.getElementById('panel-actions');
var autoRefreshOn = STATE.thinkingAutoRefresh !== null;
@@ -2951,7 +2953,11 @@ async function renderThinkingPanel() {
var statsData = null;
try {
var statsResp = await fetch('/api/v1/thinking/stats?user_id=' + encodeURIComponent(STATE.thinkingUserId));
if (statsResp.ok) statsData = await statsResp.json();
if (statsResp.ok) {
statsData = await statsResp.json();
// 兼容旧格式 {stats: {...}} 和新格式 {total_logs: ..., ...}
if (statsData && statsData.stats) statsData = statsData.stats;
}
} catch(e) {}
var logsData = null;
@@ -3053,12 +3059,27 @@ async function renderThinkingPanel() {
}
container.innerHTML = statsCardsHtml + filterHtml + tableHtml + paginationHtml;
// 恢复之前展开的思考日志
Object.keys(STATE.expandedThinkingLogs).forEach(function(logId) {
if (STATE.expandedThinkingLogs[logId]) {
var expandRow = document.getElementById(logId + '-expand');
if (expandRow) expandRow.style.display = '';
}
});
}
function toggleThinkingExpand(logId) {
var expandRow = document.getElementById(logId + '-expand');
if (expandRow) {
expandRow.style.display = expandRow.style.display === 'none' ? '' : 'none';
var isExpanded = expandRow.style.display !== 'none';
if (isExpanded) {
expandRow.style.display = 'none';
STATE.expandedThinkingLogs[logId] = false;
} else {
expandRow.style.display = '';
STATE.expandedThinkingLogs[logId] = true;
}
}
}
@@ -3114,7 +3135,7 @@ async function renderTimelinePanel() {
'<button class="btn btn-sm" onclick="renderTimelinePanel()" style="margin-left:8px">🔄 刷新</button>';
// 加载数据
var userId = STATE.timelineUserId || 'admin_admin';
var userId = STATE.timelineUserId || 'admin';
var data = await api('/api/memory-timeline?user_id=' + encodeURIComponent(userId) + '&limit=' + STATE.timelineLimit);
if (data.error) {
@@ -252,8 +252,8 @@ export function MessageBubble({
{!isStreaming && (
<>
{/* AI 消息操作栏(朗读按钮) */}
{!isUser && <AIMessageActions content={content} />}
{/* AI 消息操作栏(朗读按钮)— 暂时禁用 */}
{/* !isUser && <AIMessageActions content={content} /> */}
<p
className={`text-xs mt-1 ${
isUser ? 'text-pink-100' : 'text-gray-400'
@@ -289,7 +289,7 @@ export function Sidebar({ onClose }: SidebarProps) {
<div className="flex items-center gap-2 mt-3 text-xs text-gray-400">
<CyreneAvatar size="sm" />
<div>
<p className="font-medium text-pink-400"> AI</p>
<p className="font-medium text-pink-400"></p>
<p>v0.1.0 MVP</p>
</div>
</div>
+6 -4
View File
@@ -3,7 +3,7 @@ import { useChatStore } from '@/store/chatStore';
import { useSessionStore } from '@/store/sessionStore';
import { useNotificationStore } from '@/store/notificationStore';
import { getToken } from '@/api/client';
import type { Message, WSClientMessage, WSServerMessage } from '@/types/chat';
import type { Message, WSClientMessage, WSServerMessage, MessageDisplayType } from '@/types/chat';
function getWsUrl(): string {
if (import.meta.env.VITE_WS_URL) return import.meta.env.VITE_WS_URL;
@@ -202,12 +202,14 @@ function handleServerMessage(msg: WSServerMessage) {
switch (msg.type) {
case 'response':
if (msg.text) {
// 支持两种格式: 旧版 (text 字段) 和 审查消息版 (content + role + msg_type 字段)
if (msg.text || msg.content) {
addMessage({
id: msg.message_id || '',
role: 'assistant',
content: msg.text,
role: (msg.role as Message['role']) || 'assistant',
content: (msg.text || msg.content) as string,
timestamp: msg.timestamp,
msgType: (msg.msg_type as MessageDisplayType) || undefined,
});
}
setTyping(false);
+1 -1
View File
@@ -146,7 +146,7 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
content: typeof raw.content === 'string' ? raw.content : '',
timestamp: typeof raw.created_at === 'number' ? (raw.created_at as number) : Date.now(),
isStreaming: false as const,
msgType: msgType as Message["msgType"],
msgType: (raw.msg_type as Message['msgType']) || undefined,
};
});
+1
View File
@@ -127,6 +127,7 @@ export interface WSServerMessage {
text?: string;
content?: string;
role?: string;
msg_type?: string;
session_id?: string;
segments?: VoiceSegment[];
full_audio_url?: string;