fix: 修复 AI 回复无法送达发送者 + 重复消息 + action角色泄露 + OS环境支持

广播逻辑重构:
- AI 回复 (stream_start/response/stream_segments/multi_message/stream_end) 改用 broadcastToUser 发送给所有客户端
- 用户消息回显保持 broadcastToUserExcept 排除发送者

消息去重与角色修复:
- CacheMessage(user) 移至回复生成后,避免本轮 LLM 调用出现重复用户消息
- action 角色消息在 DB 存储时映射为 assistant,DeepSeek 等模型不支持自定义角色
- stream_end defer 机制确保错误路径也会终止客户端思考指示器

OS 完整环境支持:
- host 包重构为 HostBackend 接口 + Direct/WSL/Docker 三种后端
- 新增 os_exec/os_file/os_system 工具供 AI 在完整 Linux 环境中自由操作

其他:
- 视觉模型注入 + 图片预处理后清空 Images 避免传给 Chat 模型
- 图片 URL 相对路径→绝对 URL 转换
- DevTools 链路追踪页面 + 重启修复
- 记忆搜索模糊匹配增强
- 后台思考定时调度支持
- 管理后台页面 (模型配置/用户管理等)
- docs/api 更新广播机制说明

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 12:46:17 +08:00
parent aac64ed8b7
commit 91c9ee4b2d
49 changed files with 5032 additions and 299 deletions
+403 -13
View File
@@ -582,16 +582,7 @@ func (t *Thinker) performThink(triggerReason string) {
return
}
// 2. 检索相关记忆
var memories []memory.MemoryEntry
if t.memRetriever != nil {
memories, err = t.memRetriever.Retrieve(ctx, t.adminUserID, "最近发生了什么 重要的事情 用户偏好 个人信息")
if err != nil {
log.Printf("[后台思考] 记忆检索失败: %v", err)
}
}
// 3. 获取当前活跃会话的对话历史(优先活跃会话,回退到管理员主会话)
// 2. 获取当前活跃会话的对话历史(优先活跃会话,回退到管理员主会话)
var convHistory []model.LLMMessage
if t.convStore != nil {
t.mu.Lock()
@@ -608,6 +599,37 @@ func (t *Thinker) performThink(triggerReason string) {
}
}
// 3. 检索相关记忆(精确检索 + 模糊搜索)
var memories []memory.MemoryEntry
if t.memRetriever != nil {
memories, err = t.memRetriever.Retrieve(ctx, t.adminUserID, "最近发生了什么 重要的事情 用户偏好 个人信息")
if err != nil {
log.Printf("[后台思考] 记忆检索失败: %v", err)
}
// 模糊搜索:从对话历史提取话题,LLM 扩展关键词后语义搜索
if t.memClient != nil && len(convHistory) > 0 {
fuzzyQuery := lastUserMessage(convHistory)
if fuzzyQuery == "" {
fuzzyQuery = "最近对话 重要事件 用户状态"
}
fuzzyResults := t.fuzzyMemorySearch(ctx, t.adminUserID, fuzzyQuery)
seen := make(map[string]bool)
for _, m := range memories {
seen[m.ID] = true
}
for _, m := range fuzzyResults {
if !seen[m.ID] {
seen[m.ID] = true
memories = append(memories, m)
}
}
if len(fuzzyResults) > 0 {
log.Printf("[后台思考] 模糊搜索补充 %d 条记忆", len(fuzzyResults))
}
}
}
// 4. 查询 IoT 设备状态(每次都查询,无间隔限制)
var deviceSummary string
if t.iotClient != nil {
@@ -750,10 +772,9 @@ func (t *Thinker) performThink(triggerReason string) {
log.Printf("[后台思考] 完成 (触发原因=%s, 内容长度=%d, 工具调用=%d次)", triggerReason, len(finalContent), totalToolCalls)
// 9. 周期性记忆维护(每 10 次思考触发一次)
// 注:不再从思考结果中提取记忆——思考内容基于已有记忆生成,
// 再次提取会造成"读取→思考→保存→再次读取"的重复循环。
// 9. 记忆维护:机械合并(每10次) + LLM整理(每次)
t.maybeMaintainMemories(currentCount)
t.performMemoryConsolidation(ctx)
}
// buildThinkingSystemPrompt 构建思考用的系统提示词
@@ -1196,6 +1217,375 @@ func (t *Thinker) maybeMaintainMemories(thinkCount int) {
}
}
// consolidationAction is a parsed memory consolidation instruction from the LLM.
type consolidationAction struct {
Action string `json:"action"`
IDs []string `json:"ids,omitempty"`
ID string `json:"id,omitempty"`
Content string `json:"content,omitempty"`
Category string `json:"category,omitempty"`
Importance int `json:"importance,omitempty"`
Priority int `json:"priority,omitempty"`
Keywords []string `json:"keywords,omitempty"`
Reason string `json:"reason,omitempty"`
}
// performMemoryConsolidation uses LLM to review and clean up the memory store.
// It identifies duplicates, contradictions, outdated info, and low-quality memories,
// then executes merge/delete/update actions.
func (t *Thinker) performMemoryConsolidation(ctx context.Context) {
if t.memClient == nil {
return
}
allMemories, err := t.memClient.Query(ctx, model.MemoryQuery{
UserID: t.adminUserID,
Limit: 200,
})
if err != nil {
log.Printf("[记忆整理] 获取记忆失败: %v", err)
return
}
if len(allMemories) < 5 {
return
}
log.Printf("[记忆整理] LLM 审查 %d 条记忆...", len(allMemories))
systemPrompt := t.buildConsolidationPrompt(allMemories)
messages := []model.LLMMessage{
{Role: model.RoleSystem, Content: systemPrompt},
{Role: model.RoleUser, Content: "请审查以上记忆库,找出重复、矛盾、过时和低质量的记忆,输出 JSON 整理方案。如果没有需要整理的,输出空数组 []。"},
}
resp, err := t.toolAdapter.Chat(ctx, messages)
if err != nil {
log.Printf("[记忆整理] LLM 调用失败: %v", err)
return
}
actions := parseConsolidationActions(resp.Content)
if len(actions) == 0 {
log.Printf("[记忆整理] 记忆库状态良好,无需整理")
return
}
log.Printf("[记忆整理] LLM 建议 %d 项操作", len(actions))
executed := t.executeConsolidationActions(ctx, actions, allMemories)
log.Printf("[记忆整理] 完成: 执行了 %d 项操作", executed)
}
// buildConsolidationPrompt formats all memories as a structured list for LLM review.
func (t *Thinker) buildConsolidationPrompt(memories []model.MemoryEntry) string {
var sb strings.Builder
sb.WriteString("你是记忆库管理助手。审查以下用户记忆,找出问题并输出 JSON 操作清单。\n\n")
sb.WriteString("## 需要识别的问题\n")
sb.WriteString("1. 重复记忆 — 多条记忆记录了相同信息 → merge 合并为一条\n")
sb.WriteString("2. 矛盾记忆 — 两条记忆互相矛盾(如\"喜欢猫\"vs\"讨厌猫\")→ delete 删除过时的、update 修正错误的\n")
sb.WriteString("3. 过时记忆 — 信息已被新记忆取代 → delete 或 update\n")
sb.WriteString("4. 低质量记忆 — 太模糊、不完整、无实际信息量 → delete\n\n")
sb.WriteString("## JSON 操作格式\n")
sb.WriteString("```json\n[\n")
sb.WriteString(" {\"action\":\"merge\", \"ids\":[\"id1\",\"id2\"], \"content\":\"合并后的内容\", \"category\":\"personal_info\", \"importance\":8, \"reason\":\"两条记录同一件事\"},\n")
sb.WriteString(" {\"action\":\"delete\", \"id\":\"id3\", \"reason\":\"完全被 id1 覆盖\"},\n")
sb.WriteString(" {\"action\":\"update\", \"id\":\"id4\", \"content\":\"修正后的内容\", \"importance\":7, \"reason\":\"纠正过时信息\"},\n")
sb.WriteString(" {\"action\":\"create\", \"content\":\"需要补充的记忆\", \"category\":\"knowledge\", \"importance\":6, \"reason\":\"从已有记忆推断\"}\n")
sb.WriteString("]\n```\n\n")
sb.WriteString("## 规则\n")
sb.WriteString("- 只输出 JSON 数组,可以用 ```json``` 包裹,不要输出其他解释文字\n")
sb.WriteString("- 确实有问题才建议操作,不要强行找问题\n")
sb.WriteString("- merge 时保留最重要的那条的 ID,合并内容应包含各条的关键信息\n")
sb.WriteString("- 不确定时宁可保守(不操作)\n")
sb.WriteString("- importance 范围 1-10,数字越大越重要\n")
sb.WriteString("- category 可选: personal_info, user_preference, conversation, knowledge, event, task, relationship\n\n")
sb.WriteString(fmt.Sprintf("## 当前记忆库 (%d 条)\n\n", len(memories)))
for i, m := range memories {
sb.WriteString(fmt.Sprintf("%d. [%s] **%s** | cat=%s imp=%d pri=%d | src=%s\n",
i+1, m.ID[:min(8, len(m.ID))], m.Content,
m.Category, m.Importance, m.Priority, m.Source))
}
return sb.String()
}
// parseConsolidationActions extracts JSON actions from LLM response text.
func parseConsolidationActions(text string) []consolidationAction {
// Try to extract from ```json fences first
jsonStr := text
if idx := strings.Index(text, "```json"); idx >= 0 {
start := idx + 7
if end := strings.Index(text[start:], "```"); end >= 0 {
jsonStr = text[start : start+end]
}
} else if idx := strings.Index(text, "```"); idx >= 0 {
start := idx + 3
if end := strings.Index(text[start:], "```"); end >= 0 {
jsonStr = text[start : start+end]
}
}
// Find the JSON array
arrStart := strings.Index(jsonStr, "[")
arrEnd := strings.LastIndex(jsonStr, "]")
if arrStart < 0 || arrEnd <= arrStart {
return nil
}
jsonStr = jsonStr[arrStart : arrEnd+1]
var actions []consolidationAction
if err := json.Unmarshal([]byte(jsonStr), &actions); err != nil {
log.Printf("[记忆整理] JSON 解析失败: %v", err)
return nil
}
return actions
}
// executeConsolidationActions runs the parsed consolidation actions against the memory store.
func (t *Thinker) executeConsolidationActions(ctx context.Context, actions []consolidationAction, memories []model.MemoryEntry) int {
// Index memories by their short ID prefix for lookup
memByShortID := make(map[string]*model.MemoryEntry)
for i := range memories {
short := memories[i].ID[:min(8, len(memories[i].ID))]
memByShortID[short] = &memories[i]
}
memByFullID := make(map[string]*model.MemoryEntry)
for i := range memories {
memByFullID[memories[i].ID] = &memories[i]
}
executed := 0
for _, a := range actions {
switch a.Action {
case "delete":
id := resolveID(a.ID, memByShortID, memByFullID)
if id == "" {
log.Printf("[记忆整理] 跳过 delete: 找不到记忆 %s", a.ID)
continue
}
if err := t.memClient.Delete(ctx, id); err != nil {
log.Printf("[记忆整理] 删除 %s 失败: %v", a.ID, err)
continue
}
log.Printf("[记忆整理] 已删除: %s (原因: %s)", a.ID, a.Reason)
executed++
case "merge":
if len(a.IDs) < 2 {
continue
}
// Resolve all IDs, use first as the keeper
var resolved []string
for _, mid := range a.IDs {
if rid := resolveID(mid, memByShortID, memByFullID); rid != "" {
resolved = append(resolved, rid)
}
}
if len(resolved) < 2 {
continue
}
keeper := resolved[0]
// Update the keeper with merged content
cat := model.MemoryCategory(a.Category)
if cat == "" {
if m, ok := memByFullID[keeper]; ok {
cat = m.Category
}
}
imp := a.Importance
if imp == 0 {
if m, ok := memByFullID[keeper]; ok {
imp = m.Importance + 1
}
}
if imp > 10 {
imp = 10
}
pri := a.Priority
if pri == 0 {
if m, ok := memByFullID[keeper]; ok {
pri = int(m.Priority)
}
}
if err := t.memClient.Update(ctx, &model.MemoryEntry{
ID: keeper,
Content: a.Content,
Category: cat,
Importance: imp,
Priority: model.MemoryPriority(pri),
Keywords: a.Keywords,
Source: "consolidated",
}); err != nil {
log.Printf("[记忆整理] 更新合并目标 %s 失败: %v", keeper, err)
continue
}
// Delete the discarded ones
for _, did := range resolved[1:] {
if err := t.memClient.Delete(ctx, did); err != nil {
log.Printf("[记忆整理] 删除被合并记忆 %s 失败: %v", did, err)
}
}
log.Printf("[记忆整理] 已合并: %v -> %s (原因: %s)", resolved, keeper, a.Reason)
executed++
case "update":
id := resolveID(a.ID, memByShortID, memByFullID)
if id == "" {
log.Printf("[记忆整理] 跳过 update: 找不到记忆 %s", a.ID)
continue
}
existing := memByFullID[id]
cat := model.MemoryCategory(a.Category)
if cat == "" && existing != nil {
cat = existing.Category
}
imp := a.Importance
if imp == 0 && existing != nil {
imp = existing.Importance
}
pri := a.Priority
if pri == 0 && existing != nil {
pri = int(existing.Priority)
}
if err := t.memClient.Update(ctx, &model.MemoryEntry{
ID: id,
Content: a.Content,
Category: cat,
Importance: imp,
Priority: model.MemoryPriority(pri),
Keywords: a.Keywords,
Source: "consolidated",
}); err != nil {
log.Printf("[记忆整理] 更新 %s 失败: %v", id, err)
continue
}
log.Printf("[记忆整理] 已更新: %s (原因: %s)", id, a.Reason)
executed++
case "create":
cat := model.MemoryCategory(a.Category)
if cat == "" {
cat = model.CategoryKnowledge
}
imp := a.Importance
if imp == 0 {
imp = 5
}
if err := t.memClient.Save(ctx, &model.MemoryEntry{
UserID: t.adminUserID,
Content: a.Content,
Category: cat,
Importance: imp,
Priority: model.MemoryNormal,
Keywords: a.Keywords,
Source: "consolidation",
}); err != nil {
log.Printf("[记忆整理] 创建记忆失败: %v", err)
continue
}
log.Printf("[记忆整理] 已创建: %s (原因: %s)", a.Content, a.Reason)
executed++
}
}
return executed
}
// resolveID tries to match a short or full ID to an existing memory.
func resolveID(id string, byShort, byFull map[string]*model.MemoryEntry) string {
if _, ok := byFull[id]; ok {
return id
}
if m, ok := byShort[id]; ok {
return m.ID
}
// Try prefix match
for fullID := range byFull {
if strings.HasPrefix(fullID, id) {
return fullID
}
}
return ""
}
// fuzzyMemorySearch expands the query via LLM keyword extraction and performs semantic search.
func (t *Thinker) fuzzyMemorySearch(ctx context.Context, userID, query string) []memory.MemoryEntry {
if t.memClient == nil {
return nil
}
keywords := t.expandMemoryKeywords(ctx, query)
if len(keywords) == 0 {
return nil
}
log.Printf("[后台思考] 模糊记忆关键词: %v", keywords)
var allResults []memory.MemoryEntry
seen := make(map[string]bool)
for _, kw := range keywords {
results, err := t.memClient.QueryByText(ctx, userID, kw, "", 0, 5)
if err != nil {
log.Printf("[后台思考] 模糊搜索 '%s' 失败: %v", kw, err)
continue
}
for _, m := range results {
if !seen[m.ID] {
seen[m.ID] = true
allResults = append(allResults, m)
}
}
}
return allResults
}
// expandMemoryKeywords uses LLM to generate fuzzy/related keywords for memory search.
func (t *Thinker) expandMemoryKeywords(ctx context.Context, message string) []string {
prompt := fmt.Sprintf(
"从以下对话消息中提取 3-5 个可用于模糊搜索记忆的关键词。这些关键词应该是:\n"+
"- 与话题相关的抽象概念\n- 同义词和相关词\n- 更宽泛或更具体的相关概念\n"+
"- 不要包含消息中已经出现的原词\n\n"+
"用户消息:「%s」\n\n"+
"只输出 JSON 字符串数组,例如:[\"关键词1\",\"关键词2\"]", message)
resp, err := t.llmAdapter.Chat(ctx, []model.LLMMessage{
{Role: model.RoleSystem, Content: "你是记忆搜索专家。输出 JSON 字符串数组。"},
{Role: model.RoleUser, Content: prompt},
})
if err != nil {
log.Printf("[后台思考] 关键词扩展失败: %v", err)
return nil
}
text := strings.TrimSpace(resp.Content)
if idx := strings.Index(text, "["); idx >= 0 {
if end := strings.LastIndex(text, "]"); end > idx {
text = text[idx : end+1]
}
}
var keywords []string
if err := json.Unmarshal([]byte(text), &keywords); err != nil {
log.Printf("[后台思考] 解析关键词 JSON 失败: %v (raw=%s)", err, resp.Content)
return nil
}
return keywords
}
// lastUserMessage extracts the last user message from conversation history.
func lastUserMessage(history []model.LLMMessage) string {
for i := len(history) - 1; i >= 0; i-- {
if history[i].Role == model.RoleUser {
runes := []rune(history[i].Content)
if len(runes) > 200 {
return string(runes[:200])
}
return history[i].Content
}
}
return ""
}
// formatDeviceContext 格式化设备状态为文本
func formatDeviceContext(devices []tools.IoTDevice) string {
if len(devices) == 0 {