mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-13 08:25:10 +08:00
529 lines
16 KiB
Go
529 lines
16 KiB
Go
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
|
||
}
|