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
@@ -2,12 +2,15 @@ package subsession
import (
"context"
"encoding/json"
"fmt"
"github.com/yourname/cyrene-ai/pkg/logger"
"strings"
"time"
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
"github.com/yourname/cyrene-ai/ai-core/internal/memory"
"github.com/yourname/cyrene-ai/ai-core/internal/model"
"github.com/yourname/cyrene-ai/pkg/logger"
)
// MemoryRetriever 记忆检索接口
@@ -16,9 +19,12 @@ type MemoryRetriever interface {
}
// MemoryProvider 记忆检索子会话提供者
// 职责:检索与当前对话相关的用户记忆,排序去重,返回结构化摘要
// 职责:检索与当前对话相关的用户记忆,排序去重,返回结构化摘要
// 支持 LLM 驱动的模糊关键词扩展搜索。
type MemoryProvider struct {
retriever MemoryRetriever
retriever MemoryRetriever
llmAdapter *llm.Adapter
memClient *memory.Client
}
// NewMemoryProvider 创建记忆检索子会话提供者
@@ -28,6 +34,12 @@ func NewMemoryProvider(retriever MemoryRetriever) *MemoryProvider {
}
}
// SetFuzzySearch enables LLM-driven fuzzy keyword expansion for broader memory retrieval.
func (p *MemoryProvider) SetFuzzySearch(llmAdapter *llm.Adapter, memClient *memory.Client) {
p.llmAdapter = llmAdapter
p.memClient = memClient
}
func (p *MemoryProvider) Type() model.SubSessionType {
return model.SubSessionMemory
}
@@ -93,6 +105,7 @@ func (p *MemoryProvider) Execute(ctx context.Context, subCtx []model.LLMMessage)
return result, nil
}
// Phase 1: exact/keyword retrieval
memories, err := p.retriever.Retrieve(ctx, userID, userMessage)
if err != nil {
logger.Printf("[memory-subsession] 记忆检索失败: %v", err)
@@ -101,6 +114,20 @@ func (p *MemoryProvider) Execute(ctx context.Context, subCtx []model.LLMMessage)
return result, nil
}
seen := make(map[string]bool)
for _, m := range memories {
seen[m.ID] = true
}
// Phase 2: LLM-driven fuzzy keyword expansion + semantic search
fuzzyMemories := p.fuzzySearch(ctx, userID, userMessage)
for _, m := range fuzzyMemories {
if !seen[m.ID] {
seen[m.ID] = true
memories = append(memories, m)
}
}
// 转换为 MemorySnippet
snippets := make([]model.MemorySnippet, 0, len(memories))
for _, m := range memories {
@@ -117,7 +144,7 @@ func (p *MemoryProvider) Execute(ctx context.Context, subCtx []model.LLMMessage)
if len(snippets) == 0 {
result.Summary = "(没有找到相关记忆)"
} else {
result.Summary = fmt.Sprintf("检索到 %d 条相关记忆", len(snippets))
result.Summary = fmt.Sprintf("检索到 %d 条相关记忆(含模糊匹配)", len(snippets))
// 按重要性列出前几条
topCount := len(snippets)
if topCount > 3 {
@@ -138,6 +165,74 @@ func (p *MemoryProvider) Execute(ctx context.Context, subCtx []model.LLMMessage)
}
result.Memories = snippets
logger.Printf("[memory-subsession] 完成: %s", result.Summary)
logger.Printf("[memory-subsession] 完成: %s (精确=%d, 模糊=%d)", result.Summary, len(memories)-len(fuzzyMemories), len(fuzzyMemories))
return result, nil
}
// fuzzySearch expands the user message into fuzzy keywords via LLM and performs semantic search.
func (p *MemoryProvider) fuzzySearch(ctx context.Context, userID, userMessage string) []memory.MemoryEntry {
if p.llmAdapter == nil || p.memClient == nil {
return nil
}
keywords := p.expandKeywords(ctx, userMessage)
if len(keywords) == 0 {
return nil
}
logger.Printf("[memory-subsession] 模糊关键词: %v", keywords)
var allResults []memory.MemoryEntry
seen := make(map[string]bool)
for _, kw := range keywords {
results, err := p.memClient.QueryByText(ctx, userID, kw, "", 0, 5)
if err != nil {
logger.Printf("[memory-subsession] 模糊搜索 '%s' 失败: %v", kw, err)
continue
}
for _, m := range results {
if !seen[m.ID] {
seen[m.ID] = true
allResults = append(allResults, m)
}
}
}
return allResults
}
// expandKeywords uses LLM to generate fuzzy/related search keywords from the user message.
func (p *MemoryProvider) expandKeywords(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 := p.llmAdapter.Chat(ctx, []model.LLMMessage{
{Role: model.RoleSystem, Content: "你是记忆搜索专家。输出 JSON 字符串数组。"},
{Role: model.RoleUser, Content: prompt},
})
if err != nil {
logger.Printf("[memory-subsession] 关键词扩展失败: %v", err)
return nil
}
text := strings.TrimSpace(resp.Content)
// Extract JSON array
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 {
logger.Printf("[memory-subsession] 解析关键词 JSON 失败: %v (raw=%s)", err, resp.Content)
return nil
}
return keywords
}