ccccccc / src /cursor-client.ts
bingn's picture
Upload 12 files
5844451 verified
/**
* cursor-client.ts - Cursor API 客户端
*
* 职责:
* 1. 发送请求到 https://cursor.com/api/chat(带 Chrome TLS 指纹模拟 headers)
* 2. 流式解析 SSE 响应
* 3. 自动重试(最多 2 次)
*
* 注:x-is-human token 验证已被 Cursor 停用,直接发送空字符串即可。
*/
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';
// Chrome 浏览器请求头模拟
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': '', // Cursor 不再校验此字段
'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15'
};
}
// ==================== API 请求 ====================
/**
* 发送请求到 Cursor /api/chat 并以流式方式处理响应(带重试)
*/
export async function sendCursorRequest(
req: CursorChatRequest,
onChunk: (event: CursorSSEEvent) => void,
): Promise<void> {
const maxRetries = 2;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await sendCursorRequestInner(req, onChunk);
return;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[Cursor] 请求失败 (${attempt}/${maxRetries}): ${msg}`);
if (attempt < maxRetries) {
console.log(`[Cursor] 2s 后重试...`);
await new Promise(r => setTimeout(r, 2000));
} else {
throw err;
}
}
}
}
async function sendCursorRequestInner(
req: CursorChatRequest,
onChunk: (event: CursorSSEEvent) => void,
): Promise<void> {
const headers = getChromeHeaders();
console.log(`[Cursor] 发送请求: model=${req.model}, messages=${req.messages.length}`);
const config = getConfig();
const controller = new AbortController();
// ★ 空闲超时(Idle Timeout):用读取活动检测替换固定总时长超时。
// 每次收到新数据时重置计时器,只有在指定时间内完全无数据到达时才中断。
// 这样长输出(如写长文章、大量工具调用)不会因总时长超限被误杀。
const IDLE_TIMEOUT_MS = config.timeout * 1000; // 复用 timeout 配置作为空闲超时阈值
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');
}
// 流式读取 SSE 响应
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
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);
onChunk(event);
} catch {
// 非 JSON 数据,忽略
}
}
}
// 处理剩余 buffer
if (buffer.startsWith('data: ')) {
const data = buffer.slice(6).trim();
if (data) {
try {
const event: CursorSSEEvent = JSON.parse(data);
onChunk(event);
} catch { /* ignore */ }
}
}
} 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;
}