Files
MyGoNavi/internal/ai/provider/codebuddy_cli.go
Syngnat 06dd9507ee feat(ai): 补齐 Cursor 与 CodeBuddy 会话态聊天链路
- 新增 SessionChatProvider 接口,补齐非流式对话的会话态复用能力
- 为 Cursor Agent 和 CodeBuddy CLI 同步实现流式与非流式会话续接及状态持久化
- CustomProvider 补充会话态透传,统一 custom provider 的会话复用行为
- Service 新增 AIChatSendInSession,聊天主链路非流式回退改走带 session 的发送接口
- 保留原 AIChatSend 无状态语义,避免标题生成和记忆压缩污染主会话上下文
- 补充前后端定向测试,覆盖会话恢复、续接发送和前端回退分流
2026-06-18 13:35:08 +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 执行超时(%s当前登录态、Base URL 或 API Key 可能没有返回有效响应", codebuddyCLIRequestTimeout)
return nil, nil, requestErr
}
if exitErr, ok := err.(*exec.ExitError); ok {
requestErr = fmt.Errorf("CodeBuddy CLI 执行失败: %s", string(exitErr.Stderr))
return nil, nil, requestErr
}
requestErr = fmt.Errorf("CodeBuddy CLI 执行失败: %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("创建 stdout 管道失败: %w", err)
return "", requestErr
}
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
requestErr = fmt.Errorf("启动 CodeBuddy CLI 失败: %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 鉴权失败: %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 返回错误: %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 返回错误: %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 返回错误: %s", errMsg)
_ = cmd.Wait()
return "", nil
}
}
waitErr := cmd.Wait()
stderrStr := strings.TrimSpace(stderrBuf.String())
if isClaudeCLITimeout(ctx, waitErr) {
requestErr = fmt.Errorf("CodeBuddy CLI 执行超时(%s当前登录态、Base URL 或 API Key 可能没有返回有效响应", codebuddyCLIRequestTimeout)
callback(ai.StreamChunk{
Error: requestErr.Error(),
Done: true,
})
return "", nil
}
if waitErr != nil {
errMsg := fmt.Sprintf("CodeBuddy CLI 异常退出: %v", waitErr)
if stderrStr != "" {
errMsg = fmt.Sprintf("CodeBuddy CLI 异常退出: %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("解析 CodeBuddy 会话状态失败: %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("序列化 CodeBuddy 会话状态失败: %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 命令,请先安装 CodeBuddy CLI: 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 返回错误: %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("CodeBuddy CLI 在 Windows 下配置的 CODEBUDDY_CODE_GIT_BASH_PATH 不存在: %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 返回未知错误", 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 鉴权失败 (HTTP %d): %s", event.ErrorStatus, errText), true
}
return fmt.Sprintf("CodeBuddy CLI 鉴权失败: %s", errText), true
}