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
@@ -0,0 +1,217 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/yourname/cyrene-ai/ai-core/internal/host"
)
// OSExecTool allows the AI to execute arbitrary commands in a full OS
// environment (WSL or Docker container). Unlike host_exec which runs in
// a restricted sandbox, this provides unrestricted OS access.
type OSExecTool struct {
manager *host.Manager
}
// NewOSExecTool creates a new OS exec tool for full OS command execution.
func NewOSExecTool(manager *host.Manager) *OSExecTool {
return &OSExecTool{manager: manager}
}
func (t *OSExecTool) Definition() ToolDefinition {
return ToolDefinition{
Name: "os_exec",
Description: "在完整的操作系统环境(WSL/Docker容器)中执行任意命令。适用于复杂操作:安装软件包、编译大型项目、运行脚本、管理服务等。拥有完整的Linux系统权限,无命令限制。日常简单操作请使用 host_exec。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"command": map[string]interface{}{
"type": "string",
"description": "要执行的命令,例如 'pip install pandas && python analyze.py' 或 'apt-get update && apt-get install -y ffmpeg'",
},
"work_dir": map[string]interface{}{
"type": "string",
"description": "工作目录。不指定则使用默认目录。",
},
"timeout_sec": map[string]interface{}{
"type": "integer",
"description": "超时时间(秒),默认30秒,最大300秒。复杂任务请设置更长的超时。",
},
},
"required": []string{"command"},
},
}
}
func (t *OSExecTool) Execute(ctx context.Context, args map[string]interface{}) (*ToolResult, error) {
cmd, _ := args["command"].(string)
if cmd == "" {
return &ToolResult{
ToolName: "os_exec",
Success: false,
Error: "command 参数不能为空",
}, nil
}
workDir, _ := args["work_dir"].(string)
timeoutSec := 60 // Default longer timeout for complex operations
if v, ok := args["timeout_sec"].(float64); ok {
timeoutSec = int(v)
}
timeout := time.Duration(timeoutSec) * time.Second
result, err := t.manager.Exec(ctx, cmd, workDir, timeout)
if err != nil && result == nil {
return &ToolResult{
ToolName: "os_exec",
Success: false,
Error: err.Error(),
}, nil
}
data, _ := json.Marshal(map[string]interface{}{
"command": cmd,
"backend": t.manager.BackendName(),
"exit_code": result.ExitCode,
"duration": result.Duration,
"timed_out": result.TimedOut,
"stdout": result.Stdout,
"stderr": result.Stderr,
})
success := result.ExitCode == 0 && !result.TimedOut
return &ToolResult{
ToolName: "os_exec",
Success: success,
Data: string(data),
}, nil
}
// OSFileTool provides unrestricted file system access within the OS environment.
type OSFileTool struct {
manager *host.Manager
}
// NewOSFileTool creates a new OS file tool for full OS file operations.
func NewOSFileTool(manager *host.Manager) *OSFileTool {
return &OSFileTool{manager: manager}
}
func (t *OSFileTool) Definition() ToolDefinition {
return ToolDefinition{
Name: "os_file",
Description: "在完整OS环境中读写文件。支持在整个文件系统中自由操作:读取/写入/列出文件,无目录限制。适用于批量文件处理、日志分析、配置文件管理等复杂文件操作。日常简单文件操作请使用 host_file。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{
"type": "string",
"description": "操作类型: read, write, list",
"enum": []string{"read", "write", "list"},
},
"path": map[string]interface{}{
"type": "string",
"description": "文件或目录路径",
},
"content": map[string]interface{}{
"type": "string",
"description": "写入内容 (仅 write 操作需要)",
},
},
"required": []string{"action", "path"},
},
}
}
func (t *OSFileTool) Execute(ctx context.Context, args map[string]interface{}) (*ToolResult, error) {
action, _ := args["action"].(string)
path, _ := args["path"].(string)
if action == "" || path == "" {
return &ToolResult{
ToolName: "os_file",
Success: false,
Error: "action 和 path 参数不能为空",
}, nil
}
switch action {
case "read":
content, err := t.manager.ReadFile(path, 1024*1024)
if err != nil {
return &ToolResult{ToolName: "os_file", Success: false, Error: err.Error()}, nil
}
data, _ := json.Marshal(map[string]interface{}{
"path": path,
"content": content,
"size": len(content),
})
return &ToolResult{ToolName: "os_file", Success: true, Data: string(data)}, nil
case "write":
content, _ := args["content"].(string)
if err := t.manager.WriteFile(path, content, 1024*1024); err != nil {
return &ToolResult{ToolName: "os_file", Success: false, Error: err.Error()}, nil
}
data, _ := json.Marshal(map[string]interface{}{
"path": path,
"written": len(content),
"status": "ok",
})
return &ToolResult{ToolName: "os_file", Success: true, Data: string(data)}, nil
case "list":
entries, err := t.manager.ListDir(path)
if err != nil {
return &ToolResult{ToolName: "os_file", Success: false, Error: err.Error()}, nil
}
data, _ := json.Marshal(map[string]interface{}{
"path": path,
"entries": entries,
"count": len(entries),
})
return &ToolResult{ToolName: "os_file", Success: true, Data: string(data)}, nil
default:
return &ToolResult{ToolName: "os_file", Success: false, Error: fmt.Sprintf("不支持的操作: %s", action)}, nil
}
}
// OSSystemTool provides OS-level system information.
type OSSystemTool struct {
manager *host.Manager
}
// NewOSSystemTool creates a new OS system info tool.
func NewOSSystemTool(manager *host.Manager) *OSSystemTool {
return &OSSystemTool{manager: manager}
}
func (t *OSSystemTool) Definition() ToolDefinition {
return ToolDefinition{
Name: "os_system",
Description: "获取完整OS环境的系统信息,包括操作系统详情、CPU架构、内存使用、磁盘空间等。与 host_system 不同,此工具返回的是WSL/容器内的完整Linux系统信息。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"query": map[string]interface{}{
"type": "string",
"description": "查询类型: info(完整信息), memory(内存), cpu(CPU), disk(磁盘)",
"enum": []string{"info", "memory", "cpu", "disk"},
},
},
},
}
}
func (t *OSSystemTool) Execute(ctx context.Context, args map[string]interface{}) (*ToolResult, error) {
info := t.manager.SystemInfo()
data, _ := json.Marshal(info)
return &ToolResult{
ToolName: "os_system",
Success: true,
Data: string(data),
}, nil
}
@@ -0,0 +1,102 @@
package tools
import (
"context"
"os"
"strings"
"testing"
"github.com/yourname/cyrene-ai/ai-core/internal/host"
)
func TestOSExecToolWSL(t *testing.T) {
distro := os.Getenv("WSL_DISTRO")
if distro == "" {
t.Skip("WSL_DISTRO not set, skipping OS tool integration test")
}
backend := host.NewWSLBackend(distro, "cyrene", "test123", 30e9)
mgr := host.NewManager(backend)
// Test os_exec
t.Run("os_exec", func(t *testing.T) {
tool := NewOSExecTool(mgr)
def := tool.Definition()
if def.Name != "os_exec" {
t.Fatalf("unexpected name: %s", def.Name)
}
result, err := tool.Execute(context.Background(), map[string]interface{}{
"command": "echo 'os_exec works!' && uname -a",
})
if err != nil {
t.Fatalf("execute error: %v", err)
}
if !result.Success {
t.Fatalf("exec failed: %s", result.Error)
}
if !strings.Contains(result.Data, "os_exec works!") {
t.Fatalf("unexpected output: %s", result.Data)
}
t.Logf("os_exec OK: data len=%d", len(result.Data))
})
// Test os_file
t.Run("os_file", func(t *testing.T) {
tool := NewOSFileTool(mgr)
def := tool.Definition()
if def.Name != "os_file" {
t.Fatalf("unexpected name: %s", def.Name)
}
// Write
r, err := tool.Execute(context.Background(), map[string]interface{}{
"action": "write",
"path": "/tmp/cyrene-os-tool-test.txt",
"content": "OS tool integration test",
})
if err != nil || !r.Success {
t.Fatalf("os_file write failed: err=%v, errMsg=%s", err, r.Error)
}
// Read
r, err = tool.Execute(context.Background(), map[string]interface{}{
"action": "read",
"path": "/tmp/cyrene-os-tool-test.txt",
})
if err != nil || !r.Success {
t.Fatalf("os_file read failed: err=%v, errMsg=%s", err, r.Error)
}
if !strings.Contains(r.Data, "OS tool integration test") {
t.Fatalf("content mismatch: %s", r.Data)
}
// List
r, err = tool.Execute(context.Background(), map[string]interface{}{
"action": "list",
"path": "/tmp",
})
if err != nil || !r.Success {
t.Fatalf("os_file list failed: err=%v, errMsg=%s", err, r.Error)
}
t.Logf("os_file OK: write+read+list all pass")
})
// Test os_system
t.Run("os_system", func(t *testing.T) {
tool := NewOSSystemTool(mgr)
def := tool.Definition()
if def.Name != "os_system" {
t.Fatalf("unexpected name: %s", def.Name)
}
result, err := tool.Execute(context.Background(), map[string]interface{}{})
if err != nil {
t.Fatalf("execute error: %v", err)
}
if !result.Success {
t.Fatalf("os_system failed: %s", result.Error)
}
if !strings.Contains(result.Data, "wsl") {
t.Fatalf("expected wsl backend info: %s", result.Data)
}
t.Logf("os_system OK: data len=%d", len(result.Data))
})
}
@@ -13,7 +13,7 @@ func TestHostExecToolDefinition(t *testing.T) {
cfg := host.DefaultSandboxConfig()
cfg.AllowedDirs = []string{os.TempDir()}
sandbox := host.NewSandbox(cfg)
mgr := host.NewManager(sandbox)
mgr := host.NewManager(host.NewDirectBackend(sandbox))
tool := NewHostExecTool(mgr)
def := tool.Definition()
@@ -40,7 +40,7 @@ func TestHostFileToolDefinition(t *testing.T) {
tmpDir := os.TempDir()
cfg.AllowedDirs = []string{tmpDir}
sandbox := host.NewSandbox(cfg)
mgr := host.NewManager(sandbox)
mgr := host.NewManager(host.NewDirectBackend(sandbox))
mgr.SetAllowedDirs([]string{tmpDir})
tool := NewHostFileTool(mgr)
@@ -67,7 +67,7 @@ func TestHostFileToolDefinition(t *testing.T) {
func TestHostSystemToolDefinition(t *testing.T) {
cfg := host.DefaultSandboxConfig()
sandbox := host.NewSandbox(cfg)
mgr := host.NewManager(sandbox)
mgr := host.NewManager(host.NewDirectBackend(sandbox))
tool := NewHostSystemTool(mgr)
def := tool.Definition()
@@ -40,7 +40,7 @@ func TestEncodeImageToDataURL_InvalidPath(t *testing.T) {
}
func TestVisionToolDefinition(t *testing.T) {
tool := NewVisionTool()
tool := NewVisionTool(nil)
def := tool.Definition()
if def.Name != "vision_analyze" {
t.Fatalf("unexpected tool name: %s", def.Name)
@@ -68,7 +68,7 @@ func TestVisionToolExecute(t *testing.T) {
}
defer os.Remove(tmpPath)
tool := NewVisionTool()
tool := NewVisionTool(nil)
ctx := context.Background()
result, err := tool.Execute(ctx, map[string]interface{}{
"image_path": tmpPath,