mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-08 15:43:42 +08:00
✨ feat(ai): 补齐 Cursor 与 CodeBuddy 会话态聊天链路
- 新增 SessionChatProvider 接口,补齐非流式对话的会话态复用能力 - 为 Cursor Agent 和 CodeBuddy CLI 同步实现流式与非流式会话续接及状态持久化 - CustomProvider 补充会话态透传,统一 custom provider 的会话复用行为 - Service 新增 AIChatSendInSession,聊天主链路非流式回退改走带 session 的发送接口 - 保留原 AIChatSend 无状态语义,避免标题生成和记忆压缩污染主会话上下文 - 补充前后端定向测试,覆盖会话恢复、续接发送和前端回退分流
This commit is contained in:
@@ -24,6 +24,10 @@ 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
|
||||
@@ -42,8 +46,13 @@ func (p *CodeBuddyCLIProvider) Validate() error {
|
||||
}
|
||||
|
||||
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, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := ensureClaudeCLITimeout(ctx, codebuddyCLIRequestTimeout)
|
||||
@@ -51,18 +60,25 @@ func (p *CodeBuddyCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*a
|
||||
|
||||
commandName, err := resolveCodeBuddyCLICommand(codebuddyLookPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
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", "--no-session-persistence"}
|
||||
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, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
requestLog := logAIUpstreamRequestStart(
|
||||
@@ -80,27 +96,55 @@ func (p *CodeBuddyCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*a
|
||||
if err != nil {
|
||||
if isClaudeCLITimeout(ctx, err) {
|
||||
requestErr = fmt.Errorf("CodeBuddy CLI 执行超时(%s),当前登录态、Base URL 或 API Key 可能没有返回有效响应", codebuddyCLIRequestTimeout)
|
||||
return nil, requestErr
|
||||
return nil, nil, requestErr
|
||||
}
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
requestErr = fmt.Errorf("CodeBuddy CLI 执行失败: %s", string(exitErr.Stderr))
|
||||
return nil, requestErr
|
||||
return nil, nil, requestErr
|
||||
}
|
||||
requestErr = fmt.Errorf("CodeBuddy CLI 执行失败: %w", err)
|
||||
return nil, requestErr
|
||||
return nil, nil, requestErr
|
||||
}
|
||||
|
||||
resp, parseErr := parseCodeBuddyCLIChatOutput(output)
|
||||
resp, nextSessionID, parseErr := parseCodeBuddyCLIChatOutput(output)
|
||||
if parseErr != nil {
|
||||
requestErr = parseErr
|
||||
return nil, requestErr
|
||||
return nil, nil, requestErr
|
||||
}
|
||||
return resp, nil
|
||||
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
|
||||
return "", err
|
||||
}
|
||||
|
||||
ctx, cancel := ensureClaudeCLITimeout(ctx, codebuddyCLIRequestTimeout)
|
||||
@@ -108,18 +152,21 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques
|
||||
|
||||
commandName, err := resolveCodeBuddyCLICommand(codebuddyLookPath)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
prompt := buildPrompt(req.Messages)
|
||||
args := []string{"-p", prompt, "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--no-session-persistence"}
|
||||
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
|
||||
return "", err
|
||||
}
|
||||
|
||||
requestLog := logAIUpstreamRequestStart(
|
||||
@@ -138,7 +185,7 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
requestErr = fmt.Errorf("创建 stdout 管道失败: %w", err)
|
||||
return requestErr
|
||||
return "", requestErr
|
||||
}
|
||||
|
||||
var stderrBuf bytes.Buffer
|
||||
@@ -146,7 +193,7 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
requestErr = fmt.Errorf("启动 CodeBuddy CLI 失败: %w", err)
|
||||
return requestErr
|
||||
return "", requestErr
|
||||
}
|
||||
|
||||
if cmd.Process != nil {
|
||||
@@ -155,6 +202,7 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
currentSessionID := strings.TrimSpace(resumeSessionID)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
@@ -167,6 +215,9 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques
|
||||
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":
|
||||
@@ -178,7 +229,7 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
return nil
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
case "assistant":
|
||||
@@ -186,7 +237,7 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques
|
||||
callback(ai.StreamChunk{Error: errMsg, Done: true})
|
||||
requestErr = fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg)
|
||||
_ = cmd.Wait()
|
||||
return nil
|
||||
return "", nil
|
||||
}
|
||||
if event.Message.Content != nil {
|
||||
for _, block := range event.Message.Content {
|
||||
@@ -208,17 +259,17 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques
|
||||
callback(ai.StreamChunk{Error: errMsg, Done: true})
|
||||
requestErr = fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg)
|
||||
_ = cmd.Wait()
|
||||
return nil
|
||||
return "", nil
|
||||
}
|
||||
callback(ai.StreamChunk{Done: true})
|
||||
_ = cmd.Wait()
|
||||
return nil
|
||||
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
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +282,7 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques
|
||||
Error: requestErr.Error(),
|
||||
Done: true,
|
||||
})
|
||||
return nil
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if waitErr != nil {
|
||||
@@ -241,11 +292,38 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques
|
||||
}
|
||||
requestErr = fmt.Errorf("%s", errMsg)
|
||||
callback(ai.StreamChunk{Error: errMsg, Done: true})
|
||||
return nil
|
||||
return "", nil
|
||||
}
|
||||
|
||||
callback(ai.StreamChunk{Done: true})
|
||||
return nil
|
||||
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) {
|
||||
@@ -280,10 +358,10 @@ func buildCodeBuddyCLIRequestLogBody(outputFormat string, commandName string, ar
|
||||
}
|
||||
}
|
||||
|
||||
func parseCodeBuddyCLIChatOutput(output []byte) (*ai.ChatResponse, error) {
|
||||
func parseCodeBuddyCLIChatOutput(output []byte) (*ai.ChatResponse, string, error) {
|
||||
trimmed := bytes.TrimSpace(output)
|
||||
if len(trimmed) == 0 {
|
||||
return &ai.ChatResponse{}, nil
|
||||
return &ai.ChatResponse{}, "", nil
|
||||
}
|
||||
|
||||
var events []cliStreamEvent
|
||||
@@ -296,20 +374,24 @@ func parseCodeBuddyCLIChatOutput(output []byte) (*ai.ChatResponse, error) {
|
||||
return buildCodeBuddyCLIResponseFromEvents([]cliStreamEvent{event})
|
||||
}
|
||||
|
||||
return &ai.ChatResponse{Content: strings.TrimSpace(string(output))}, nil
|
||||
return &ai.ChatResponse{Content: strings.TrimSpace(string(output))}, "", nil
|
||||
}
|
||||
|
||||
func buildCodeBuddyCLIResponseFromEvents(events []cliStreamEvent) (*ai.ChatResponse, error) {
|
||||
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)
|
||||
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)
|
||||
@@ -318,12 +400,12 @@ func buildCodeBuddyCLIResponseFromEvents(events []cliStreamEvent) (*ai.ChatRespo
|
||||
}
|
||||
|
||||
if resultText != "" {
|
||||
return &ai.ChatResponse{Content: resultText}, nil
|
||||
return &ai.ChatResponse{Content: resultText}, sessionID, nil
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
return &ai.ChatResponse{Content: strings.Join(parts, "")}, nil
|
||||
return &ai.ChatResponse{Content: strings.Join(parts, "")}, sessionID, nil
|
||||
}
|
||||
return &ai.ChatResponse{}, nil
|
||||
return &ai.ChatResponse{}, sessionID, nil
|
||||
}
|
||||
|
||||
func (p *CodeBuddyCLIProvider) setEnv(cmd *exec.Cmd) error {
|
||||
|
||||
Reference in New Issue
Block a user