Files
MyGoNavi/internal/ai/provider/cursor_agent.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

718 lines
20 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"
"io"
"net/http"
"net/url"
"strings"
"time"
"GoNavi-Wails/internal/ai"
)
const (
defaultCursorAPIBaseURL = "https://api.cursor.com/v1"
cursorHTTPTimeout = 120 * time.Second
cursorRunPollInterval = time.Second
)
// CursorAgentProvider 通过 Cursor Cloud Agents API 发起对话。
// 支持基于 session state 复用已有 agent并对 follow-up runs 继续追加上下文。
type CursorAgentProvider struct {
config ai.ProviderConfig
baseURL string
client *http.Client
}
type cursorSessionState struct {
AgentID string `json:"agentId,omitempty"`
LastRunID string `json:"lastRunId,omitempty"`
}
type cursorImageInput struct {
Data string `json:"data,omitempty"`
URL string `json:"url,omitempty"`
MimeType string `json:"mimeType,omitempty"`
}
// NewCursorAgentProvider 创建 Cursor Agent Provider。
func NewCursorAgentProvider(config ai.ProviderConfig) (Provider, error) {
normalized := config
normalized.BaseURL = NormalizeCursorAPIBaseURL(config.BaseURL)
normalized.Model = strings.TrimSpace(config.Model)
return &CursorAgentProvider{
config: normalized,
baseURL: normalized.BaseURL,
client: &http.Client{
Timeout: cursorHTTPTimeout,
},
}, nil
}
func (p *CursorAgentProvider) Name() string {
if strings.TrimSpace(p.config.Name) != "" {
return p.config.Name
}
return "Cursor"
}
func (p *CursorAgentProvider) Validate() error {
if strings.TrimSpace(p.config.APIKey) == "" {
return fmt.Errorf("API Key 不能为空")
}
return nil
}
// NormalizeCursorAPIBaseURL 归一化 Cursor API 的 base URL。
func NormalizeCursorAPIBaseURL(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return defaultCursorAPIBaseURL
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return normalizeCursorAPIBaseURLString(trimmed)
}
parsed.RawQuery = ""
parsed.Fragment = ""
parsed.Path = normalizeCursorAPIPath(parsed.Path)
return strings.TrimRight(parsed.String(), "/")
}
// ResolveCursorAPIEndpoint 基于归一化后的 base URL 生成具体接口地址。
func ResolveCursorAPIEndpoint(baseURL string, endpoint string) string {
normalizedBaseURL := NormalizeCursorAPIBaseURL(baseURL)
normalizedEndpoint := strings.TrimLeft(strings.TrimSpace(endpoint), "/")
if normalizedEndpoint == "" {
return normalizedBaseURL
}
return normalizedBaseURL + "/" + normalizedEndpoint
}
func normalizeCursorAPIBaseURLString(raw string) string {
normalized := strings.TrimRight(strings.TrimSpace(raw), "/")
if normalized == "" {
return defaultCursorAPIBaseURL
}
lower := strings.ToLower(normalized)
switch {
case strings.HasSuffix(lower, "/v1/agents"):
normalized = normalized[:len(normalized)-len("/v1/agents")]
case strings.HasSuffix(lower, "/agents"):
normalized = normalized[:len(normalized)-len("/agents")]
case strings.HasSuffix(lower, "/v1/models"):
normalized = normalized[:len(normalized)-len("/v1/models")]
case strings.HasSuffix(lower, "/models"):
normalized = normalized[:len(normalized)-len("/models")]
}
normalized = strings.TrimRight(normalized, "/")
if strings.HasSuffix(strings.ToLower(normalized), "/v1") {
return normalized
}
return normalized + "/v1"
}
func normalizeCursorAPIPath(path string) string {
normalized := strings.TrimRight(strings.TrimSpace(path), "/")
lower := strings.ToLower(normalized)
switch {
case strings.HasSuffix(lower, "/v1/agents"):
normalized = normalized[:len(normalized)-len("/v1/agents")]
case strings.HasSuffix(lower, "/agents"):
normalized = normalized[:len(normalized)-len("/agents")]
case strings.HasSuffix(lower, "/v1/models"):
normalized = normalized[:len(normalized)-len("/v1/models")]
case strings.HasSuffix(lower, "/models"):
normalized = normalized[:len(normalized)-len("/models")]
}
normalized = strings.TrimRight(normalized, "/")
if strings.HasSuffix(strings.ToLower(normalized), "/v1") {
return normalized
}
if normalized == "" {
return "/v1"
}
return normalized + "/v1"
}
type cursorPrompt struct {
Text string `json:"text"`
Images []cursorImageInput `json:"images,omitempty"`
}
type cursorModelSelection struct {
ID string `json:"id"`
}
type cursorCreateAgentRequest struct {
Prompt cursorPrompt `json:"prompt"`
Model *cursorModelSelection `json:"model,omitempty"`
}
type cursorCreateRunRequest struct {
Prompt cursorPrompt `json:"prompt"`
}
type cursorCreateAgentResponse struct {
Agent struct {
ID string `json:"id"`
} `json:"agent"`
Run struct {
ID string `json:"id"`
AgentID string `json:"agentId"`
} `json:"run"`
}
type cursorRunResponse struct {
ID string `json:"id"`
AgentID string `json:"agentId"`
Status string `json:"status"`
Result string `json:"result"`
DurationMS int `json:"durationMs"`
}
type cursorAssistantEvent struct {
Text string `json:"text"`
}
type cursorErrorEvent struct {
Code string `json:"code"`
Message string `json:"message"`
}
type cursorResultEvent struct {
RunID string `json:"runId"`
Status string `json:"status"`
Text string `json:"text"`
DurationMS int `json:"durationMs"`
}
func (p *CursorAgentProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
resp, _, err := p.ChatWithState(ctx, nil, req)
return resp, err
}
func (p *CursorAgentProvider) 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
}
sessionState, err := parseCursorSessionState(state)
if err != nil {
return nil, nil, err
}
agentID := strings.TrimSpace(sessionState.AgentID)
runID := ""
if agentID == "" {
agentID, runID, err = p.createAgent(ctx, req)
if err != nil {
return nil, nil, err
}
} else {
runID, err = p.createRun(ctx, agentID, req)
if err != nil {
return nil, nil, err
}
}
run, err := p.waitForRun(ctx, agentID, runID)
if err != nil {
return nil, nil, err
}
sessionState.AgentID = agentID
sessionState.LastRunID = runID
nextState, err := json.Marshal(sessionState)
if err != nil {
return nil, nil, fmt.Errorf("序列化 Cursor 会话状态失败: %w", err)
}
return &ai.ChatResponse{
Content: strings.TrimSpace(run.Result),
}, json.RawMessage(nextState), nil
}
func (p *CursorAgentProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {
_, err := p.ChatStreamWithState(ctx, nil, req, callback)
return err
}
func (p *CursorAgentProvider) ChatStreamWithState(ctx context.Context, state json.RawMessage, req ai.ChatRequest, callback func(ai.StreamChunk)) (json.RawMessage, error) {
if err := p.Validate(); err != nil {
return nil, err
}
sessionState, err := parseCursorSessionState(state)
if err != nil {
return nil, err
}
agentID := strings.TrimSpace(sessionState.AgentID)
runID := ""
if agentID == "" {
agentID, runID, err = p.createAgent(ctx, req)
if err != nil {
return nil, err
}
} else {
runID, err = p.createRun(ctx, agentID, req)
if err != nil {
return nil, err
}
}
sessionState.AgentID = agentID
sessionState.LastRunID = runID
stream, err := p.openRunStream(ctx, agentID, runID)
if err != nil {
return nil, err
}
defer stream.Close()
scanner := bufio.NewScanner(stream)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var (
currentEventType string
currentDataLines []string
receivedAssistantText bool
receivedResultText bool
completedExplicitly bool
)
dispatchEvent := func(eventType string, dataLines []string) (bool, error) {
if strings.TrimSpace(eventType) == "" {
eventType = "message"
}
payload := strings.TrimSpace(strings.Join(dataLines, "\n"))
switch eventType {
case "assistant":
if payload == "" {
return false, nil
}
var event cursorAssistantEvent
if err := json.Unmarshal([]byte(payload), &event); err != nil {
return false, nil
}
if strings.TrimSpace(event.Text) != "" {
receivedAssistantText = true
callback(ai.StreamChunk{Content: event.Text})
}
case "thinking":
if payload == "" {
return false, nil
}
var event cursorAssistantEvent
if err := json.Unmarshal([]byte(payload), &event); err != nil {
return false, nil
}
if strings.TrimSpace(event.Text) != "" {
callback(ai.StreamChunk{
Thinking: event.Text,
ReasoningContent: event.Text,
})
}
case "result":
if payload == "" {
return false, nil
}
var event cursorResultEvent
if err := json.Unmarshal([]byte(payload), &event); err != nil {
return false, nil
}
if !receivedAssistantText && strings.TrimSpace(event.Text) != "" {
receivedResultText = true
callback(ai.StreamChunk{Content: event.Text})
}
if isCursorRunFailureStatus(event.Status) {
callback(ai.StreamChunk{
Error: cursorRunStatusMessage(event.Status, event.Text),
Done: true,
})
completedExplicitly = true
return true, nil
}
case "error":
if payload == "" {
callback(ai.StreamChunk{Error: "Cursor 流式请求失败", Done: true})
completedExplicitly = true
return true, nil
}
var event cursorErrorEvent
if err := json.Unmarshal([]byte(payload), &event); err != nil {
callback(ai.StreamChunk{Error: "Cursor 流式请求失败", Done: true})
completedExplicitly = true
return true, nil
}
errMessage := strings.TrimSpace(event.Message)
if errMessage == "" {
errMessage = "Cursor 流式请求失败"
}
callback(ai.StreamChunk{Error: errMessage, Done: true})
completedExplicitly = true
return true, nil
case "done":
callback(ai.StreamChunk{Done: true})
completedExplicitly = true
return true, nil
}
return false, nil
}
for scanner.Scan() {
line := scanner.Text()
switch {
case strings.TrimSpace(line) == "":
done, dispatchErr := dispatchEvent(currentEventType, currentDataLines)
currentEventType = ""
currentDataLines = nil
if dispatchErr != nil {
return nil, dispatchErr
}
if done {
return marshalCursorSessionState(sessionState)
}
case strings.HasPrefix(line, "event:"):
currentEventType = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
currentDataLines = append(currentDataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("读取 Cursor 流式响应失败: %w", err)
}
if len(currentDataLines) > 0 || strings.TrimSpace(currentEventType) != "" {
done, dispatchErr := dispatchEvent(currentEventType, currentDataLines)
if dispatchErr != nil {
return nil, dispatchErr
}
if done {
return marshalCursorSessionState(sessionState)
}
}
if !completedExplicitly {
if !receivedAssistantText && !receivedResultText {
callback(ai.StreamChunk{Error: "未收到任何有效响应内容,请检查 Cursor 配置或模型权限", Done: true})
return marshalCursorSessionState(sessionState)
}
callback(ai.StreamChunk{Done: true})
}
return marshalCursorSessionState(sessionState)
}
func (p *CursorAgentProvider) createAgent(ctx context.Context, req ai.ChatRequest) (string, string, error) {
requestBody, err := buildCursorCreateAgentRequest(req, p.config.Model)
if err != nil {
return "", "", err
}
responseBody := cursorCreateAgentResponse{}
if err := p.doJSONRequest(ctx, http.MethodPost, ResolveCursorAPIEndpoint(p.baseURL, "agents"), requestBody, &responseBody, "application/json"); err != nil {
return "", "", err
}
agentID := strings.TrimSpace(responseBody.Agent.ID)
runID := strings.TrimSpace(responseBody.Run.ID)
if agentID == "" || runID == "" {
return "", "", fmt.Errorf("Cursor 创建 agent 成功,但未返回有效的 agentId/runId")
}
return agentID, runID, nil
}
func buildCursorCreateAgentRequest(req ai.ChatRequest, model string) (cursorCreateAgentRequest, error) {
prompt, err := buildCursorPromptInput(req.Messages)
if err != nil {
return cursorCreateAgentRequest{}, err
}
requestBody := cursorCreateAgentRequest{
Prompt: prompt,
}
if trimmedModel := strings.TrimSpace(model); trimmedModel != "" {
requestBody.Model = &cursorModelSelection{ID: trimmedModel}
}
return requestBody, nil
}
func buildCursorPrompt(messages []ai.Message) (string, error) {
prompt := strings.TrimSpace(buildPrompt(messages))
if prompt == "" && requestMessagesContainImages(messages) {
return "请结合这些图片继续分析并回答。", nil
}
if prompt == "" {
return "", fmt.Errorf("请求内容不能为空")
}
return prompt, nil
}
func buildCursorPromptInput(messages []ai.Message) (cursorPrompt, error) {
text, err := buildCursorPrompt(messages)
if err != nil {
return cursorPrompt{}, err
}
images, err := buildCursorImageInputs(messages)
if err != nil {
return cursorPrompt{}, err
}
return cursorPrompt{
Text: text,
Images: images,
}, nil
}
func buildCursorImageInputs(messages []ai.Message) ([]cursorImageInput, error) {
images := make([]cursorImageInput, 0)
for _, message := range messages {
for _, img := range message.Images {
trimmed := strings.TrimSpace(img)
if trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
images = append(images, cursorImageInput{URL: trimmed})
continue
}
mimeType, rawBase64, err := ParseDataURI(trimmed)
if err != nil {
return nil, fmt.Errorf("解析图片数据失败: %w", err)
}
images = append(images, cursorImageInput{
Data: rawBase64,
MimeType: mimeType,
})
}
}
if len(images) > 5 {
return nil, fmt.Errorf("Cursor 最多支持 5 张图片,当前请求包含 %d 张", len(images))
}
return images, nil
}
func (p *CursorAgentProvider) waitForRun(ctx context.Context, agentID string, runID string) (*cursorRunResponse, error) {
ticker := time.NewTicker(cursorRunPollInterval)
defer ticker.Stop()
for {
run, err := p.getRun(ctx, agentID, runID)
if err != nil {
return nil, err
}
if isCursorRunTerminalStatus(run.Status) {
if isCursorRunFailureStatus(run.Status) {
return nil, fmt.Errorf("%s", cursorRunStatusMessage(run.Status, run.Result))
}
return run, nil
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-ticker.C:
}
}
}
func (p *CursorAgentProvider) getRun(ctx context.Context, agentID string, runID string) (*cursorRunResponse, error) {
endpoint := ResolveCursorAPIEndpoint(p.baseURL, fmt.Sprintf("agents/%s/runs/%s", agentID, runID))
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("创建 Cursor run 查询失败: %w", err)
}
httpReq.Header.Set("Accept", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+p.config.APIKey)
for k, v := range p.config.Headers {
httpReq.Header.Set(k, v)
}
resp, err := p.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("查询 Cursor run 状态失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("Cursor run 查询失败 (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
}
responseBody := cursorRunResponse{}
if err := json.NewDecoder(resp.Body).Decode(&responseBody); err != nil {
return nil, fmt.Errorf("解析 Cursor run 响应失败: %w", err)
}
return &responseBody, nil
}
func (p *CursorAgentProvider) createRun(ctx context.Context, agentID string, req ai.ChatRequest) (string, error) {
prompt, err := buildCursorPromptInput(req.Messages)
if err != nil {
return "", err
}
requestBody := cursorCreateRunRequest{
Prompt: prompt,
}
var responseBody struct {
Run struct {
ID string `json:"id"`
} `json:"run"`
}
if err := p.doJSONRequest(ctx, http.MethodPost, ResolveCursorAPIEndpoint(p.baseURL, fmt.Sprintf("agents/%s/runs", agentID)), requestBody, &responseBody, "application/json"); err != nil {
return "", err
}
runID := strings.TrimSpace(responseBody.Run.ID)
if runID == "" {
return "", fmt.Errorf("Cursor 创建 follow-up run 成功,但未返回有效 runId")
}
return runID, nil
}
func (p *CursorAgentProvider) openRunStream(ctx context.Context, agentID string, runID string) (io.ReadCloser, error) {
endpoint := ResolveCursorAPIEndpoint(p.baseURL, fmt.Sprintf("agents/%s/runs/%s/stream", agentID, runID))
requestLog := logAIUpstreamRequestStart(p.Name(), http.MethodGet, endpoint, nil)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
logAIUpstreamRequestFinish(requestLog, 0, err)
return nil, fmt.Errorf("创建 Cursor 流式请求失败: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+p.config.APIKey)
httpReq.Header.Set("Accept", "text/event-stream")
httpReq.Header.Set("Cache-Control", "no-cache")
for k, v := range p.config.Headers {
httpReq.Header.Set(k, v)
}
resp, err := p.client.Do(httpReq)
if err != nil {
logAIUpstreamRequestFinish(requestLog, 0, err)
return nil, fmt.Errorf("发送 Cursor 流式请求失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
statusErr := fmt.Errorf("Cursor API 返回错误 (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
logAIUpstreamRequestFinish(requestLog, resp.StatusCode, statusErr)
return nil, statusErr
}
logAIUpstreamRequestFinish(requestLog, resp.StatusCode, nil)
return resp.Body, nil
}
func (p *CursorAgentProvider) doJSONRequest(ctx context.Context, method string, endpoint string, body any, target any, accept string) error {
var requestBody io.Reader
if body != nil {
bodyBytes, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("序列化 Cursor 请求失败: %w", err)
}
requestBody = bytes.NewReader(bodyBytes)
}
requestLog := logAIUpstreamRequestStart(p.Name(), method, endpoint, body)
httpReq, err := http.NewRequestWithContext(ctx, method, endpoint, requestBody)
if err != nil {
logAIUpstreamRequestFinish(requestLog, 0, err)
return fmt.Errorf("创建 Cursor 请求失败: %w", err)
}
if body != nil {
httpReq.Header.Set("Content-Type", "application/json")
}
if strings.TrimSpace(accept) != "" {
httpReq.Header.Set("Accept", accept)
}
httpReq.Header.Set("Authorization", "Bearer "+p.config.APIKey)
for k, v := range p.config.Headers {
httpReq.Header.Set(k, v)
}
resp, err := p.client.Do(httpReq)
if err != nil {
logAIUpstreamRequestFinish(requestLog, 0, err)
return fmt.Errorf("发送 Cursor 请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
statusErr := fmt.Errorf("Cursor API 返回错误 (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
logAIUpstreamRequestFinish(requestLog, resp.StatusCode, statusErr)
return statusErr
}
if target != nil {
if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
logAIUpstreamRequestFinish(requestLog, resp.StatusCode, err)
return fmt.Errorf("解析 Cursor 响应失败: %w", err)
}
}
logAIUpstreamRequestFinish(requestLog, resp.StatusCode, nil)
return nil
}
func parseCursorSessionState(state json.RawMessage) (cursorSessionState, error) {
if len(state) == 0 {
return cursorSessionState{}, nil
}
var result cursorSessionState
if err := json.Unmarshal(state, &result); err != nil {
return cursorSessionState{}, fmt.Errorf("解析 Cursor 会话状态失败: %w", err)
}
return result, nil
}
func marshalCursorSessionState(state cursorSessionState) (json.RawMessage, error) {
if strings.TrimSpace(state.AgentID) == "" {
return nil, nil
}
bytes, err := json.Marshal(state)
if err != nil {
return nil, fmt.Errorf("序列化 Cursor 会话状态失败: %w", err)
}
return json.RawMessage(bytes), nil
}
func isCursorRunTerminalStatus(status string) bool {
switch strings.ToUpper(strings.TrimSpace(status)) {
case "FINISHED", "ERROR", "CANCELLED", "EXPIRED":
return true
default:
return false
}
}
func isCursorRunFailureStatus(status string) bool {
switch strings.ToUpper(strings.TrimSpace(status)) {
case "ERROR", "CANCELLED", "EXPIRED":
return true
default:
return false
}
}
func cursorRunStatusMessage(status string, result string) string {
normalizedStatus := strings.ToUpper(strings.TrimSpace(status))
if text := strings.TrimSpace(result); text != "" {
return fmt.Sprintf("Cursor 运行结束(%s%s", normalizedStatus, text)
}
return fmt.Sprintf("Cursor 运行结束(%s", normalizedStatus)
}