fix: 连接保活 + 续写收敛优化 + 拒绝模式扩充

- 流式路径:每 15s 发送 SSE 注释 keepalive,防止 504 网关超时
- 非流式路径:chunked 空白字符保活,JSON.parse 天然兼容
- MAX_AUTO_CONTINUE 6→3,新增最小进展阈值(<100 chars 停止)
- 新增连续小增量检测(2 次 <500 chars 停止挤牙膏)
- 追加 15 条 REFUSAL_PATTERNS(EN scope/expertise + CN 中文拒绝)
This commit is contained in:
小海
2026-03-16 11:43:16 +08:00
parent f0ab29056f
commit 0e16861b2c

View File

@@ -115,6 +115,23 @@ const REFUSAL_PATTERNS = [
/语言偏好请求/,
/提供.*具体场景/,
/即报错/,
// EN: scope/expertise wordings (2026-03 batch)
/(?:outside|beyond)\s+(?:the\s+)?scope\s+of\s+what/i,
/not\s+(?:within|in)\s+(?:my|the)\s+scope/i,
/this\s+assistant\s+is\s+(?:focused|scoped)/i,
/(?:only|just)\s+(?:able|here)\s+to\s+(?:answer|help)/i,
/I\s+(?:can\s+)?only\s+help\s+with\s+(?:questions|issues)\s+(?:related|about)/i,
/(?:here|designed)\s+to\s+help\s+(?:with\s+)?(?:questions\s+)?about\s+Cursor/i,
/not\s+(?:something|a\s+topic)\s+(?:related|specific)\s+to\s+(?:Cursor|coding)/i,
/outside\s+(?:my|the|your)\s+area\s+of\s+(?:expertise|scope)/i,
/(?:can[.']?t|cannot|unable\s+to)\s+help\s+with\s+(?:this|that)\s+(?:request|question|topic)/i,
/scoped\s+to\s+(?:answering|helping)/i,
// CN: Chinese refusal wordings — Cursor 中文界面下的拒绝 (2026-03 batch)
/只能回答.*(?:Cursor|编辑器).*(?:相关|有关)/,
/专[注门].*(?:回答|帮助|解答).*(?:Cursor|编辑器)/,
/有什么.*(?:Cursor|编辑器).*(?:问题|可以)/,
/无法提供.*(?:推荐|建议|帮助)/,
/(?:功能使用|账户|故障排除|账号|订阅|套餐|计费).*(?:等|问题)/,
];
export function isRefusal(text: string): boolean {
@@ -594,6 +611,15 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A
},
});
// ★ 流式保活:在缓冲/续写期间每 15s 发送 SSE 注释,防止网关判定连接空闲超时 (504)
const keepaliveInterval = setInterval(() => {
try {
res.write(': keepalive\n\n');
// @ts-expect-error flush exists on ServerResponse when compression is used
if (typeof res.flush === 'function') res.flush();
} catch { /* connection already closed, ignore */ }
}, 15000);
let fullResponse = '';
let sentText = '';
let blockIndex = 0;
@@ -677,8 +703,9 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A
// 流完成后,处理完整响应
// ★ 内部截断续写如果模型输出过长被截断常见于写大文件Proxy 内部分段续写,然后拼接成完整响应
// 这样可以确保工具调用(如 Write不会横跨两次 API 响应而退化为纯文本
const MAX_AUTO_CONTINUE = 6;
const MAX_AUTO_CONTINUE = 3;
let continueCount = 0;
let consecutiveSmallAdds = 0; // 连续小增量计数
// 保存原始请求的消息快照(不含续写追加的消息)
const originalMessages = [...activeCursorReq.messages];
@@ -745,6 +772,23 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener
console.log(`[Handler] ⚠️ 续写内容全部为重复,停止续写`);
break;
}
// ★ 最小进展检测:去重后新增内容过少(<100 chars模型几乎已完成
if (deduped.trim().length < 100) {
console.log(`[Handler] ⚠️ 续写新增内容过少 (${deduped.trim().length} chars < 100),停止续写`);
break;
}
// ★ 连续小增量检测连续2次增量 < 500 chars说明模型已经在挤牙膏
if (deduped.trim().length < 500) {
consecutiveSmallAdds++;
if (consecutiveSmallAdds >= 2) {
console.log(`[Handler] ⚠️ 连续 ${consecutiveSmallAdds} 次小增量续写,停止续写`);
break;
}
} else {
consecutiveSmallAdds = 0;
}
}
let stopReason = (hasTools && isTruncated(fullResponse)) ? 'max_tokens' : 'end_turn';
@@ -939,6 +983,9 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener
writeSSE(res, 'error', {
type: 'error', error: { type: 'api_error', message },
});
} finally {
// ★ 清除保活定时器
clearInterval(keepaliveInterval);
}
res.end();
@@ -947,6 +994,18 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener
// ==================== 非流式处理 ====================
async function handleNonStream(res: Response, cursorReq: CursorChatRequest, body: AnthropicRequest): Promise<void> {
// ★ 非流式保活:手动设置 chunked 响应,在缓冲期间每 15s 发送空白字符保活
// JSON.parse 会忽略前导空白,所以客户端解析不受影响
res.writeHead(200, { 'Content-Type': 'application/json' });
const keepaliveInterval = setInterval(() => {
try {
res.write(' ');
// @ts-expect-error flush exists on ServerResponse when compression is used
if (typeof res.flush === 'function') res.flush();
} catch { /* connection already closed, ignore */ }
}, 15000);
try {
let fullText = await sendCursorRequestFull(cursorReq);
const hasTools = (body.tools?.length ?? 0) > 0;
let activeCursorReq = cursorReq;
@@ -1004,8 +1063,9 @@ async function handleNonStream(res: Response, cursorReq: CursorChatRequest, body
// ★ 内部截断续写(与流式路径对齐)
// Claude CLI 使用非流式模式时,写大文件最容易被截断
// 在 proxy 内部完成续写,确保工具调用参数完整
const MAX_AUTO_CONTINUE = 6;
const MAX_AUTO_CONTINUE = 3;
let continueCount = 0;
let consecutiveSmallAdds = 0; // 连续小增量计数
const originalMessages = [...activeCursorReq.messages];
while (hasTools && isTruncated(fullText) && continueCount < MAX_AUTO_CONTINUE) {
@@ -1061,6 +1121,23 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener
console.log(`[Handler] ⚠️ 非流式续写内容全部为重复,停止续写`);
break;
}
// ★ 最小进展检测:去重后新增内容过少(<100 chars模型几乎已完成
if (deduped.trim().length < 100) {
console.log(`[Handler] ⚠️ 非流式续写新增内容过少 (${deduped.trim().length} chars < 100),停止续写`);
break;
}
// ★ 连续小增量检测连续2次增量 < 500 chars说明模型已经在挤牙膏
if (deduped.trim().length < 500) {
consecutiveSmallAdds++;
if (consecutiveSmallAdds >= 2) {
console.log(`[Handler] ⚠️ 非流式连续 ${consecutiveSmallAdds} 次小增量续写,停止续写`);
break;
}
} else {
consecutiveSmallAdds = 0;
}
}
const contentBlocks: AnthropicContentBlock[] = [];
@@ -1166,7 +1243,20 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener
},
};
res.json(response);
clearInterval(keepaliveInterval);
res.end(JSON.stringify(response));
} catch (err: unknown) {
clearInterval(keepaliveInterval);
const message = err instanceof Error ? err.message : String(err);
console.error(`[Handler] 非流式请求处理失败:`, message);
try {
res.end(JSON.stringify({
type: 'error',
error: { type: 'api_error', message },
}));
} catch { /* response already ended */ }
}
}
// ==================== SSE 工具函数 ====================