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 从思考结果中提取记忆(异步执行)