From f762e9538821f554e5617ce4dd0057120fd7c496 Mon Sep 17 00:00:00 2001 From: niupilot Date: Fri, 5 Sep 2025 12:28:38 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20DeepSeek=20v3.1=20?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 DeepSeek 配置结构和环境变量支持 - 实现 DeepSeek 本地 HTTP API 集成(deepseek_local.go) - 实现 DeepSeek Docker 容器集成(deepseek_docker.go) - 支持 OpenAI 兼容的 API 格式 - 更新配置示例文件包含 DeepSeek 选项 - 添加单元测试验证功能 - 默认配置:api.deepseek.com, deepseek-chat 模型 Generated with [codeagent](https://github.com/qiniu/codeagent) Co-Authored-By: niupilot --- config.example.yaml | 9 +- internal/code/code.go | 10 +- internal/code/deepseek_docker.go | 164 +++++++++++++++++++++++++++++++ internal/code/deepseek_local.go | 143 +++++++++++++++++++++++++++ internal/code/deepseek_test.go | 63 ++++++++++++ internal/config/config.go | 25 +++++ 6 files changed, 411 insertions(+), 3 deletions(-) create mode 100644 internal/code/deepseek_docker.go create mode 100644 internal/code/deepseek_local.go create mode 100644 internal/code/deepseek_test.go diff --git a/config.example.yaml b/config.example.yaml index 03991cf..d391677 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -27,12 +27,19 @@ gemini: container_image: google-gemini/gemini-cli:latest timeout: 30m +deepseek: + api_key: your-deepseek-api-key-here + base_url: https://api.deepseek.com # Optional, defaults to official API address + model: deepseek-chat # Optional, defaults to deepseek-chat + container_image: codeagent/deepseek-cli:latest + timeout: 30m + docker: socket: unix:///var/run/docker.sock network: bridge # Code provider configuration -code_provider: claude # Options: claude, gemini +code_provider: claude # Options: claude, gemini, deepseek use_docker: true # Whether to use Docker, false means use local CLI # AI mention configuration diff --git a/internal/code/code.go b/internal/code/code.go index 68a1b3c..83d6861 100644 --- a/internal/code/code.go +++ b/internal/code/code.go @@ -9,8 +9,9 @@ import ( ) const ( - ProviderClaude = "claude" - ProviderGemini = "gemini" + ProviderClaude = "claude" + ProviderGemini = "gemini" + ProviderDeepSeek = "deepseek" ) type Response struct { @@ -48,6 +49,11 @@ func New(workspace *models.Workspace, cfg *config.Config) (Code, error) { return NewGeminiDocker(workspace, cfg) } return NewGeminiLocal(workspace, cfg) + case ProviderDeepSeek: + if cfg.UseDocker { + return NewDeepSeekDocker(workspace, cfg) + } + return NewDeepSeekLocal(workspace, cfg) default: return nil, fmt.Errorf("unsupported code provider: %s", provider) } diff --git a/internal/code/deepseek_docker.go b/internal/code/deepseek_docker.go new file mode 100644 index 0000000..30bd574 --- /dev/null +++ b/internal/code/deepseek_docker.go @@ -0,0 +1,164 @@ +package code + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/qiniu/codeagent/internal/config" + "github.com/qiniu/codeagent/pkg/models" + "github.com/qiniu/x/log" +) + +// deepSeekDocker Docker 实现 +type deepSeekDocker struct { + containerName string +} + +// NewDeepSeekDocker 创建 Docker DeepSeek 实现 +func NewDeepSeekDocker(workspace *models.Workspace, cfg *config.Config) (Code, error) { + if cfg.DeepSeek.APIKey == "" { + return nil, fmt.Errorf("DEEPSEEK_API_KEY is required") + } + + // 解析仓库信息,只获取仓库名,不包含完整URL + repoName := extractRepoName(workspace.Repository) + + // Generate unique container name using shared function + containerName := generateContainerName("deepseek", workspace.Org, repoName, workspace) + + // 检查是否已经有对应的容器在运行 + if isContainerRunning(containerName) { + log.Infof("Found existing container: %s, reusing it", containerName) + return &deepSeekDocker{ + containerName: containerName, + }, nil + } + + // 确保路径存在 + workspacePath, err := filepath.Abs(workspace.Path) + if err != nil { + return nil, fmt.Errorf("failed to get absolute workspace path: %w", err) + } + + sessionPath, err := filepath.Abs(workspace.SessionPath) + if err != nil { + return nil, fmt.Errorf("failed to get absolute session path: %w", err) + } + + // 检查是否使用了/tmp目录(在macOS上可能导致挂载问题) + if strings.HasPrefix(workspacePath, "/tmp/") { + log.Warnf("Warning: Using /tmp directory may cause mount issues on macOS. Consider using other path instead.") + log.Warnf("Current workspace path: %s", workspacePath) + } + + if strings.HasPrefix(sessionPath, "/tmp/") { + log.Warnf("Warning: Using /tmp directory may cause mount issues on macOS. Consider using other path instead.") + log.Warnf("Current session path: %s", sessionPath) + } + + // 检查路径是否存在 + if _, err := os.Stat(workspacePath); os.IsNotExist(err) { + log.Errorf("Workspace path does not exist: %s", workspacePath) + return nil, fmt.Errorf("workspace path does not exist: %s", workspacePath) + } + + if _, err := os.Stat(sessionPath); os.IsNotExist(err) { + log.Errorf("Session path does not exist: %s", sessionPath) + return nil, fmt.Errorf("session path does not exist: %s", sessionPath) + } + + // 构建 Docker 命令 + args := []string{ + "run", + "--rm", // 容器运行完后自动删除 + "-d", // 后台运行 + "--name", containerName, // 设置容器名称 + "-e", "DEEPSEEK_API_KEY=" + cfg.DeepSeek.APIKey, + "-e", "DEEPSEEK_BASE_URL=" + cfg.DeepSeek.BaseURL, + "-e", "DEEPSEEK_MODEL=" + cfg.DeepSeek.Model, + "-v", fmt.Sprintf("%s:/workspace", workspacePath), // 挂载工作空间 + "-v", fmt.Sprintf("%s:/tmp/session", sessionPath), // 挂载临时目录 + "-w", "/workspace", // 设置工作目录 + } + + // Mount processed .codeagent directory if available + if workspace.ProcessedCodeAgentPath != "" { + if _, err := os.Stat(workspace.ProcessedCodeAgentPath); err == nil { + args = append(args, "-v", fmt.Sprintf("%s:/workspace/.codeagent", workspace.ProcessedCodeAgentPath)) + log.Infof("Mounting processed .codeagent directory: %s -> /workspace/.codeagent", workspace.ProcessedCodeAgentPath) + } else { + log.Warnf("Processed .codeagent directory not found: %s", workspace.ProcessedCodeAgentPath) + } + } + + // Add container image + args = append(args, cfg.DeepSeek.ContainerImage) + + // 打印调试信息 + log.Infof("Docker command: docker %s", strings.Join(args, " ")) + + cmd := exec.Command("docker", args...) + + // 捕获命令输出 + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + log.Errorf("Failed to start Docker container: %v", err) + log.Errorf("Docker stderr: %s", stderr.String()) + return nil, fmt.Errorf("failed to start Docker container: %w", err) + } + + // 等待命令完成 + if err := cmd.Wait(); err != nil { + log.Errorf("docker container failed: %v", err) + log.Errorf("docker stdout: %s", stdout.String()) + log.Errorf("docker stderr: %s", stderr.String()) + return nil, fmt.Errorf("docker container failed: %w", err) + } + + log.Infof("docker container started successfully") + + return &deepSeekDocker{ + containerName: containerName, + }, nil +} + +// Prompt 实现 Code 接口 +func (d *deepSeekDocker) Prompt(message string) (*Response, error) { + // 使用内置的 deepseek-cli 工具(假设容器内有这个工具) + args := []string{ + "exec", + d.containerName, + "deepseek-cli", + "--prompt", + message, + } + + cmd := exec.Command("docker", args...) + + log.Infof("Executing DeepSeek CLI with docker: %s", strings.Join(args, " ")) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + + // 启动命令 + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("failed to execute deepseek: %w", err) + } + + return &Response{Out: stdout}, nil +} + +// Close 实现 Code 接口 +func (d *deepSeekDocker) Close() error { + stopCmd := exec.Command("docker", "rm", "-f", d.containerName) + return stopCmd.Run() +} \ No newline at end of file diff --git a/internal/code/deepseek_local.go b/internal/code/deepseek_local.go new file mode 100644 index 0000000..bf300b9 --- /dev/null +++ b/internal/code/deepseek_local.go @@ -0,0 +1,143 @@ +package code + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/qiniu/codeagent/internal/config" + "github.com/qiniu/codeagent/pkg/models" + "github.com/qiniu/x/log" +) + +// deepSeekLocal 本地 HTTP API 实现 +type deepSeekLocal struct { + workspace *models.Workspace + config *config.Config + client *http.Client +} + +// NewDeepSeekLocal 创建本地 DeepSeek API 实现 +func NewDeepSeekLocal(workspace *models.Workspace, cfg *config.Config) (Code, error) { + if cfg.DeepSeek.APIKey == "" { + return nil, fmt.Errorf("DEEPSEEK_API_KEY is required") + } + + client := &http.Client{ + Timeout: cfg.DeepSeek.Timeout, + } + + return &deepSeekLocal{ + workspace: workspace, + config: cfg, + client: client, + }, nil +} + +type deepSeekRequest struct { + Model string `json:"model"` + Messages []deepSeekMessage `json:"messages"` + Stream bool `json:"stream"` +} + +type deepSeekMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type deepSeekResponse struct { + Choices []struct { + Message deepSeekMessage `json:"message"` + } `json:"choices"` +} + +// Prompt 实现 Code 接口 - HTTP API 版本 +func (d *deepSeekLocal) Prompt(message string) (*Response, error) { + // 构建请求 + reqBody := deepSeekRequest{ + Model: d.config.DeepSeek.Model, + Messages: []deepSeekMessage{ + { + Role: "user", + Content: message, + }, + }, + Stream: false, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + // 设置超时 + timeout := d.config.DeepSeek.Timeout + if timeout == 0 { + timeout = 5 * time.Minute + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + // 创建 HTTP 请求 + req, err := http.NewRequestWithContext(ctx, "POST", d.config.DeepSeek.BaseURL+"/chat/completions", bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // 设置请求头 + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+d.config.DeepSeek.APIKey) + + log.Infof("Executing DeepSeek API call to %s with model %s", d.config.DeepSeek.BaseURL, d.config.DeepSeek.Model) + + // 发送请求 + resp, err := d.client.Do(req) + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + log.Warnf("DeepSeek API call timed out after %s", timeout) + return nil, fmt.Errorf("deepseek API call timed out: %w", err) + } + return nil, fmt.Errorf("failed to call DeepSeek API: %w", err) + } + defer resp.Body.Close() + + // 读取响应 + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // 检查响应状态码 + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("DeepSeek API returned status %d: %s", resp.StatusCode, string(body)) + } + + // 解析响应 + var deepSeekResp deepSeekResponse + if err := json.Unmarshal(body, &deepSeekResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + if len(deepSeekResp.Choices) == 0 { + return nil, fmt.Errorf("no choices in DeepSeek response") + } + + // 获取生成的内容 + content := deepSeekResp.Choices[0].Message.Content + + log.Infof("DeepSeek API call completed successfully") + return &Response{ + Out: strings.NewReader(content), + }, nil +} + +// Close 实现 Code 接口 +func (d *deepSeekLocal) Close() error { + // HTTP 客户端不需要特殊的清理 + return nil +} \ No newline at end of file diff --git a/internal/code/deepseek_test.go b/internal/code/deepseek_test.go new file mode 100644 index 0000000..85b960d --- /dev/null +++ b/internal/code/deepseek_test.go @@ -0,0 +1,63 @@ +package code + +import ( + "testing" + "time" + + "github.com/qiniu/codeagent/internal/config" + "github.com/qiniu/codeagent/pkg/models" +) + +func TestNewDeepSeekLocal(t *testing.T) { + workspace := &models.Workspace{ + Path: "/tmp/test", + } + + cfg := &config.Config{ + DeepSeek: config.DeepSeekConfig{ + APIKey: "test-key", + BaseURL: "https://api.deepseek.com", + Model: "deepseek-chat", + Timeout: 30 * time.Minute, + }, + } + + deepseek, err := NewDeepSeekLocal(workspace, cfg) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if deepseek == nil { + t.Fatal("Expected deepseek instance, got nil") + } + + // Test Close + err = deepseek.Close() + if err != nil { + t.Fatalf("Expected no error on close, got %v", err) + } +} + +func TestNewDeepSeekLocalMissingAPIKey(t *testing.T) { + workspace := &models.Workspace{ + Path: "/tmp/test", + } + + cfg := &config.Config{ + DeepSeek: config.DeepSeekConfig{ + BaseURL: "https://api.deepseek.com", + Model: "deepseek-chat", + Timeout: 30 * time.Minute, + }, + } + + _, err := NewDeepSeekLocal(workspace, cfg) + if err == nil { + t.Fatal("Expected error for missing API key, got nil") + } + + expectedErr := "DEEPSEEK_API_KEY is required" + if err.Error() != expectedErr { + t.Fatalf("Expected error '%s', got '%s'", expectedErr, err.Error()) + } +} \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index b126f90..005b8bd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { Workspace WorkspaceConfig `yaml:"workspace"` Claude ClaudeConfig `yaml:"claude"` Gemini GeminiConfig `yaml:"gemini"` + DeepSeek DeepSeekConfig `yaml:"deepseek"` Docker DockerConfig `yaml:"docker"` CodeProvider string `yaml:"code_provider"` UseDocker bool `yaml:"use_docker"` @@ -36,6 +37,14 @@ type GeminiConfig struct { GoogleCloudProject string `yaml:"google_cloud_project"` } +type DeepSeekConfig struct { + APIKey string `yaml:"api_key"` + BaseURL string `yaml:"base_url"` + Model string `yaml:"model"` + ContainerImage string `yaml:"container_image"` + Timeout time.Duration `yaml:"timeout"` +} + type ServerConfig struct { Port int `yaml:"port"` WebhookSecret string `yaml:"webhook_secret"` @@ -152,6 +161,15 @@ func (c *Config) loadFromEnv() { if project := os.Getenv("GOOGLE_CLOUD_PROJECT"); project != "" { c.Gemini.GoogleCloudProject = project } + if apiKey := os.Getenv("DEEPSEEK_API_KEY"); apiKey != "" { + c.DeepSeek.APIKey = apiKey + } + if baseURL := os.Getenv("DEEPSEEK_BASE_URL"); baseURL != "" { + c.DeepSeek.BaseURL = baseURL + } + if model := os.Getenv("DEEPSEEK_MODEL"); model != "" { + c.DeepSeek.Model = model + } if provider := os.Getenv("CODE_PROVIDER"); provider != "" { c.CodeProvider = provider } else { @@ -236,6 +254,13 @@ func loadFromEnv() *Config { Timeout: 30 * time.Minute, GoogleCloudProject: os.Getenv("GOOGLE_CLOUD_PROJECT"), }, + DeepSeek: DeepSeekConfig{ + APIKey: os.Getenv("DEEPSEEK_API_KEY"), + BaseURL: getEnvOrDefault("DEEPSEEK_BASE_URL", "https://api.deepseek.com"), + Model: getEnvOrDefault("DEEPSEEK_MODEL", "deepseek-chat"), + ContainerImage: getEnvOrDefault("DEEPSEEK_IMAGE", "codeagent/deepseek-cli:latest"), + Timeout: 30 * time.Minute, + }, Docker: DockerConfig{ Socket: getEnvOrDefault("DOCKER_SOCKET", "unix:///var/run/docker.sock"), Network: getEnvOrDefault("DOCKER_NETWORK", "bridge"),