Files
MyGoNavi/internal/ai/provider/codebuddy_cli.go
2026-06-23 18:26:20 +08:00

529 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package provider
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"runtime"
"strings"
"time"
ai "GoNavi-Wails/internal/ai"
"GoNavi-Wails/internal/logger"
)
var codebuddyLookPath = exec.LookPath
var codebuddyCommandContext = exec.CommandContext
var codebuddyCLIRequestTimeout = 90 * time.Second
// CodeBuddyCLIProvider 通过 CodeBuddy CLI 发送聊天请求。
type CodeBuddyCLIProvider struct {
config ai.ProviderConfig
}
type codebuddySessionState struct {
SessionID string `json:"sessionId,omitempty"`
}
// NewCodeBuddyCLIProvider 创建 CodeBuddyCLIProvider 实例。
func NewCodeBuddyCLIProvider(config ai.ProviderConfig) (Provider, error) {
return &CodeBuddyCLIProvider{config: config}, nil
}
func (p *CodeBuddyCLIProvider) Name() string {
return "CodeBuddyCLI"
}
func (p *CodeBuddyCLIProvider) Validate() error {
_, err := resolveCodeBuddyCLICommand(codebuddyLookPath)
if err != nil {
return err
}
return nil
}
func (p *CodeBuddyCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
resp, _, err := p.ChatWithState(ctx, nil, req)
return resp, err
}
func (p *CodeBuddyCLIProvider) ChatWithState(ctx context.Context, state json.RawMessage, req ai.ChatRequest) (*ai.ChatResponse, json.RawMessage, error) {
if err := p.Validate(); err != nil {
return nil, nil, err
}
ctx, cancel := ensureClaudeCLITimeout(ctx, codebuddyCLIRequestTimeout)
defer cancel()
commandName, err := resolveCodeBuddyCLICommand(codebuddyLookPath)
if err != nil {
return nil, nil, err
}
sessionState, err := parseCodeBuddySessionState(state)
if err != nil {
return nil, nil, err
}
prompt := buildPrompt(req.Messages)
args := []string{"-p", prompt, "--output-format", "json", "--enable-session-tracking"}
if strings.TrimSpace(p.config.Model) != "" {
args = append(args, "--model", strings.TrimSpace(p.config.Model))
}
if strings.TrimSpace(sessionState.SessionID) != "" {
args = append(args, "--resume", strings.TrimSpace(sessionState.SessionID))
}
cmd := codebuddyCommandContext(ctx, commandName, args...)
if err := p.setEnv(cmd); err != nil {
return nil, nil, err
}
requestLog := logAIUpstreamRequestStart(
p.Name(),
"CLI",
codebuddyCLIEndpointForLog(p.config),
buildCodeBuddyCLIRequestLogBody("json", commandName, args, prompt, p.config, req),
)
var requestErr error
defer func() {
logAIUpstreamRequestFinish(requestLog, 0, requestErr)
}()
output, err := cmd.Output()
if err != nil {
if isClaudeCLITimeout(ctx, err) {
requestErr = fmt.Errorf("CodeBuddy CLI timed out (%s); the current login session, Base URL, or API Key may not be returning a valid response", codebuddyCLIRequestTimeout)
return nil, nil, requestErr
}
if exitErr, ok := err.(*exec.ExitError); ok {
requestErr = fmt.Errorf("CodeBuddy CLI execution failed: %s", string(exitErr.Stderr))
return nil, nil, requestErr
}
requestErr = fmt.Errorf("CodeBuddy CLI execution failed: %w", err)
return nil, nil, requestErr
}
resp, nextSessionID, parseErr := parseCodeBuddyCLIChatOutput(output)
if parseErr != nil {
requestErr = parseErr
return nil, nil, requestErr
}
if strings.TrimSpace(nextSessionID) == "" {
nextSessionID = strings.TrimSpace(sessionState.SessionID)
}
nextState, err := marshalCodeBuddySessionState(nextSessionID)
if err != nil {
return nil, nil, err
}
return resp, nextState, nil
}
func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {
_, err := p.ChatStreamWithState(ctx, nil, req, callback)
return err
}
func (p *CodeBuddyCLIProvider) ChatStreamWithState(ctx context.Context, state json.RawMessage, req ai.ChatRequest, callback func(ai.StreamChunk)) (json.RawMessage, error) {
sessionState, err := parseCodeBuddySessionState(state)
if err != nil {
return nil, err
}
sessionID, err := p.chatStreamWithSession(ctx, strings.TrimSpace(sessionState.SessionID), req, callback)
if err != nil {
return nil, err
}
if strings.TrimSpace(sessionID) == "" {
sessionID = strings.TrimSpace(sessionState.SessionID)
}
return marshalCodeBuddySessionState(sessionID)
}
func (p *CodeBuddyCLIProvider) chatStreamWithSession(ctx context.Context, resumeSessionID string, req ai.ChatRequest, callback func(ai.StreamChunk)) (string, error) {
if err := p.Validate(); err != nil {
return "", err
}
ctx, cancel := ensureClaudeCLITimeout(ctx, codebuddyCLIRequestTimeout)
defer cancel()
commandName, err := resolveCodeBuddyCLICommand(codebuddyLookPath)
if err != nil {
return "", err
}
prompt := buildPrompt(req.Messages)
args := []string{"-p", prompt, "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--enable-session-tracking"}
if strings.TrimSpace(p.config.Model) != "" {
args = append(args, "--model", strings.TrimSpace(p.config.Model))
}
if strings.TrimSpace(resumeSessionID) != "" {
args = append(args, "--resume", strings.TrimSpace(resumeSessionID))
}
cmd := codebuddyCommandContext(ctx, commandName, args...)
if err := p.setEnv(cmd); err != nil {
return "", err
}
requestLog := logAIUpstreamRequestStart(
p.Name(),
"CLI",
codebuddyCLIEndpointForLog(p.config),
buildCodeBuddyCLIRequestLogBody("stream-json", commandName, args, prompt, p.config, req),
)
var requestErr error
defer func() {
logAIUpstreamRequestFinish(requestLog, 0, requestErr)
}()
cmd.Stdin = nil
stdout, err := cmd.StdoutPipe()
if err != nil {
requestErr = fmt.Errorf("create stdout pipe failed: %w", err)
return "", requestErr
}
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
requestErr = fmt.Errorf("start CodeBuddy CLI failed: %w", err)
return "", requestErr
}
if cmd.Process != nil {
logger.Infof("CodeBuddyCLI 请求进程已启动requestId=%s pid=%d", requestLog.id, cmd.Process.Pid)
}
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
currentSessionID := strings.TrimSpace(resumeSessionID)
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "" {
continue
}
var event cliStreamEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
logger.Warnf("CodeBuddyCLI 忽略非 JSON 输出requestId=%s line=%s", requestLog.id, RedactAIUpstreamLogText(line))
continue
}
if strings.TrimSpace(event.SessionID) != "" {
currentSessionID = strings.TrimSpace(event.SessionID)
}
switch event.Type {
case "system":
if isCodeBuddyCLISystemRetryEvent(event) {
if errMsg, hasError := extractCodeBuddyCLISystemRetryError(event); hasError {
callback(ai.StreamChunk{Error: errMsg, Done: true})
requestErr = fmt.Errorf("CodeBuddy CLI authentication failed: %s", errMsg)
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
return "", nil
}
}
case "assistant":
if errMsg, hasError := extractCodeBuddyCLIEventError(event); hasError {
callback(ai.StreamChunk{Error: errMsg, Done: true})
requestErr = fmt.Errorf("CodeBuddy CLI returned an error: %s", errMsg)
_ = cmd.Wait()
return "", nil
}
if event.Message.Content != nil {
for _, block := range event.Message.Content {
if block.Type == "thinking" && block.Thinking != "" {
callback(ai.StreamChunk{Thinking: block.Thinking})
} else if block.Type == "text" && block.Text != "" {
callback(ai.StreamChunk{Content: block.Text})
}
}
}
case "content_block_delta":
if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
callback(ai.StreamChunk{Thinking: event.Delta.Thinking})
} else if event.Delta.Text != "" {
callback(ai.StreamChunk{Content: event.Delta.Text})
}
case "result":
if errMsg, hasError := extractCodeBuddyCLIEventError(event); hasError {
callback(ai.StreamChunk{Error: errMsg, Done: true})
requestErr = fmt.Errorf("CodeBuddy CLI returned an error: %s", errMsg)
_ = cmd.Wait()
return "", nil
}
callback(ai.StreamChunk{Done: true})
_ = cmd.Wait()
return currentSessionID, nil
case "error":
errMsg, _ := extractCodeBuddyCLIEventError(event)
callback(ai.StreamChunk{Error: errMsg, Done: true})
requestErr = fmt.Errorf("CodeBuddy CLI returned an error: %s", errMsg)
_ = cmd.Wait()
return "", nil
}
}
waitErr := cmd.Wait()
stderrStr := strings.TrimSpace(stderrBuf.String())
if isClaudeCLITimeout(ctx, waitErr) {
requestErr = fmt.Errorf("CodeBuddy CLI timed out (%s); the current login session, Base URL, or API Key may not be returning a valid response", codebuddyCLIRequestTimeout)
callback(ai.StreamChunk{
Error: requestErr.Error(),
Done: true,
})
return "", nil
}
if waitErr != nil {
errMsg := fmt.Sprintf("CodeBuddy CLI exited unexpectedly: %v", waitErr)
if stderrStr != "" {
errMsg = fmt.Sprintf("CodeBuddy CLI exited unexpectedly: %s", stderrStr)
}
requestErr = fmt.Errorf("%s", errMsg)
callback(ai.StreamChunk{Error: errMsg, Done: true})
return "", nil
}
callback(ai.StreamChunk{Done: true})
return currentSessionID, nil
}
func parseCodeBuddySessionState(state json.RawMessage) (codebuddySessionState, error) {
trimmed := bytes.TrimSpace(state)
if len(trimmed) == 0 {
return codebuddySessionState{}, nil
}
var sessionState codebuddySessionState
if err := json.Unmarshal(trimmed, &sessionState); err != nil {
return codebuddySessionState{}, fmt.Errorf("parse CodeBuddy session state failed: %w", err)
}
sessionState.SessionID = strings.TrimSpace(sessionState.SessionID)
return sessionState, nil
}
func marshalCodeBuddySessionState(sessionID string) (json.RawMessage, error) {
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return nil, nil
}
payload, err := json.Marshal(codebuddySessionState{SessionID: sessionID})
if err != nil {
return nil, fmt.Errorf("serialize CodeBuddy session state failed: %w", err)
}
return json.RawMessage(payload), nil
}
func resolveCodeBuddyCLICommand(lookPath func(string) (string, error)) (string, error) {
for _, command := range []string{"codebuddy", "cbc"} {
if _, err := lookPath(command); err == nil {
return command, nil
}
}
return "", fmt.Errorf("CodeBuddy CLI command not found. Install it first: npm install -g @tencent/codebuddy")
}
func codebuddyCLIEndpointForLog(config ai.ProviderConfig) string {
baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
if baseURL != "" {
return sanitizeAIUpstreamURL(baseURL)
}
return "codebuddy://cli"
}
func buildCodeBuddyCLIRequestLogBody(outputFormat string, commandName string, args []string, prompt string, config ai.ProviderConfig, req ai.ChatRequest) map[string]any {
return map[string]any{
"command": commandName,
"args": claudeCLIArgsForLog(args),
"prompt": prompt,
"output_format": outputFormat,
"model": strings.TrimSpace(config.Model),
"base_url": codebuddyCLIEndpointForLog(config),
"has_api_key": strings.TrimSpace(config.APIKey) != "",
"message_count": len(req.Messages),
"tool_count": len(req.Tools),
"tool_names": claudeCLIToolNamesForLog(req.Tools),
}
}
func parseCodeBuddyCLIChatOutput(output []byte) (*ai.ChatResponse, string, error) {
trimmed := bytes.TrimSpace(output)
if len(trimmed) == 0 {
return &ai.ChatResponse{}, "", nil
}
var events []cliStreamEvent
if err := json.Unmarshal(trimmed, &events); err == nil && len(events) > 0 {
return buildCodeBuddyCLIResponseFromEvents(events)
}
var event cliStreamEvent
if err := json.Unmarshal(trimmed, &event); err == nil {
return buildCodeBuddyCLIResponseFromEvents([]cliStreamEvent{event})
}
return &ai.ChatResponse{Content: strings.TrimSpace(string(output))}, "", nil
}
func buildCodeBuddyCLIResponseFromEvents(events []cliStreamEvent) (*ai.ChatResponse, string, error) {
parts := make([]string, 0, len(events))
resultText := ""
sessionID := ""
for _, event := range events {
if errMsg, hasError := extractCodeBuddyCLIEventError(event); hasError {
return nil, "", fmt.Errorf("CodeBuddy CLI returned an error: %s", errMsg)
}
if strings.TrimSpace(event.Result) != "" {
resultText = strings.TrimSpace(event.Result)
}
if strings.TrimSpace(event.SessionID) != "" {
sessionID = strings.TrimSpace(event.SessionID)
}
for _, block := range event.Message.Content {
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
parts = append(parts, block.Text)
}
}
}
if resultText != "" {
return &ai.ChatResponse{Content: resultText}, sessionID, nil
}
if len(parts) > 0 {
return &ai.ChatResponse{Content: strings.Join(parts, "")}, sessionID, nil
}
return &ai.ChatResponse{}, sessionID, nil
}
func (p *CodeBuddyCLIProvider) setEnv(cmd *exec.Cmd) error {
env, err := buildCodeBuddyCLIEnv(p.config, cmd.Environ(), runtime.GOOS, codebuddyLookPath, fileExists)
if err != nil {
return err
}
cmd.Env = env
return nil
}
func buildCodeBuddyCLIEnv(config ai.ProviderConfig, baseEnv []string, goos string, lookPath func(string) (string, error), exists func(string) bool) ([]string, error) {
env := append([]string(nil), baseEnv...)
if strings.TrimSpace(config.BaseURL) != "" {
env = upsertEnv(env, "CODEBUDDY_BASE_URL", strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"))
}
if strings.TrimSpace(config.APIKey) != "" {
env = upsertEnv(env, "CODEBUDDY_API_KEY", strings.TrimSpace(config.APIKey))
env = upsertEnv(env, "CODEBUDDY_AUTH_TOKEN", strings.TrimSpace(config.APIKey))
}
if len(config.Headers) > 0 {
if payload, err := json.Marshal(config.Headers); err == nil {
env = upsertEnv(env, "CODEBUDDY_CUSTOM_HEADERS", string(payload))
}
}
gitBashPath, err := resolveCodeBuddyGitBashPath(env, goos, lookPath, exists)
if err != nil {
return nil, err
}
if gitBashPath != "" {
env = upsertEnv(env, "CODEBUDDY_CODE_GIT_BASH_PATH", gitBashPath)
}
return env, nil
}
func resolveCodeBuddyGitBashPath(env []string, goos string, lookPath func(string) (string, error), exists func(string) bool) (string, error) {
if goos != "windows" {
return "", nil
}
if configured := strings.TrimSpace(envValue(env, "CODEBUDDY_CODE_GIT_BASH_PATH")); configured != "" {
if exists(configured) {
return configured, nil
}
return "", fmt.Errorf("Configured CODEBUDDY_CODE_GIT_BASH_PATH does not exist on Windows: %s", configured)
}
for _, command := range []string{"bash.exe", "bash"} {
if bashPath, err := lookPath(command); err == nil && exists(bashPath) {
return bashPath, nil
}
}
if gitPath, err := lookPath("git.exe"); err == nil {
gitDir := parentWindowsPath(gitPath)
for _, candidate := range []string{
joinWindowsPath(parentWindowsPath(gitDir), "bin", "bash.exe"),
joinWindowsPath(gitDir, "bash.exe"),
} {
if candidate != "" && exists(candidate) {
return candidate, nil
}
}
}
for _, candidate := range windowsGitBashCandidates(env) {
if exists(candidate) {
return candidate, nil
}
}
return "", nil
}
func extractCodeBuddyCLIEventError(event cliStreamEvent) (string, bool) {
if event.Type != "error" && !event.IsError {
return "", false
}
if msg := strings.TrimSpace(event.Result); msg != "" {
return msg, true
}
for _, block := range event.Message.Content {
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
return strings.TrimSpace(block.Text), true
}
}
if msg := strings.TrimSpace(event.Error.Message); msg != "" {
return msg, true
}
return "CodeBuddy CLI returned an unknown error", true
}
func isCodeBuddyCLISystemRetryEvent(event cliStreamEvent) bool {
return event.Type == "system" && event.Subtype == "api_retry"
}
func extractCodeBuddyCLISystemRetryError(event cliStreamEvent) (string, bool) {
if !isCodeBuddyCLISystemRetryEvent(event) {
return "", false
}
errText := strings.TrimSpace(event.Error.Message)
if event.ErrorStatus != 401 && event.ErrorStatus != 403 && !strings.EqualFold(errText, "authentication_failed") {
return "", false
}
if errText == "" {
errText = "authentication_failed"
}
if event.ErrorStatus > 0 {
return fmt.Sprintf("CodeBuddy CLI authentication failed (HTTP %d): %s", event.ErrorStatus, errText), true
}
return fmt.Sprintf("CodeBuddy CLI authentication failed: %s", errText), true
}