/** * streaming-text.ts - 流式文本增量释放辅助 * * 目标: * 1. 为纯正文流提供更接近“打字效果”的增量输出 * 2. 在真正开始向客户端输出前,先保留一小段预热文本,降低拒绝前缀泄漏概率 * 3. 发送时保留尾部保护窗口,给跨 chunk 的清洗规则预留上下文 */ export interface LeadingThinkingSplit { startedWithThinking: boolean; complete: boolean; thinkingContent: string; remainder: string; } export interface IncrementalTextStreamerOptions { warmupChars?: number; guardChars?: number; transform?: (text: string) => string; isBlockedPrefix?: (text: string) => boolean; } export interface IncrementalTextStreamer { push(chunk: string): string; finish(): string; hasUnlocked(): boolean; hasSentText(): boolean; getRawText(): string; } const THINKING_OPEN = ''; 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 标签,返回可用于拒绝检测或最终文本处理的正文。 * * ★ 使用 indexOf + lastIndexOf 而非非贪婪正则,防止 thinking 内容本身 * 包含 字面量时提前截断导致标签泄漏到正文。 */ export function stripThinkingTags(text: string): string { if (!text || !text.includes(THINKING_OPEN)) return text; const startIdx = text.indexOf(THINKING_OPEN); const endIdx = text.lastIndexOf(THINKING_CLOSE); if (endIdx > startIdx) { return (text.slice(0, startIdx) + text.slice(endIdx + THINKING_CLOSE.length)).trim(); } // 未闭合(流式截断)→ 剥离从 开始的全部内容 return text.slice(0, startIdx).trim(); } /** * 检测文本是否以 开头(允许前导空白)。 * * ★ 修复 Issue #64:用位置约束替代宽松的 includes(''), * 防止用户消息或模型正文中的字面量 误触发 extractThinking, * 导致正文内容被错误截断或丢失。 */ export function hasLeadingThinking(text: string): boolean { if (!text) return false; return /^\s*/.test(text); } /** * 只解析“前导 thinking 块”。 * * Cursor 的 thinking 通常位于响应最前面,正文随后出现。 * 这里仅处理前导块,避免把正文中的普通文本误判成 thinking 标签。 */ export function splitLeadingThinkingBlocks(text: string): LeadingThinkingSplit { if (!text) { return { startedWithThinking: false, complete: false, thinkingContent: '', remainder: '', }; } const trimmed = text.trimStart(); if (!trimmed.startsWith(THINKING_OPEN)) { return { startedWithThinking: false, complete: false, thinkingContent: '', remainder: text, }; } let cursor = trimmed; const thinkingParts: string[] = []; while (cursor.startsWith(THINKING_OPEN)) { const closeIndex = cursor.indexOf(THINKING_CLOSE, THINKING_OPEN.length); if (closeIndex === -1) { return { startedWithThinking: true, complete: false, thinkingContent: '', remainder: '', }; } const content = cursor.slice(THINKING_OPEN.length, closeIndex).trim(); if (content) thinkingParts.push(content); cursor = cursor.slice(closeIndex + THINKING_CLOSE.length).trimStart(); } return { startedWithThinking: true, complete: true, thinkingContent: thinkingParts.join('\n\n'), remainder: cursor, }; } /** * 创建增量文本释放器。 * * 释放策略: * - 先缓冲一小段,确认不像拒绝前缀,再开始输出 * - 输出时总是保留尾部 guardChars,不把“边界附近”的文本过早发出去 * - 最终 finish() 时再把剩余文本一次性补齐 */ export function createIncrementalTextStreamer( options: IncrementalTextStreamerOptions = {}, ): IncrementalTextStreamer { const warmupChars = options.warmupChars ?? DEFAULT_WARMUP_CHARS; const guardChars = options.guardChars ?? DEFAULT_GUARD_CHARS; const transform = options.transform ?? ((text: string) => text); const isBlockedPrefix = options.isBlockedPrefix ?? (() => false); let rawText = ''; let sentText = ''; let unlocked = false; let sentAny = false; const tryUnlock = (): boolean => { if (unlocked) return true; const preview = transform(rawText); if (!preview.trim()) return false; const hasBoundary = STREAM_START_BOUNDARY_RE.test(preview); const enoughChars = preview.length >= warmupChars; if (!hasBoundary && !enoughChars) { return false; } if (isBlockedPrefix(preview.trim())) { 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; }; const emitFromRawLength = (rawLength: number): string => { const transformed = transform(rawText.slice(0, rawLength)); if (transformed.length <= sentText.length) return ''; const delta = transformed.slice(sentText.length); sentText = transformed; if (delta) sentAny = true; return delta; }; return { push(chunk: string): string { if (!chunk) return ''; rawText += chunk; if (!tryUnlock()) return ''; const safeRawLength = Math.max(0, rawText.length - guardChars); if (safeRawLength <= 0) return ''; return emitFromRawLength(safeRawLength); }, finish(): string { if (!rawText) return ''; return emitFromRawLength(rawText.length); }, hasUnlocked(): boolean { return unlocked; }, hasSentText(): boolean { return sentAny; }, getRawText(): string { return rawText; }, }; }