| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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(); |
| const headers: Record<string, string> = { |
| 'Content-Type': 'application/json', |
| 'accept': '*/*', |
| 'sec-ch-ua-platform': '"macOS"', |
| 'x-path': '/api/chat', |
| 'sec-ch-ua': '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"', |
| 'x-method': 'POST', |
| 'sec-ch-ua-bitness': '"64"', |
| 'sec-ch-ua-mobile': '?0', |
| 'sec-ch-ua-arch': '"arm"', |
| 'sec-ch-ua-platform-version': '"14.6.1"', |
| 'dnt': '1', |
| 'origin': 'https://cursor.com', |
| 'sec-fetch-site': 'same-origin', |
| 'sec-fetch-mode': 'cors', |
| 'sec-fetch-dest': 'empty', |
| 'referer': 'https://cursor.com/cn/docs', |
| 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8', |
| 'priority': 'u=1, i', |
| 'user-agent': config.fingerprint.userAgent, |
| 'x-is-human': '', |
| }; |
|
|
| |
| if (config.cookie) { |
| headers['cookie'] = config.cookie; |
| } |
|
|
| return headers; |
| } |
|
|
| |
|
|
| |
| |
| |
| 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 config = getConfig(); |
|
|
| |
| const useStealthProxy = !!config.stealthProxy; |
| const targetUrl = useStealthProxy |
| ? `${config.stealthProxy!.replace(/\/$/, '')}/proxy/chat` |
| : CURSOR_CHAT_API; |
| |
| const headers = useStealthProxy |
| ? { 'Content-Type': 'application/json' } |
| : getChromeHeaders(); |
|
|
| |
|
|
| 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 fetchOptions = useStealthProxy ? {} : getProxyFetchOptions(); |
| const resp = await fetch(targetUrl, { |
| method: 'POST', |
| headers, |
| body: JSON.stringify(req), |
| signal: controller.signal, |
| ...fetchOptions, |
| } 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 tagBuffer = ''; |
| let htmlRepeatAborted = false; |
| const HTML_TOKEN_RE = /(<\/?[a-z][a-z0-9]*\s*\/?>|&[a-z]+;)/gi; |
|
|
| 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; |
| } |
|
|
| |
| |
| tagBuffer += event.delta; |
| const tagMatches = [...tagBuffer.matchAll(new RegExp(HTML_TOKEN_RE.source, 'gi'))]; |
| if (tagMatches.length > 0) { |
| const lastTagMatch = tagMatches[tagMatches.length - 1]; |
| tagBuffer = tagBuffer.slice(lastTagMatch.index! + lastTagMatch[0].length); |
| for (const m of tagMatches) { |
| const token = m[0].toLowerCase(); |
| if (token === lastDelta) { |
| repeatCount++; |
| if (repeatCount >= REPEAT_THRESHOLD) { |
| console.warn(`[Cursor] ⚠️ 检测到 HTML token 重复: "${token}" 已连续重复 ${repeatCount} 次,中止流`); |
| htmlRepeatAborted = true; |
| reader.cancel(); |
| break; |
| } |
| } else { |
| lastDelta = token; |
| repeatCount = 1; |
| } |
| } |
| if (htmlRepeatAborted) break; |
| } else if (tagBuffer.length > 20) { |
| |
| tagBuffer = ''; |
| } |
| } |
|
|
| onChunk(event); |
| } catch { |
| |
| } |
| } |
|
|
| if (degenerateAborted || htmlRepeatAborted) break; |
| } |
|
|
| |
| if (degenerateAborted) { |
| throw new Error('DEGENERATE_LOOP_ABORTED'); |
| } |
| |
| if (htmlRepeatAborted) { |
| throw new Error('HTML_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<{ text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }> { |
| let fullText = ''; |
| let usage: { inputTokens?: number; outputTokens?: number; totalTokens?: number } | undefined; |
| await sendCursorRequest(req, (event) => { |
| if (event.type === 'text-delta' && event.delta) { |
| fullText += event.delta; |
| } |
| if (event.messageMetadata?.usage) { |
| usage = event.messageMetadata.usage; |
| } |
| }); |
| return { text: fullText, usage }; |
| } |
|
|