| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import type { CursorChatRequest, CursorSSEEvent } from './types.js'; |
| import { getConfig } from './config.js'; |
| import { getProxyFetchOptions } from './proxy-agent.js'; |
|
|
| const CURSOR_CHAT_API = 'https://cursor.com/api/chat'; |
|
|
| |
| function getChromeHeaders(): Record<string, string> { |
| const config = getConfig(); |
| return { |
| 'Content-Type': 'application/json', |
| 'sec-ch-ua-platform': '"Windows"', |
| 'x-path': '/api/chat', |
| 'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"', |
| 'x-method': 'POST', |
| 'sec-ch-ua-bitness': '"64"', |
| 'sec-ch-ua-mobile': '?0', |
| 'sec-ch-ua-arch': '"x86"', |
| 'sec-ch-ua-platform-version': '"19.0.0"', |
| 'origin': 'https://cursor.com', |
| 'sec-fetch-site': 'same-origin', |
| 'sec-fetch-mode': 'cors', |
| 'sec-fetch-dest': 'empty', |
| 'referer': 'https://cursor.com/', |
| 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8', |
| 'priority': 'u=1, i', |
| 'user-agent': config.fingerprint.userAgent, |
| 'x-is-human': '', |
| }; |
| } |
|
|
| |
|
|
| |
| |
| |
| export async function sendCursorRequest( |
| req: CursorChatRequest, |
| onChunk: (event: CursorSSEEvent) => void, |
| externalSignal?: AbortSignal, |
| ): Promise<void> { |
| const maxRetries = 2; |
| for (let attempt = 1; attempt <= maxRetries; attempt++) { |
| try { |
| await sendCursorRequestInner(req, onChunk, externalSignal); |
| return; |
| } catch (err) { |
| |
| if (externalSignal?.aborted) throw err; |
| |
| if (err instanceof Error && err.message === 'DEGENERATE_LOOP_ABORTED') return; |
| const msg = err instanceof Error ? err.message : String(err); |
| console.error(`[Cursor] 请求失败 (${attempt}/${maxRetries}): ${msg.substring(0, 100)}`); |
| if (attempt < maxRetries) { |
| await new Promise(r => setTimeout(r, 2000)); |
| } else { |
| throw err; |
| } |
| } |
| } |
| } |
|
|
| async function sendCursorRequestInner( |
| req: CursorChatRequest, |
| onChunk: (event: CursorSSEEvent) => void, |
| externalSignal?: AbortSignal, |
| ): Promise<void> { |
| const headers = getChromeHeaders(); |
|
|
| |
|
|
| const config = getConfig(); |
| const controller = new AbortController(); |
| |
| if (externalSignal) { |
| if (externalSignal.aborted) { controller.abort(); } |
| else { externalSignal.addEventListener('abort', () => controller.abort(), { once: true }); } |
| } |
|
|
| |
| |
| |
| const IDLE_TIMEOUT_MS = config.timeout * 1000; |
| let idleTimer: ReturnType<typeof setTimeout> | null = null; |
|
|
| const resetIdleTimer = () => { |
| if (idleTimer) clearTimeout(idleTimer); |
| idleTimer = setTimeout(() => { |
| console.warn(`[Cursor] 空闲超时(${config.timeout}s 无新数据),中止请求`); |
| controller.abort(); |
| }, IDLE_TIMEOUT_MS); |
| }; |
|
|
| |
| resetIdleTimer(); |
|
|
| try { |
| const resp = await fetch(CURSOR_CHAT_API, { |
| method: 'POST', |
| headers, |
| body: JSON.stringify(req), |
| signal: controller.signal, |
| ...getProxyFetchOptions(), |
| } as any); |
|
|
| if (!resp.ok) { |
| const body = await resp.text(); |
| throw new Error(`Cursor API 错误: HTTP ${resp.status} - ${body}`); |
| } |
|
|
| if (!resp.body) { |
| throw new Error('Cursor API 响应无 body'); |
| } |
|
|
| |
| const reader = resp.body.getReader(); |
| const decoder = new TextDecoder(); |
| let buffer = ''; |
|
|
| |
| |
| |
| let lastDelta = ''; |
| let repeatCount = 0; |
| const REPEAT_THRESHOLD = 8; |
| let degenerateAborted = false; |
|
|
| |
| let lineBuffer = ''; |
| let lastLine = ''; |
| let lineRepeatCount = 0; |
| let lineRepeatAborted = false; |
|
|
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
|
|
| |
| resetIdleTimer(); |
|
|
| buffer += decoder.decode(value, { stream: true }); |
| const lines = buffer.split('\n'); |
| buffer = lines.pop() || ''; |
|
|
| for (const line of lines) { |
| if (!line.startsWith('data: ')) continue; |
| const data = line.slice(6).trim(); |
| if (!data) continue; |
|
|
| try { |
| const event: CursorSSEEvent = JSON.parse(data); |
|
|
| |
| if (event.type === 'text-delta' && event.delta) { |
| const trimmedDelta = event.delta.trim(); |
| |
| if (trimmedDelta.length > 0 && trimmedDelta.length <= 20) { |
| if (trimmedDelta === lastDelta) { |
| repeatCount++; |
| if (repeatCount >= REPEAT_THRESHOLD) { |
| console.warn(`[Cursor] ⚠️ 检测到退化循环: "${trimmedDelta}" 已连续重复 ${repeatCount} 次,中止流`); |
| degenerateAborted = true; |
| |
| reader.cancel(); |
| break; |
| } |
| } else { |
| lastDelta = trimmedDelta; |
| repeatCount = 1; |
| } |
| } else { |
| |
| lastDelta = ''; |
| repeatCount = 0; |
| } |
| } |
|
|
| |
| if (event.type === 'text-delta' && event.delta) { |
| lineBuffer += event.delta; |
| if (lineBuffer.length > 50) { lineBuffer = ''; } |
| if (lineBuffer.indexOf('\n') !== -1) { |
| const nlParts = lineBuffer.split('\n'); |
| lineBuffer = nlParts.pop()!; |
| for (const completedLine of nlParts) { |
| const trimLine = completedLine.trim(); |
| if (!trimLine) continue; |
| if (trimLine === lastLine) { |
| lineRepeatCount++; |
| if (lineRepeatCount >= REPEAT_THRESHOLD) { |
| console.warn(`[Cursor] ⚠️ 检测到行级重复: "${trimLine.substring(0, 60)}" 已连续重复 ${lineRepeatCount} 次,中止流`); |
| lineRepeatAborted = true; |
| reader.cancel(); |
| break; |
| } |
| } else { |
| lastLine = trimLine; |
| lineRepeatCount = 1; |
| } |
| } |
| if (lineRepeatAborted) break; |
| } |
| } |
|
|
| onChunk(event); |
| } catch { |
| |
| } |
| } |
|
|
| if (degenerateAborted || lineRepeatAborted) break; |
| } |
|
|
| |
| if (degenerateAborted) { |
| throw new Error('DEGENERATE_LOOP_ABORTED'); |
| } |
| |
| if (lineRepeatAborted) { |
| throw new Error('LINE_REPEAT_ABORTED'); |
| } |
|
|
| |
| if (buffer.startsWith('data: ')) { |
| const data = buffer.slice(6).trim(); |
| if (data) { |
| try { |
| const event: CursorSSEEvent = JSON.parse(data); |
| onChunk(event); |
| } catch { } |
| } |
| } |
| } finally { |
| if (idleTimer) clearTimeout(idleTimer); |
| } |
| } |
|
|
| |
| |
| |
| export async function sendCursorRequestFull(req: CursorChatRequest): Promise<string> { |
| let fullText = ''; |
| await sendCursorRequest(req, (event) => { |
| if (event.type === 'text-delta' && event.delta) { |
| fullText += event.delta; |
| } |
| }); |
| return fullText; |
| } |
|
|