diff --git a/.gitignore b/.gitignore index 30335ad..f9d1550 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ node_modules/ # IDE .idea/ .vscode/ +.cursor/ *.swp *.swo diff --git a/Dockerfile b/Dockerfile index 146cfed..c34c341 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,14 +33,18 @@ RUN npm ci --omit=dev \ # 从 builder 阶段拷贝编译后的产物 COPY --from=builder --chown=cursor:nodejs /app/dist ./dist -# 拷贝默认配置文件(可通过 volume 挂载覆盖) -COPY --chown=cursor:nodejs config.yaml ./config.yaml +# 创建日志目录并授权 +RUN mkdir -p /app/logs && chown cursor:nodejs /app/logs + +# 注意:config.yaml 不打包进镜像,通过 docker-compose volumes 挂载 +# 如果未挂载,服务会使用内置默认值 + 环境变量 # 切换到非 root 用户 USER cursor -# 声明对外暴露的端口 +# 声明对外暴露的端口和持久化卷 EXPOSE 3010 +VOLUME ["/app/logs"] # 启动服务 CMD ["npm", "start"] diff --git a/README.md b/README.md index f87c264..f79742a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# Cursor2API v2.7.2 +# Cursor2API v2.7.3 将 Cursor 文档页免费 AI 对话接口代理转换为 **Anthropic Messages API** 和 **OpenAI Chat Completions API**,支持 **Claude Code** 和 **Cursor IDE** 使用。 -> ⚠️ **版本说明**:当前 v2.7.2 新增日志查看器日夜主题切换、标题提取修复、配置模板化。 +> ⚠️ **版本说明**:当前 v2.7.3 统一 thinking 剥离逻辑、增强拒绝检测准确性、优化 Docker 部署配置。 ## 原理 diff --git a/docker-compose.yml b/docker-compose.yml index c153365..c4c53f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,8 +9,11 @@ services: ports: - "3010:3010" volumes: - # 挂载外部配置文件(推荐)——修改后只需 docker compose restart 即可生效 + # 挂载配置文件(可选)——先从 config.yaml.example 复制一份: cp config.yaml.example config.yaml + # 修改后只需 docker compose restart 即可生效;不挂载则使用内置默认值 + 环境变量 - ./config.yaml:/app/config.yaml:ro + # 日志持久化目录(需要在 config.yaml 或环境变量中开启 logging.file_enabled) + - ./logs:/app/logs environment: - NODE_ENV=production - PORT=3010 @@ -21,6 +24,25 @@ services: # [可选环境变量] 以下变量如果声明,将会覆盖 config.yaml 中对应的配置: # - CURSOR_MODEL=anthropic/claude-sonnet-4.6 - # ── Vision 图片处理(v2.3.0 新增) ── + # ── API 鉴权 ── + # 公网部署时强烈建议开启,多个 token 用逗号分隔 + # - AUTH_TOKEN=sk-your-secret-token-1,sk-your-secret-token-2 + + # ── Thinking 开关(最高优先级,覆盖 config.yaml) ── + # true=始终启用思考链, false=强制关闭 + # - THINKING_ENABLED=true + + # ── 历史消息压缩 ── + # - COMPRESSION_ENABLED=true + # - COMPRESSION_LEVEL=2 + + # ── 日志持久化 ── + # - LOG_FILE_ENABLED=true + # - LOG_DIR=./logs + + # ── 浏览器指纹(base64 JSON) ── + # - FP=eyJ1c2VyQWdlbnQiOiIuLi4ifQ== + + # ── Vision 图片处理 ── # 默认使用本地 OCR(零配置),如需外部 Vision API 请在 config.yaml 中修改 vision.mode 为 'api' # 并配置 vision.base_url / vision.api_key / vision.model diff --git a/package.json b/package.json index ef12677..0161fa3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cursor2api", - "version": "2.7.2", + "version": "2.7.3", "description": "Proxy Cursor docs AI to Anthropic Messages API for Claude Code", "type": "module", "scripts": { diff --git a/src/converter.ts b/src/converter.ts index 0b0d1cd..614e89c 100644 --- a/src/converter.ts +++ b/src/converter.ts @@ -289,7 +289,7 @@ export async function convertToCursorRequest(req: AnthropicRequest): Promise remove entire sentence + result = result.replace(/[^\n.!?]*(?:accidentally|mistakenly|keep|sorry|apologies|apologize)[^\n.!?]*(?:called|calling|used|using)[^\n.!?]*Cursor[^\n.!?]*tool[^\n.!?]*[.!?]\s*/gi, ''); + result = result.replace(/[^\n.!?]*Cursor\s+documentation[^\n.!?]*tool[^\n.!?]*[.!?]\s*/gi, ''); + // Sometimes it follows up with "I need to stop this." -> remove if preceding tool hallucination + result = result.replace(/I\s+need\s+to\s+stop\s+this[.!]\s*/gi, ''); + return result; } @@ -747,7 +753,7 @@ async function handleDirectTextStream( let rawResponse = ''; let visibleText = ''; let leadingBuffer = ''; - let leadingResolved = !clientRequestedThinking; + let leadingResolved = false; let thinkingContent = ''; const attemptStreamer = createIncrementalTextStreamer({ transform: sanitizeResponse, @@ -782,11 +788,9 @@ async function handleDirectTextStream( rawResponse += event.delta; - if (!clientRequestedThinking) { - flushVisible(event.delta); - return; - } - + // ★ 始终缓冲前导内容以检测并剥离 标签 + // 无论 clientRequestedThinking 是否为 true,都需要分离 thinking + // 区别在于:true 时发送 thinking content block,false 时静默丢弃 thinking 标签 if (!leadingResolved) { leadingBuffer += event.delta; const split = splitLeadingThinkingBlocks(leadingBuffer); @@ -800,6 +804,12 @@ async function handleDirectTextStream( return; } + // 没有以 开头:检查缓冲区是否足够判断 + // 如果缓冲区还很短(< "".length),继续等待 + if (leadingBuffer.trimStart().length < THINKING_OPEN.length) { + return; + } + leadingResolved = true; const buffered = leadingBuffer; leadingBuffer = ''; @@ -810,6 +820,21 @@ async function handleDirectTextStream( flushVisible(event.delta); }); + // ★ 流结束后 flush 残留的 leadingBuffer + // 极短响应可能在 leadingBuffer 中有未发送的内容 + if (!leadingResolved && leadingBuffer) { + leadingResolved = true; + // 再次尝试分离 thinking(完整响应可能包含完整的 thinking 块) + const split = splitLeadingThinkingBlocks(leadingBuffer); + if (split.startedWithThinking && split.complete) { + thinkingContent = split.thinkingContent; + flushVisible(split.remainder); + } else { + flushVisible(leadingBuffer); + } + leadingBuffer = ''; + } + if (firstChunk) { log.endPhase(); } else { @@ -820,7 +845,7 @@ async function handleDirectTextStream( return { rawResponse, - visibleText: clientRequestedThinking ? visibleText : rawResponse, + visibleText, thinkingContent, streamer: attemptStreamer, }; @@ -833,14 +858,11 @@ async function handleDirectTextStream( finalThinkingContent = attempt.thinkingContent; streamer = attempt.streamer; - const textForRefusalCheck = clientRequestedThinking - ? finalVisibleText - : stripThinkingTags(finalRawResponse); - - if (!streamer.hasSentText() && isRefusal(textForRefusalCheck) && retryCount < MAX_REFUSAL_RETRIES) { + // visibleText 始终是剥离 thinking 后的文本,可直接用于拒绝检测 + if (!streamer.hasSentText() && isRefusal(finalVisibleText) && retryCount < MAX_REFUSAL_RETRIES) { retryCount++; log.warn('Handler', 'retry', `检测到拒绝(第${retryCount}次),自动重试`, { - preview: textForRefusalCheck.substring(0, 200), + preview: finalVisibleText.substring(0, 200), }); log.updateSummary({ retryCount }); const retryBody = buildRetryRequest(body, retryCount - 1); @@ -867,16 +889,12 @@ async function handleDirectTextStream( if (finalThinkingContent) { log.recordThinking(finalThinkingContent); log.updateSummary({ thinkingChars: finalThinkingContent.length }); - if (clientRequestedThinking) { - log.info('Handler', 'thinking', `剥离 thinking → content block: ${finalThinkingContent.length} chars, 剩余 ${finalVisibleText.length} chars`); - } else { - log.info('Handler', 'thinking', `保留 thinking 在正文中 (非客户端请求): ${finalThinkingContent.length} chars`); - } + log.info('Handler', 'thinking', `剥离 thinking: ${finalThinkingContent.length} chars, 剩余正文 ${finalVisibleText.length} chars, clientRequested=${clientRequestedThinking}`); } let finalTextToSend: string; - const refusalText = clientRequestedThinking ? finalVisibleText : stripThinkingTags(finalRawResponse); - const usedFallback = !streamer.hasSentText() && isRefusal(refusalText); + // visibleText 现在始终是剥离 thinking 后的文本 + const usedFallback = !streamer.hasSentText() && isRefusal(finalVisibleText); if (usedFallback) { if (isToolCapabilityQuestion(body)) { log.info('Handler', 'refusal', '工具能力询问被拒绝 → 返回 Claude 能力描述'); @@ -911,7 +929,7 @@ async function handleDirectTextStream( writeSSE(res, 'message_stop', { type: 'message_stop' }); const finalRecordedResponse = streamer.hasSentText() - ? sanitizeResponse(clientRequestedThinking ? finalVisibleText : finalRawResponse) + ? sanitizeResponse(finalVisibleText) : finalTextToSend; log.recordFinalResponse(finalRecordedResponse); log.complete(finalRecordedResponse.length, 'end_turn'); @@ -1003,36 +1021,27 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A }); // ★ Thinking 提取(在拒绝检测之前,防止 thinking 内容触发 isRefusal 误判) + // 始终剥离 thinking 标签,避免泄漏到最终文本中 let thinkingContent = ''; if (fullResponse.includes('')) { const { thinkingContent: extracted, strippedText } = extractThinking(fullResponse); if (extracted) { thinkingContent = extracted; + fullResponse = strippedText; log.recordThinking(thinkingContent); log.updateSummary({ thinkingChars: thinkingContent.length }); if (clientRequestedThinking) { - // 客户端原生请求 thinking → 剥离标签,稍后发送 thinking content block - fullResponse = strippedText; log.info('Handler', 'thinking', `剥离 thinking → content block: ${thinkingContent.length} chars, 剩余 ${fullResponse.length} chars`); } else { - // proxy 注入的 thinking → 保留标签在正文中,Claude Code 可直接显示 - log.info('Handler', 'thinking', `保留 thinking 在正文中 (非客户端请求): ${thinkingContent.length} chars`); + log.info('Handler', 'thinking', `剥离 thinking (非客户端请求): ${thinkingContent.length} chars, 剩余 ${fullResponse.length} chars`); } } } - // 拒绝检测 + 自动重试(工具模式和非工具模式均生效) - // ★ 关键:拒绝检测必须在 thinking-stripped 文本上进行 - // 否则 thinking 中的反思性语言(如 "haven't given a specific task")会触发误判 - const getTextForRefusalCheck = () => { - if (fullResponse.includes('')) { - return extractThinking(fullResponse).strippedText; - } - return fullResponse; - }; + // 拒绝检测 + 自动重试 + // fullResponse 已在上方剥离 thinking 标签,可直接用于拒绝检测 const shouldRetryRefusal = () => { - const textToCheck = getTextForRefusalCheck(); - if (!isRefusal(textToCheck)) return false; + if (!isRefusal(fullResponse)) return false; if (hasTools && hasToolCalls(fullResponse)) return false; return true; }; @@ -1044,6 +1053,14 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A const retryBody = buildRetryRequest(body, retryCount - 1); activeCursorReq = await convertToCursorRequest(retryBody); await executeStream(); + // 重试后也需要剥离 thinking 标签 + if (fullResponse.includes('')) { + const { thinkingContent: retryThinking, strippedText: retryStripped } = extractThinking(fullResponse); + if (retryThinking) { + thinkingContent = retryThinking; + fullResponse = retryStripped; + } + } log.info('Handler', 'retry', `重试响应: ${fullResponse.length} chars`, { preview: fullResponse.substring(0, 200) }); } @@ -1294,12 +1311,10 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener let textToSend = fullResponse; // ★ 仅对短响应或开头明确匹配拒绝模式的响应进行压制 - // 长响应(如模型在写报告)中可能碰巧包含某个宽泛的拒绝关键词,不应被误判 - // 截断响应(stopReason=max_tokens)一定不是拒绝 - const strippedResponse = getTextForRefusalCheck(); - const isShortResponse = strippedResponse.trim().length < 500; - const startsWithRefusal = isRefusal(strippedResponse.substring(0, 300)); - const isActualRefusal = stopReason !== 'max_tokens' && (isShortResponse ? isRefusal(strippedResponse) : startsWithRefusal); + // fullResponse 已被剥离 thinking 标签 + const isShortResponse = fullResponse.trim().length < 500; + const startsWithRefusal = isRefusal(fullResponse.substring(0, 300)); + const isActualRefusal = stopReason !== 'max_tokens' && (isShortResponse ? isRefusal(fullResponse) : startsWithRefusal); if (isActualRefusal) { log.info('Handler', 'sanitize', `抑制无工具的完整拒绝响应`, { preview: fullResponse.substring(0, 200) }); @@ -1407,31 +1422,25 @@ async function handleNonStream(res: Response, cursorReq: CursorChatRequest, body }); // ★ Thinking 提取(在拒绝检测之前) + // 始终剥离 thinking 标签,避免泄漏到最终文本中 let thinkingContent = ''; if (fullText.includes('')) { const { thinkingContent: extracted, strippedText } = extractThinking(fullText); if (extracted) { thinkingContent = extracted; + fullText = strippedText; if (clientRequestedThinking) { - fullText = strippedText; log.info('Handler', 'thinking', `非流式剥离 thinking → content block: ${thinkingContent.length} chars, 剩余 ${fullText.length} chars`); } else { - log.info('Handler', 'thinking', `非流式保留 thinking 在正文中: ${thinkingContent.length} chars`); + log.info('Handler', 'thinking', `非流式剥离 thinking (非客户端请求): ${thinkingContent.length} chars, 剩余 ${fullText.length} chars`); } } } - // 拒绝检测 + 自动重试(工具模式和非工具模式均生效) - // ★ 关键:拒绝检测必须在 thinking-stripped 文本上进行 - const getTextForRefusalCheck = () => { - if (fullText.includes('')) { - return extractThinking(fullText).strippedText; - } - return fullText; - }; + // 拒绝检测 + 自动重试 + // fullText 已在上方剥离 thinking 标签,可直接用于拒绝检测 const shouldRetry = () => { - const textToCheck = getTextForRefusalCheck(); - return isRefusal(textToCheck) && !(hasTools && hasToolCalls(fullText)); + return isRefusal(fullText) && !(hasTools && hasToolCalls(fullText)); }; if (shouldRetry()) { @@ -1442,6 +1451,14 @@ async function handleNonStream(res: Response, cursorReq: CursorChatRequest, body const retryBody = buildRetryRequest(body, attempt); activeCursorReq = await convertToCursorRequest(retryBody); fullText = await sendCursorRequestFull(activeCursorReq); + // 重试后也需要剥离 thinking 标签 + if (fullText.includes('')) { + const { thinkingContent: retryThinking, strippedText: retryStripped } = extractThinking(fullText); + if (retryThinking) { + thinkingContent = retryThinking; + fullText = retryStripped; + } + } if (!shouldRetry()) break; } if (shouldRetry()) { @@ -1623,10 +1640,10 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener } else { let textToSend = fullText; // ★ 同样仅对短响应或开头匹配的进行拒绝压制 - const strippedText = getTextForRefusalCheck(); - const isShort = strippedText.trim().length < 500; - const startsRefusal = isRefusal(strippedText.substring(0, 300)); - const isRealRefusal = stopReason !== 'max_tokens' && (isShort ? isRefusal(strippedText) : startsRefusal); + // fullText 已被剥离 thinking 标签 + const isShort = fullText.trim().length < 500; + const startsRefusal = isRefusal(fullText.substring(0, 300)); + const isRealRefusal = stopReason !== 'max_tokens' && (isShort ? isRefusal(fullText) : startsRefusal); if (isRealRefusal) { log.info('Handler', 'sanitize', `非流式抑制纯文本拒绝响应`, { preview: fullText.substring(0, 200) }); textToSend = 'Let me proceed with the task.';