fix: 修复 SSE 流式事件格式错误、启用配置超时、修正工具调用完整性检测

1. handler.ts: 修复 content_block_delta 事件缺少 index 和 delta 包装层的严重 Bug
   - 当 AI 响应包含 ```json 但非工具调用时,文本增量会因格式错误而丢失
2. cursor-client.ts: 请求超时改用 config.timeout 配置值,不再硬编码 120s
3. converter.ts: 修复 isToolCallComplete() 始终返回 true 的逻辑错误
4. handler.ts: 移除未使用的 isToolCallComplete 导入
This commit is contained in:
小海
2026-03-04 17:39:46 +08:00
parent 561017e7b1
commit be3037fca8
3 changed files with 66 additions and 31 deletions

View File

@@ -238,10 +238,6 @@ function extractToolResultText(block: AnthropicContentBlock): string {
// ==================== 响应解析 ====================
/**
* 从 AI 响应文本中解析工具调用
* 匹配 ```json action ... ``` 块
*/
export function parseToolCalls(responseText: string): {
toolCalls: ParsedToolCall[];
cleanText: string;
@@ -249,27 +245,29 @@ export function parseToolCalls(responseText: string): {
const toolCalls: ParsedToolCall[] = [];
let cleanText = responseText;
const regex = /```json\s+action[\s\S]*?\{([\s\S]*?)\}\s*```/g;
// 我们先把整块内容取出来
const fullBlockRegex = /```json\s+action\s*([\s\S]*?)\s*```/g;
const fullBlockRegex = /```json(?:\s+action)?\s*([\s\S]*?)\s*```/g;
let match: RegExpExecArray | null;
while ((match = fullBlockRegex.exec(responseText)) !== null) {
let isToolCall = false;
try {
const parsed = JSON.parse(match[1]);
if (parsed.tool) {
// check for tool or name
if (parsed.tool || parsed.name) {
toolCalls.push({
name: parsed.tool,
arguments: parsed.parameters || {}
name: parsed.tool || parsed.name,
arguments: parsed.parameters || parsed.arguments || parsed.input || {}
});
isToolCall = true;
}
} catch (e) {
console.error('[Converter] Failed to parse JSON action block:', e);
// Ignored, not a valid json tool call
}
// 移除已解析的调用块
cleanText = cleanText.replace(match[0], '');
if (isToolCall) {
// 移除已解析的调用块
cleanText = cleanText.replace(match[0], '');
}
}
return { toolCalls, cleanText: cleanText.trim() };
@@ -279,7 +277,7 @@ export function parseToolCalls(responseText: string): {
* 检查文本是否包含工具调用
*/
export function hasToolCalls(text: string): boolean {
return text.includes('```json action');
return text.includes('```json');
}
/**
@@ -287,9 +285,10 @@ export function hasToolCalls(text: string): boolean {
*/
export function isToolCallComplete(text: string): boolean {
const openCount = (text.match(/```json\s+action/g) || []).length;
const closeCount = (text.match(/```(?!json\s+action)/g) || []).length;
// 粗略估计:如果是 ``` 结尾的,通常是结束了。这里不做完全精确匹配
return true;
// Count closing ``` that are NOT part of opening ```json action
const allBackticks = (text.match(/```/g) || []).length;
const closeCount = allBackticks - openCount;
return openCount > 0 && closeCount >= openCount;
}
// ==================== 工具函数 ====================

View File

@@ -252,9 +252,10 @@ async function sendCursorRequestInner(
console.log(`[Cursor] 发送请求: model=${req.model}, messages=${req.messages.length}`);
// 请求级超时
// 请求级超时(使用配置值)
const config = getConfig();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 120_000); // 2分钟
const timeout = setTimeout(() => controller.abort(), config.timeout * 1000);
try {
const resp = await fetch(CURSOR_CHAT_API, {

View File

@@ -13,7 +13,7 @@ import type {
AnthropicContentBlock,
CursorSSEEvent,
} from './types.js';
import { convertToCursorRequest, parseToolCalls, hasToolCalls, isToolCallComplete } from './converter.js';
import { convertToCursorRequest, parseToolCalls, hasToolCalls } from './converter.js';
import { sendCursorRequest, sendCursorRequestFull } from './cursor-client.js';
import { getConfig } from './config.js';
@@ -105,6 +105,7 @@ async function handleStream(res: Response, cursorReq: ReturnType<typeof convertT
});
let fullResponse = '';
let sentText = '';
let blockIndex = 0;
let textBlockStarted = false;
@@ -137,6 +138,7 @@ async function handleStream(res: Response, cursorReq: ReturnType<typeof convertT
index: blockIndex,
delta: { type: 'text_delta', text: event.delta },
});
sentText += event.delta;
});
// 流完成后,处理完整响应
@@ -148,10 +150,33 @@ async function handleStream(res: Response, cursorReq: ReturnType<typeof convertT
if (toolCalls.length > 0) {
stopReason = 'tool_use';
// 如果有工具调用前的文本,确保文本块已关闭
// find match length of sentText in cleanText
let matchLen = 0;
for (let i = Math.min(cleanText.length, sentText.length); i >= 0; i--) {
if (cleanText.startsWith(sentText.substring(0, i))) {
matchLen = i;
break;
}
}
const unsentCleanText = cleanText.substring(matchLen).trim();
// 如果解析后有干净的文本(工具调用之外的文本),发送文本块
if (unsentCleanText) {
if (!textBlockStarted) {
writeSSE(res, 'content_block_start', {
type: 'content_block_start', index: blockIndex,
content_block: { type: 'text', text: '' },
});
textBlockStarted = true;
}
writeSSE(res, 'content_block_delta', {
type: 'content_block_delta', index: blockIndex,
delta: { type: 'text_delta', text: (sentText.endsWith('\n') ? '' : '\n') + unsentCleanText }
});
}
// 如果有文本块,确保完成结束
if (textBlockStarted) {
// 需要重新计算:流式发送的文本可能包含了工具调用标签之前的部分
// 这里我们结束当前文本块
writeSSE(res, 'content_block_stop', {
type: 'content_block_stop', index: blockIndex,
});
@@ -159,13 +184,6 @@ async function handleStream(res: Response, cursorReq: ReturnType<typeof convertT
textBlockStarted = false;
}
// 如果解析后有干净的文本(工具调用之外的文本),发送文本块
if (cleanText && !textBlockStarted) {
// 文本可能已经通过流式增量发送了,这里的 cleanText 是去除工具调用后的剩余
// 不需要重复发送
}
// 发送工具调用块
for (const tc of toolCalls) {
const tcId = toolId();
writeSSE(res, 'content_block_start', {
@@ -186,6 +204,23 @@ async function handleStream(res: Response, cursorReq: ReturnType<typeof convertT
});
blockIndex++;
}
} else {
// False alarm! The tool triggers were just normal text.
// We must send the remaining unsent fullResponse.
const unsentText = fullResponse.substring(sentText.length);
if (unsentText) {
if (!textBlockStarted) {
writeSSE(res, 'content_block_start', {
type: 'content_block_start', index: blockIndex,
content_block: { type: 'text', text: '' },
});
textBlockStarted = true;
}
writeSSE(res, 'content_block_delta', {
type: 'content_block_delta', index: blockIndex,
delta: { type: 'text_delta', text: unsentText },
});
}
}
}