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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user