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
+104 -7
View File
@@ -133,6 +133,7 @@ func main() {
adminUserID := "admin"
adminSessionID := "admin-session-main"
if cfg.DatabaseURL != "" {
convStore.SetDatabaseURL(cfg.DatabaseURL)
if err := convStore.LoadFromDB(cfg.DatabaseURL, adminSessionID, 50); err != nil {
log.Printf("⚠ 从数据库恢复会话历史失败(不影响服务启动): %v", err)
}
@@ -151,13 +152,22 @@ func main() {
log.Println("IoT 客户端未配置 (IOT_SERVICE_URL 和 IOT_DEBUG_SERVICE_URL 均为空)")
}
// 初始化主机操控管理器 (Phase 6.2: 沙箱执行 + 文件系统隔离)
// 初始化主机操控管理器 (沙箱执行 + 文件系统隔离)
hostSandbox := host.NewSandbox(host.DefaultSandboxConfig())
hostManager := host.NewManager(hostSandbox)
directBackend := host.NewDirectBackend(hostSandbox)
hostManager := host.NewManager(directBackend)
dataDir := getEnv("DATA_DIR", "/tmp/cyrene_data")
hostManager.SetAllowedDirs([]string{dataDir, os.TempDir(), "."})
log.Printf("主机操控管理器已就绪: 沙箱执行 + 文件隔离 (数据目录=%s)", dataDir)
// 初始化完整OS环境管理器 (WSL/Docker,无沙箱限制,供 os_* 工具使用)
osManager := createOSManager()
if osManager != nil {
log.Printf("完整OS环境管理器已就绪: backend=%s", osManager.BackendName())
} else {
log.Println("完整OS环境管理器未配置 (设置 HOST_EXEC_BACKEND=wsl 或 docker 以启用)")
}
// 初始化 RAG 知识库 (Phase 6.6: 知识库 RAG 增强)
knowledgeDir := getEnv("KNOWLEDGE_DIR", "./data/knowledge")
ragEmbedder := rag.NewEmbedder(cfg.LLMBaseURL, cfg.LLMAPIKey, "text-embedding-3-small")
@@ -167,6 +177,7 @@ func main() {
// 初始化工具注册中心 (使用共享插件模块)
toolRegistry := plgManager.NewToolRegistry()
var visionProvider llm.LLMProvider
if getEnvBool("ENABLE_TOOLS", true) {
// 11 个共享通用插件 — 注册其工具到统一注册中心
registerPluginTools(toolRegistry, &pluginCalc.CalculatorPlugin{})
@@ -198,7 +209,13 @@ func main() {
toolRegistry.Register(wrapTool(tools.NewHostSystemTool(hostManager), "host_system", "Host System Info", "system"))
}
var visionProvider llm.LLMProvider
if osManager != nil {
toolRegistry.Register(wrapTool(tools.NewOSExecTool(osManager), "os_exec", "OS Command Execution", "system"))
toolRegistry.Register(wrapTool(tools.NewOSFileTool(osManager), "os_file", "OS File Operations", "system"))
toolRegistry.Register(wrapTool(tools.NewOSSystemTool(osManager), "os_system", "OS System Info", "system"))
}
visionProvider = nil
if configLoader != nil && configLoader.HasConfig() {
cfg := configLoader.GetConfig()
if route, ok := cfg.Routing["vision"]; ok && len(route.FallbackChain) > 0 {
@@ -300,7 +317,9 @@ func main() {
// 注册子会话提供者
subManager.Register(subsession.NewGeneralProvider(personaLoader))
if memRetriever != nil {
subManager.Register(subsession.NewMemoryProvider(memRetriever))
memProvider := subsession.NewMemoryProvider(memRetriever)
memProvider.SetFuzzySearch(memoryAdapter, memClient)
subManager.Register(memProvider)
}
if iotClient != nil {
subManager.Register(subsession.NewIoTProvider(iotClient, personaDir))
@@ -322,6 +341,10 @@ func main() {
memExtractor,
)
orch.SetToolRegistry(toolRegistry)
if visionProvider != nil {
orch.SetVisionProvider(visionProvider)
log.Printf("对话编排器: 视觉模型已注入 (%s)", visionProvider.ModelName())
}
log.Println("对话编排器 v2.0 已就绪")
_ = orch
@@ -428,6 +451,32 @@ func main() {
json.NewEncoder(w).Encode(toolRegistry.GetCallStats())
})
// OS 环境监控端点
mux.HandleFunc("/api/v1/system/info", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
result := map[string]interface{}{
"os_enabled": osManager != nil,
}
if osManager != nil {
result["backend"] = osManager.BackendName()
result["system"] = osManager.SystemInfo()
if disk, err := osManager.DiskUsage("/"); err == nil {
result["disk"] = disk
}
}
if hostManager != nil {
result["host"] = map[string]interface{}{
"backend": hostManager.BackendName(),
"system": hostManager.SystemInfo(),
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
})
// 启动HTTP服务
srv := &http.Server{
Addr: ":" + cfg.Port,
@@ -525,6 +574,42 @@ func getEnvBool(key string, fallback bool) bool {
}
}
// createOSManager 根据 HOST_EXEC_BACKEND 环境变量创建完整OS环境管理器。
// 支持 "wsl" 和 "docker" 两种后端。返回 nil 表示未配置或配置无效。
func createOSManager() *host.Manager {
backend := strings.ToLower(os.Getenv("HOST_EXEC_BACKEND"))
switch backend {
case "wsl":
distro := getEnv("WSL_DISTRO", "Ubuntu-22.04")
username := getEnv("WSL_USER", "cyrene")
password := os.Getenv("WSL_USER_PASSWORD")
maxTimeout := time.Duration(getEnvInt("HOST_EXEC_MAX_TIMEOUT", 300)) * time.Second
wslBackend := host.NewWSLBackend(distro, username, password, maxTimeout)
return host.NewManager(wslBackend)
case "docker":
container := getEnv("SANDBOX_CONTAINER", "cyrene-sandbox")
image := getEnv("SANDBOX_IMAGE", "ubuntu:22.04")
maxTimeout := time.Duration(getEnvInt("HOST_EXEC_MAX_TIMEOUT", 300)) * time.Second
dockerBackend := host.NewDockerBackend(container, image, maxTimeout)
return host.NewManager(dockerBackend)
default:
return nil
}
}
// getEnvInt 获取整数类型的环境变量
func getEnvInt(key string, fallback int) int {
v := os.Getenv(key)
if v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return fallback
}
return n
}
// registerPluginTools 从插件实例注册其所有工具到注册中心
func registerPluginTools(registry *plgManager.ToolRegistry, plugin plgSDK.Plugin) {
for _, t := range plugin.Tools() {
@@ -638,9 +723,6 @@ func handleChat(
userNickname = cfg.AdminNickname
}
// 0.1 缓存用户消息到会话历史
ctxBuilder.CacheMessage(req.SessionID, model.RoleUser, req.Message)
// 1. 设置 SSE 响应头
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
@@ -678,6 +760,19 @@ func handleChat(
var fullContent string
for event := range eventCh {
switch event.Type {
case model.StreamToolProgress:
tp := event.ToolProgress
progressData, _ := json.Marshal(map[string]interface{}{
"type": "tool_progress",
"tool_name": tp.ToolName,
"status": tp.Status,
"progress": tp.Progress,
"message": tp.Message,
"message_id": messageID,
})
fmt.Fprintf(w, "data: %s\n\n", progressData)
flusher.Flush()
case model.StreamError:
log.Printf("[chat] 流式错误: %v", event.Error)
errData, _ := json.Marshal(map[string]string{"delta": "", "error": event.Error.Error()})
@@ -729,6 +824,8 @@ func handleChat(
}
}
// 缓存用户消息到会话历史(在回复生成后,避免本轮 LLM 调用出现重复用户消息)
ctxBuilder.CacheMessage(req.SessionID, model.RoleUser, req.Message)
// 4. 对话完成后触发昔涟的自主思考(事件驱动,非定时)
if thinker != nil {
thinker.TriggerPostChatThink()