const compactWhitespace = (value: string) => value.replace(/\s+/g, " ").trim(); const MAX_SPEECH_CHUNK_LENGTH = 520; const MIN_SPEECH_CHUNK_LENGTH = 180; const SPEECH_SENTENCE_PATTERN = /[^。!?!?;;\n]+(?:[。!?!?;;]+|(?=\n|$))/g; export type AgentSpeechState = "idle" | "loading" | "playing" | "paused"; export type AgentSpeakOptions = { startOffset?: number; }; const normalizeWithOffsetMap = (value: string) => { let normalized = ""; const offsetMap: number[] = []; let previousWasWhitespace = false; Array.from(value).forEach((character, index) => { if (/\s/u.test(character)) { if (!previousWasWhitespace && normalized.length > 0) { normalized += " "; offsetMap.push(index); } previousWasWhitespace = true; return; } normalized += character; offsetMap.push(index); previousWasWhitespace = false; }); return { normalized: normalized.trimEnd(), offsetMap }; }; export function stripSpeechMarkdown(markdown: string) { return markdown .replace(/```[\s\S]*?```/g, "") .replace(/`([^`]+)`/g, "$1") .replace(/!\[.*?\]\(.*?\)/g, "") .replace(/\[([^\]]+)\]\(.*?\)/g, "$1") .replace(/#{1,6}\s+/g, "") .replace(/\*{1,3}(.+?)\*{1,3}/g, "$1") .replace(/~~(.+?)~~/g, "$1") .replace(/^>\s+/gm, "") .replace(/^[-*+]\s+/gm, "") .replace(/^\d+\.\s+/gm, "") .replace(/<[^>]+>/g, "") .replace(/\n{2,}/g, "\n") .trim(); } export function findSpeechSelectionStartOffset(text: string, selectedText: string): number | null { const needle = selectedText.trim(); if (!needle) return null; const exactIndex = text.indexOf(needle); if (exactIndex >= 0) return exactIndex; const normalizedNeedle = compactWhitespace(needle); if (!normalizedNeedle) return null; const haystack = normalizeWithOffsetMap(text); const normalizedIndex = haystack.normalized.indexOf(normalizedNeedle); if (normalizedIndex < 0) return null; return haystack.offsetMap[normalizedIndex] ?? null; } export function splitSpeechTextIntoChunks(text: string): string[] { const normalizedText = text.trim(); if (!normalizedText) return []; const segments = Array.from(normalizedText.matchAll(SPEECH_SENTENCE_PATTERN), (match) => compactWhitespace(match[0]) ).filter(Boolean); const sourceSegments = segments.length > 0 ? segments : [normalizedText]; const chunks: string[] = []; let currentChunk = ""; const flush = () => { if (!currentChunk) return; chunks.push(currentChunk); currentChunk = ""; }; for (const segment of sourceSegments) { if (segment.length > MAX_SPEECH_CHUNK_LENGTH) { flush(); for (let offset = 0; offset < segment.length; offset += MAX_SPEECH_CHUNK_LENGTH) { chunks.push(segment.slice(offset, offset + MAX_SPEECH_CHUNK_LENGTH)); } continue; } const candidate = currentChunk ? `${currentChunk}${segment}` : segment; if ( currentChunk && candidate.length > MAX_SPEECH_CHUNK_LENGTH && currentChunk.length >= MIN_SPEECH_CHUNK_LENGTH ) { flush(); currentChunk = segment; continue; } currentChunk = candidate; } flush(); return chunks; }