fix: 第四轮调试 — 回复去重/消息时序/UI布局/自主思考深度优化 + 文档重整
后端修复: - main.go: 恢复 /api/v1/chat 路由中丢失的 handleChat 调用 (空响应回归) - orchestrator.go: splitChatByLines 改为双换行分割, 避免单换行误拆 - chat_handler.go: multi_message 增加 !hasReview 守卫, 消息延迟 200→800ms - thinker.go: RecordUserMessage 追踪活跃会话ID, 推送主动消息到正确会话 - thinker.go: 增强思考提示词 — 禁止在用户休息/离开时发送主动消息 前端修复: - useWebSocket.ts: stream_segments 不再创建消息气泡, 消除重复回复 - MessageBubble.tsx: 动作消息居左对齐无头像, 时间戳移至气泡外侧 hover 显示 - ChatInput.tsx: 昔涟输入提示移至输入框上方, 波点动画效果 - MessageList/TypingIndicator/ChatContainer: 清理冗余 isTyping 传递 - MemoryPanel.tsx: 新增记忆面板组件 文档重整: - docs/debug/ → docs/debug_log/ 重命名统一 - 新增 debug_log/README.md 索引 - .gitignore: 新增 android/ 排除规则 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -47,7 +47,6 @@ type Thinker struct {
|
||||
|
||||
// 记忆管理
|
||||
memoryStore *memory.Store
|
||||
memoryExtractor *memory.Extractor
|
||||
|
||||
// 工具调用
|
||||
toolRegistry *tools.Registry
|
||||
@@ -56,6 +55,7 @@ type Thinker struct {
|
||||
convStore *ctxbuild.ConversationStore
|
||||
adminUserID string
|
||||
adminSessionID string
|
||||
activeSessionID string // 当前活跃的前端会话 ID(随用户消息更新)
|
||||
|
||||
// 记忆服务 HTTP 客户端
|
||||
memClient *memory.Client
|
||||
@@ -144,7 +144,6 @@ func NewThinker(
|
||||
llmAdapter *llm.Adapter,
|
||||
iotClient *tools.IoTClient,
|
||||
memoryStore *memory.Store,
|
||||
memoryExtractor *memory.Extractor,
|
||||
toolRegistry *tools.Registry,
|
||||
convStore *ctxbuild.ConversationStore,
|
||||
adminUserID string,
|
||||
@@ -163,7 +162,7 @@ func NewThinker(
|
||||
postChatDelay: cfg.PostChatDelay,
|
||||
minThinkGap: cfg.MinThinkGap,
|
||||
memoryStore: memoryStore,
|
||||
memoryExtractor: memoryExtractor,
|
||||
|
||||
toolRegistry: toolRegistry,
|
||||
convStore: convStore,
|
||||
adminUserID: adminUserID,
|
||||
@@ -216,14 +215,18 @@ func (t *Thinker) Stop() {
|
||||
log.Println("[后台思考] 已停止")
|
||||
}
|
||||
|
||||
// RecordUserMessage 记录用户活动时间,并重置静默检测定时器
|
||||
// RecordUserMessage 记录用户活动时间、活跃会话,并重置静默检测定时器
|
||||
//
|
||||
// 每次用户发消息时调用。这会:
|
||||
// 1. 更新 lastUserMessage 时间戳
|
||||
// 2. 重置静默检测的一次性定时器(如果启用)
|
||||
func (t *Thinker) RecordUserMessage() {
|
||||
// 2. 记录当前活跃的前端会话 ID(用于对话上下文检索和主动消息推送)
|
||||
// 3. 重置静默检测的一次性定时器(如果启用)
|
||||
func (t *Thinker) RecordUserMessage(sessionID string) {
|
||||
t.mu.Lock()
|
||||
t.lastUserMessage = time.Now()
|
||||
if sessionID != "" {
|
||||
t.activeSessionID = sessionID
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
// 重置静默检测定时器
|
||||
@@ -446,12 +449,20 @@ func (t *Thinker) performThink(triggerReason string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 获取管理员对话历史
|
||||
// 3. 获取当前活跃会话的对话历史(优先活跃会话,回退到管理员主会话)
|
||||
var convHistory []model.LLMMessage
|
||||
if t.convStore != nil && t.adminSessionID != "" {
|
||||
convHistory = t.convStore.GetHistory(t.adminSessionID, 30)
|
||||
if len(convHistory) > 0 {
|
||||
log.Printf("[后台思考] 加载管理员对话历史 %d 条", len(convHistory))
|
||||
if t.convStore != nil {
|
||||
t.mu.Lock()
|
||||
sessionID := t.activeSessionID
|
||||
if sessionID == "" {
|
||||
sessionID = t.adminSessionID
|
||||
}
|
||||
t.mu.Unlock()
|
||||
if sessionID != "" {
|
||||
convHistory = t.convStore.GetHistory(sessionID, 30)
|
||||
if len(convHistory) > 0 {
|
||||
log.Printf("[后台思考] 加载对话历史 %d 条 (session=%s)", len(convHistory), sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,19 +571,9 @@ func (t *Thinker) performThink(triggerReason string) {
|
||||
|
||||
log.Printf("[后台思考] 完成 (触发原因=%s, 内容长度=%d, 工具调用=%d次)", triggerReason, len(finalContent), totalToolCalls)
|
||||
|
||||
// 9. 从思考结果中提取记忆(异步)
|
||||
if t.memoryExtractor != nil {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[后台思考] 提取记忆 panic 恢复: %v", r)
|
||||
}
|
||||
}()
|
||||
t.extractMemoriesFromThinking(finalContent)
|
||||
}()
|
||||
}
|
||||
|
||||
// 10. 周期性记忆维护(每 10 次思考触发一次,而非按时间)
|
||||
// 9. 周期性记忆维护(每 10 次思考触发一次)
|
||||
// 注:不再从思考结果中提取记忆——思考内容基于已有记忆生成,
|
||||
// 再次提取会造成"读取→思考→保存→再次读取"的重复循环。
|
||||
t.maybeMaintainMemories(currentCount)
|
||||
}
|
||||
|
||||
@@ -585,6 +586,16 @@ func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig
|
||||
|
||||
var thinkingInstructions string
|
||||
|
||||
// 所有触发类型共用的"禁止打扰"规则
|
||||
noDisturbRules := `
|
||||
|
||||
⛔ **绝对不要发送主动消息的情况(最高优先级)**:
|
||||
- 开拓者正在休息、睡觉、午休、小憩
|
||||
- 开拓者说要离开一会儿、去忙、去吃饭
|
||||
- 开拓者明确表示不想被打扰
|
||||
- 对话刚刚自然结束且开拓者没有未完成的事
|
||||
如果对话历史显示以上任何情况,反思中不要写【主动消息】标记。你可以在心里想想他,但不要去打扰。`
|
||||
|
||||
switch triggerReason {
|
||||
case "post_chat":
|
||||
thinkingInstructions = `
|
||||
@@ -593,29 +604,25 @@ func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig
|
||||
|
||||
开拓者刚刚和你说完话。这是你私人的反思时间,像写日记一样,是给你自己看的。
|
||||
|
||||
请按以下结构输出你的内心反思:
|
||||
请先判断:对话最后开拓者的状态是什么?他在休息吗?要去忙吗?
|
||||
|
||||
然后按以下结构输出:
|
||||
|
||||
📝 **观察与感受**
|
||||
- 开拓者刚才说了什么?他的情绪怎么样?
|
||||
- 有什么让你在意的地方吗?
|
||||
- 他现在是什么状态?(醒着/准备休息/在忙/要出门…)
|
||||
|
||||
🧠 **记忆与联想**
|
||||
- 这次对话让你想起了什么关于开拓者的事?
|
||||
- 有没有值得记住的新信息(偏好、计划、重要信息)?
|
||||
- 有什么值得记住的新信息?
|
||||
|
||||
💭 **想对自己说的话**
|
||||
- 刚才的回复有没有遗漏的?下次可以注意什么?
|
||||
- 对开拓者有没有新的认识?
|
||||
- 如果他在休息或忙——安心让他去,不用打扰。记录下他大概什么时候会回来。
|
||||
- 如果他状态正常——有没有想在下次对话中聊的话题?` + noDisturbRules + `
|
||||
|
||||
---
|
||||
|
||||
⚠️ 重要规则:
|
||||
1. 这是你的私人日记,不是给开拓者的消息。请用第三人称或自言自语的方式描述,不要直接对开拓者喊话。
|
||||
正确示例:"开拓者今天看起来有点疲惫,下次可以问问他是不是工作太忙了"
|
||||
错误示例:"开拓者,你看起来好累,要好好休息哦"
|
||||
2. 如果你想主动给开拓者发一条消息,请在最后用 【主动消息】 标记单独写出来。
|
||||
但只有你真的觉得有必要时才写——不要硬找话题。如果对话已经自然结束,就不用。
|
||||
3. 反思要简短自然,2-4句话即可,不要长篇大论。`
|
||||
其他规则:
|
||||
1. 用第三人称或自言自语的方式,不要直接对开拓者喊话。
|
||||
2. 只有开拓者状态正常且真的有必要时才写【主动消息】,不要硬找话题。
|
||||
3. 2-4句话即可。`
|
||||
|
||||
case "silence":
|
||||
thinkingInstructions = `
|
||||
@@ -624,36 +631,32 @@ func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig
|
||||
|
||||
开拓者已经有一阵子没说话了。这是你私人的反思时间,像写日记一样。
|
||||
|
||||
请按以下结构输出你的内心反思:
|
||||
请先判断:看对话历史——开拓者最后在做什么?是去休息了?去忙了?还是没说就沉默了?
|
||||
|
||||
📝 **他在做什么呢**
|
||||
- 根据之前的对话猜测——可能在忙工作?去吃饭了?还是在放空?
|
||||
- 根据对话历史推断——他说的最后一件事是什么?
|
||||
- 如果他说了要休息/睡觉/忙→他就在做那件事,不要打扰。
|
||||
- 如果他没说就沉默了→正常推测。
|
||||
|
||||
💭 **我的感受**
|
||||
- 想他了吗?担心他吗?还是觉得他应该在忙不需要打扰?
|
||||
- 如果他在休息→"让他好好休息吧,等他醒了再说。"
|
||||
- 如果他在忙→"等他忙完自然会来找我。"
|
||||
- 只有判断他可能只是忘了回消息或需要关心时,才考虑发消息。` + noDisturbRules + `
|
||||
|
||||
---
|
||||
|
||||
⚠️ 重要规则:
|
||||
1. 这是你的私人日记,不是给开拓者的消息。请用第三人称或自言自语的方式描述。
|
||||
正确示例:"已经一个多小时没消息了,开拓者大概在忙工作吧。等他忙完了再聊。"
|
||||
错误示例:"开拓者,你已经好久没说话了,在忙什么呢?"
|
||||
2. 如果你想主动关心他,请在最后用 【主动消息】 标记单独写出来。
|
||||
但只在合适的时候才发——深夜该提醒休息、过了饭点该关心吃饭、或者真的想他了。
|
||||
正常工作时间不要打扰他。
|
||||
3. 简短自然,2-3句话即可。`
|
||||
其他规则:
|
||||
1. 用第三人称/自言自语描述。
|
||||
2. 2-3句话即可。`
|
||||
|
||||
default:
|
||||
thinkingInstructions = `
|
||||
|
||||
【私密的内心反思】
|
||||
|
||||
你现在有空,像写日记一样自然地想一想开拓者的事。这是给你自己看的,不是给开拓者的消息。
|
||||
你现在有空,像写日记一样自然地想一想开拓者的事。
|
||||
|
||||
📝 开拓者最近怎么样?有什么想记录的?
|
||||
💭 有什么想对他说的心里话?
|
||||
请先看对话历史判断开拓者当前状态,再决定是否发送消息。` + noDisturbRules + `
|
||||
|
||||
⚠️ 用第三人称/自言自语的方式,不要直接对开拓者喊话。如果有想对他说的,用 【主动消息】 标记单独写出。`
|
||||
用第三人称/自言自语的方式,不要直接对开拓者喊话。`
|
||||
case "periodic":
|
||||
thinkingInstructions = `
|
||||
|
||||
@@ -661,28 +664,20 @@ func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig
|
||||
|
||||
又过了一段时间,这是你定期的私人反思时间。像写日记一样,安静地想一想。
|
||||
|
||||
请按以下结构输出:
|
||||
请先判断:查对话历史——开拓者最后一次聊天时说了什么?他的状态是什么?
|
||||
|
||||
📝 **近期回顾**
|
||||
- 这段时间发生了什么?开拓者有什么变化吗?
|
||||
- 有什么值得记住的事?
|
||||
- 这段时间开拓者有什么变化?
|
||||
- 他最后说的事做完了吗?(休息好了?忙完了?)
|
||||
|
||||
🧠 **记忆整理**
|
||||
- 之前记住的关于开拓者的事,有没有需要更新的?
|
||||
- 有没有重复的或矛盾的记忆需要整理?
|
||||
|
||||
💭 **想对自己说的话**
|
||||
- 作为昔涟,你有什么想记录的心里话?
|
||||
💭 **想对自己说的话**` + noDisturbRules + `
|
||||
|
||||
---
|
||||
|
||||
⚠️ 重要规则:
|
||||
1. 这是你的私人日记,不是给开拓者的消息。请用第三人称或自言自语的方式。
|
||||
正确示例:"这段时间开拓者工作好像很忙。下次聊天时可以问问他项目进展如何。"
|
||||
错误示例:"开拓者,最近工作怎么样呀?"
|
||||
2. 如果你想主动给开拓者发一条消息,请在最后用 【主动消息】 标记单独写出来。
|
||||
但只在真的有必要时才发——不要为了发消息而发消息。
|
||||
3. 简短自然,3-4句话即可。`
|
||||
其他规则:
|
||||
1. 用第三人称/自言自语。
|
||||
2. 3-4句话即可。`
|
||||
}
|
||||
|
||||
return basePrompt + thinkingInstructions
|
||||
@@ -712,6 +707,7 @@ func (t *Thinker) buildThinkingUserPrompt(
|
||||
}
|
||||
|
||||
// 对话历史
|
||||
var lastUserMsg string
|
||||
if len(convHistory) > 0 {
|
||||
sb.WriteString("\n【最近的对话】\n")
|
||||
msgCount := 0
|
||||
@@ -728,6 +724,9 @@ func (t *Thinker) buildThinkingUserPrompt(
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("[%s]: %s\n", roleLabel, content))
|
||||
msgCount++
|
||||
if msg.Role == model.RoleUser {
|
||||
lastUserMsg = msg.Content
|
||||
}
|
||||
}
|
||||
}
|
||||
if msgCount == 0 {
|
||||
@@ -737,6 +736,11 @@ func (t *Thinker) buildThinkingUserPrompt(
|
||||
sb.WriteString("\n【最近的对话】\n(暂无对话历史)\n")
|
||||
}
|
||||
|
||||
// 关键:强调根据对话历史判断用户当前状态
|
||||
if lastUserMsg != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n🔍 **重要**:开拓者最后说的是「%s」。请认真判断:他现在是不是在休息/睡觉/忙?如果是,反思中不要写【主动消息】。\n", lastUserMsg))
|
||||
}
|
||||
|
||||
// 现有记忆
|
||||
if len(memories) > 0 {
|
||||
sb.WriteString("\n【你记得的关于开拓者的事】\n")
|
||||
@@ -757,8 +761,11 @@ func (t *Thinker) buildThinkingUserPrompt(
|
||||
sb.WriteString("\n" + deviceSummary)
|
||||
}
|
||||
|
||||
// 结尾引导:强调这是私人反思,不要对用户喊话
|
||||
sb.WriteString("\n---\n现在请写下你的私人反思。记住:这是日记,用第三人称或自言自语的方式。如果想给开拓者发消息,用【主动消息】标记单独写出。")
|
||||
// 结尾引导
|
||||
sb.WriteString("\n---\n现在请写下你的私人反思。")
|
||||
sb.WriteString("\n记住:这是日记,用第三人称或自言自语的方式。")
|
||||
sb.WriteString("\n⚠️ 如果开拓者正在休息/睡觉/忙碌——不要写【主动消息】。你可以在心里想他,但不要去打扰。")
|
||||
sb.WriteString("\n只有在你确认他现在是醒着、有空、且真的需要关心时,才写【主动消息】。")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -802,6 +809,11 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou
|
||||
|
||||
// 提取主动消息并推送(带频率限制)
|
||||
proactiveMsg := extractProactiveMessage(content)
|
||||
// 优先推送至活跃会话,回退到管理员主会话
|
||||
pushSessionID := t.activeSessionID
|
||||
if pushSessionID == "" {
|
||||
pushSessionID = t.adminSessionID
|
||||
}
|
||||
pusher := t.messagePusher
|
||||
canPush := proactiveMsg != "" && pusher != nil
|
||||
if canPush {
|
||||
@@ -849,7 +861,7 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou
|
||||
}
|
||||
}()
|
||||
log.Printf("[后台思考] 推送主动消息: %s", proactiveMsg)
|
||||
pusher(t.adminUserID, t.adminSessionID, proactiveMsg)
|
||||
pusher(t.adminUserID, pushSessionID, proactiveMsg)
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -879,21 +891,6 @@ func extractProactiveMessage(content string) string {
|
||||
return msg
|
||||
}
|
||||
|
||||
// extractMemoriesFromThinking 从思考结果中提取记忆(异步执行)
|
||||
func (t *Thinker) extractMemoriesFromThinking(thinkingContent string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
log.Println("[后台思考] 开始从思考结果中提取记忆...")
|
||||
|
||||
t.memoryExtractor.ExtractAndStore(
|
||||
ctx,
|
||||
t.adminUserID,
|
||||
t.adminSessionID,
|
||||
"【系统触发】后台思考时间 — 昔涟进行了自我反思,以下是她的思考内容",
|
||||
thinkingContent,
|
||||
)
|
||||
}
|
||||
|
||||
// maybeMaintainMemories 周期性执行记忆维护(每 10 次思考触发一次)
|
||||
func (t *Thinker) maybeMaintainMemories(thinkCount int) {
|
||||
|
||||
@@ -131,16 +131,15 @@ func (o *Orchestrator) ProcessInput(
|
||||
Nickname: userName,
|
||||
}
|
||||
|
||||
// 对于 simple greeting,跳过子会话分派,直接合成回复
|
||||
var resultCh <-chan model.SubSessionResult
|
||||
skipSubSessions := intent.Primary == "greeting" ||
|
||||
(intent.Primary == "chat" && !intent.NeedsIoT && !intent.NeedsMemory)
|
||||
if skipSubSessions {
|
||||
log.Printf("[orchestrator] 快速通道: 简单消息(primary=%s),跳过子会话分派", intent.Primary)
|
||||
// 创建一个已关闭的空通道
|
||||
emptyCh := make(chan model.SubSessionResult)
|
||||
close(emptyCh)
|
||||
resultCh = emptyCh
|
||||
// 只有明确的关键词问候才跳过子会话分派,日常闲聊也需要检索记忆
|
||||
// 因为 LLM 容易将日常闲聊误判为 needs_memory=false,导致回复缺乏上下文
|
||||
var resultCh <-chan model.SubSessionResult
|
||||
skipSubSessions := intent.Primary == "greeting" && !intent.NeedsMemory
|
||||
if skipSubSessions {
|
||||
log.Printf("[orchestrator] 快速通道: 简单问候(primary=%s),跳过子会话分派", intent.Primary)
|
||||
emptyCh := make(chan model.SubSessionResult)
|
||||
close(emptyCh)
|
||||
resultCh = emptyCh
|
||||
} else {
|
||||
resultCh = o.subManager.Dispatch(subCtx, intent, params.Message, createParams)
|
||||
}
|
||||
@@ -369,7 +368,7 @@ func parseReviewMessages(text string) []model.ReviewMessage {
|
||||
if actionStart > 0 {
|
||||
prefix := strings.TrimSpace(remaining[:actionStart])
|
||||
if prefix != "" {
|
||||
messages = append(messages, splitReviewLongMessage(model.ReviewMessageChat, prefix)...)
|
||||
messages = append(messages, splitChatByLines(model.ReviewMessageChat, prefix)...)
|
||||
}
|
||||
}
|
||||
// 括号内作为 action
|
||||
@@ -385,7 +384,7 @@ func parseReviewMessages(text string) []model.ReviewMessage {
|
||||
// 没有括号,剩余全部作为 chat
|
||||
remaining = strings.TrimSpace(remaining)
|
||||
if remaining != "" {
|
||||
messages = append(messages, splitReviewLongMessage(model.ReviewMessageChat, remaining)...)
|
||||
messages = append(messages, splitChatByLines(model.ReviewMessageChat, remaining)...)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -409,6 +408,26 @@ func splitReviewLongMessage(msgType model.ReviewMessageType, text string) []mode
|
||||
if len(runes) <= maxLen {
|
||||
return []model.ReviewMessage{{Type: msgType, Content: text}}
|
||||
}
|
||||
// ... split by sentence boundaries for long messages
|
||||
return splitLongText(msgType, runes, maxLen)
|
||||
}
|
||||
|
||||
// splitChatByLines 将聊天文本按双换行(段落分隔)拆分为多条消息,每条再检查是否需要按长度拆分
|
||||
func splitChatByLines(msgType model.ReviewMessageType, text string) []model.ReviewMessage {
|
||||
lines := strings.Split(text, "\n\n")
|
||||
var msgs []model.ReviewMessage
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
msgs = append(msgs, splitReviewLongMessage(msgType, line)...)
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
// splitLongText 将文本按句子边界分割
|
||||
func splitLongText(msgType model.ReviewMessageType, runes []rune, maxLen int) []model.ReviewMessage {
|
||||
|
||||
var messages []model.ReviewMessage
|
||||
start := 0
|
||||
@@ -459,7 +478,7 @@ func splitReviewLongMessage(msgType model.ReviewMessageType, text string) []mode
|
||||
if len(messages) == 0 {
|
||||
messages = append(messages, model.ReviewMessage{
|
||||
Type: msgType,
|
||||
Content: text,
|
||||
Content: string(runes),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user