diff --git a/src/cursor-client.ts b/src/cursor-client.ts index afdd2e6..e98d866 100644 --- a/src/cursor-client.ts +++ b/src/cursor-client.ts @@ -136,11 +136,11 @@ async function sendCursorRequestInner( const REPEAT_THRESHOLD = 8; // 同一 delta 连续出现 8 次 → 退化 let degenerateAborted = false; - // ★ 行级重复检测:历史消息较多时模型偶发换行重复输出 bug,连续相同行超过阈值则中止并重试 - let lineBuffer = ''; - let lastLine = ''; - let lineRepeatCount = 0; - let lineRepeatAborted = false; + // ★ HTML token 重复检测:历史消息较多时模型偶发连续输出
、 等 HTML token 的 bug + // 用 tagBuffer 跨 delta 拼接,提取完整 token 后检测连续重复,不依赖换行 + 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(); @@ -171,7 +171,6 @@ async function sendCursorRequestInner( if (repeatCount >= REPEAT_THRESHOLD) { console.warn(`[Cursor] ⚠️ 检测到退化循环: "${trimmedDelta}" 已连续重复 ${repeatCount} 次,中止流`); degenerateAborted = true; - // 不再转发此 delta,直接中止 reader.cancel(); break; } @@ -184,32 +183,33 @@ async function sendCursorRequestInner( lastDelta = ''; repeatCount = 0; } - } - // ★ 行级重复检测 - if (event.type === 'text-delta' && event.delta) { - lineBuffer += event.delta; - if (lineBuffer.length > 50) { lineBuffer = ''; } // 超长行不参与检测 - if (lineBuffer.indexOf('\n') !== -1) { - const nlParts = lineBuffer.split('\n'); - lineBuffer = nlParts.pop()!; - for (const completedLine of nlParts) { - const trimLine = completedLine.trim(); - if (!trimLine) continue; - if (trimLine === lastLine) { - lineRepeatCount++; - if (lineRepeatCount >= REPEAT_THRESHOLD) { - console.warn(`[Cursor] ⚠️ 检测到行级重复: "${trimLine.substring(0, 60)}" 已连续重复 ${lineRepeatCount} 次,中止流`); - lineRepeatAborted = true; + // ★ HTML token 重复检测:跨 delta 拼接,提取完整 HTML token 后检测连续重复 + // 解决
、、  等被拆散发送或无换行导致退化检测失效的 bug + 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 { - lastLine = trimLine; - lineRepeatCount = 1; + lastDelta = token; + repeatCount = 1; } } - if (lineRepeatAborted) break; + if (htmlRepeatAborted) break; + } else if (tagBuffer.length > 20) { + // 超过 20 字符还没有完整 HTML token,不是 HTML 序列,清空避免内存累积 + tagBuffer = ''; } } @@ -219,16 +219,16 @@ async function sendCursorRequestInner( } } - if (degenerateAborted || lineRepeatAborted) break; + if (degenerateAborted || htmlRepeatAborted) break; } // ★ 退化循环中止后,抛出特殊错误让外层 sendCursorRequest 不再重试 if (degenerateAborted) { throw new Error('DEGENERATE_LOOP_ABORTED'); } - // ★ 行级重复中止后,抛出普通错误让外层 sendCursorRequest 走正常重试 - if (lineRepeatAborted) { - throw new Error('LINE_REPEAT_ABORTED'); + // ★ HTML token 重复中止后,抛出普通错误让外层 sendCursorRequest 走正常重试 + if (htmlRepeatAborted) { + throw new Error('HTML_REPEAT_ABORTED'); } // 处理剩余 buffer diff --git a/src/streaming-text.ts b/src/streaming-text.ts index 8016b98..69320cd 100644 --- a/src/streaming-text.ts +++ b/src/streaming-text.ts @@ -34,6 +34,8 @@ const THINKING_CLOSE = ''; const DEFAULT_WARMUP_CHARS = 96; const DEFAULT_GUARD_CHARS = 256; const STREAM_START_BOUNDARY_RE = /[\n。!?.!?]/; +const HTML_TOKEN_STRIP_RE = /(<\/?[a-z][a-z0-9]*\s*\/?>|&[a-z]+;)/gi; +const HTML_VALID_RATIO_MIN = 0.2; // 去掉 HTML token 后有效字符占比低于此值则继续缓冲 /** * 剥离完整的 thinking 标签,返回可用于拒绝检测或最终文本处理的正文。 @@ -154,6 +156,17 @@ export function createIncrementalTextStreamer( return false; } + // ★ HTML 内容有效性检查:防止
、、  等纯 HTML token 连续重复时提前 unlock + // 超过 guardChars(256)后强制放行,此时 cursor-client 的 htmlRepeatAborted 早已触发重试 + if (preview.length < guardChars) { + const noSpace = preview.replace(/\s/g, ''); + const stripped = noSpace.replace(HTML_TOKEN_STRIP_RE, ''); + const ratio = noSpace.length === 0 ? 0 : stripped.length / noSpace.length; + if (ratio < HTML_VALID_RATIO_MIN) { + return false; + } + } + unlocked = true; return true; };