UNPKG

codeplot

Version:

Interactive CLI tool for feature planning and ADR generation using Gemini 2.5 Pro

267 lines (223 loc) 7.07 kB
/** * Simple streaming output processor for AI responses * Uses semantic tags and progressive text assembly instead of complex JSON streaming */ interface Option { id: string; title: string; description: string; recommended: boolean; } interface ParsedContent { header: string; bodyText: string; optionText: string; options: Option[]; } interface ExtractedSection { header?: string; body?: string; optionPrompt?: string; options?: Option[]; } interface ProcessedOption { value: string; title: string; description: string; isRecommended: boolean; } interface ProcessedChunk { parsedData: ParsedContent; isComplete: boolean; displayText: string; } export class SimpleStreamProcessor { private chunks: string[] = []; protected parsedContent: ParsedContent = { header: '', bodyText: '', optionText: '', options: [], }; private isComplete: boolean = false; constructor() { this.reset(); } reset(): void { this.chunks = []; this.parsedContent = { header: '', bodyText: '', optionText: '', options: [], }; this.isComplete = false; } /** * Process a streaming chunk and return formatted content */ processChunk(chunk: string): ProcessedChunk { this.chunks.push(chunk); const fullText = this.chunks.join(''); // Parse using semantic markdown patterns only this.parseIncrementalText(fullText); return { parsedData: this.parsedContent, isComplete: this.isComplete, displayText: this.buildDisplayText(), }; } /** * Parse text progressively using semantic patterns */ private parseIncrementalText(text: string): void { // Parse sections using markdown-like patterns const sections = this.extractSections(text); if (sections.header) { this.parsedContent.header = sections.header; } if (sections.body) { this.parsedContent.bodyText = sections.body; } if (sections.options && sections.options.length > 0) { this.parsedContent.options = sections.options; this.parsedContent.optionText = sections.optionPrompt || 'Choose your preferred option:'; } // Check if response looks complete this.isComplete = this.checkCompleteness(text); } /** * Extract semantic sections from markdown-style text */ private extractSections(text: string): ExtractedSection { const sections: ExtractedSection = {}; // Extract header (first # heading) const headerMatch = text.match(/^#\s+(.+)$/m); if (headerMatch) { sections.header = headerMatch[1].trim(); } // Extract numbered options const optionPattern = /^(\d+)\s*\.\s*\*\*(.+?)\*\*(?:\s*⭐\s*RECOMMENDED)?\s*[\r\n]([\s\S]*?)(?=^\d+\s*\.|$)/gm; const options: Option[] = []; let optionMatch: RegExpExecArray | null; while ((optionMatch = optionPattern.exec(text)) !== null) { const [fullMatch, id, title, description] = optionMatch; const isRecommended = fullMatch.includes('⭐ RECOMMENDED') || id === '1'; options.push({ id, title: title.trim(), description: description.trim().replace(/^\s*[-*]?\s*/, ''), recommended: isRecommended, }); } if (options.length > 0) { sections.options = options; // Look for option prompt before the options const optionPromptMatch = text.match( /(?:^|\n)([^\n]*(?:choose|select|option|prefer)[^\n]*):?\s*(?=\n\s*1\.)/i ); if (optionPromptMatch) { sections.optionPrompt = optionPromptMatch[1].trim(); } } // Extract body text (everything between header and options, or just main content) let bodyStartIndex = 0; let bodyEndIndex = text.length; if (headerMatch && headerMatch.index !== undefined) { bodyStartIndex = headerMatch.index + headerMatch[0].length; } if (options.length > 0) { // Find first option const firstOptionMatch = text.match(/^1\s*\./m); if (firstOptionMatch && firstOptionMatch.index !== undefined) { bodyEndIndex = firstOptionMatch.index; // Look backwards for option prompt const beforeOptions = text.substring(0, bodyEndIndex); const optionPromptMatch = beforeOptions.match( /([^\n]*(?:choose|select|option|prefer)[^\n]*):?\s*$/i ); if (optionPromptMatch && optionPromptMatch.index !== undefined) { bodyEndIndex = optionPromptMatch.index; } } } const bodyText = text.substring(bodyStartIndex, bodyEndIndex).trim(); if (bodyText) { sections.body = bodyText; } return sections; } /** * Check if the response appears complete */ private checkCompleteness(text: string): boolean { // Simple heuristics to determine if response is complete const lines = text.split('\n'); const lastLine = lines[lines.length - 1]?.trim(); // If there are options, check if they seem complete if (this.parsedContent.options.length > 0) { const hasCustomOption = text.includes('custom') || text.includes('own response'); return hasCustomOption || text.includes('💬') || lastLine.endsWith('.'); } // For non-option responses, check for natural endings return lastLine.endsWith('.') || lastLine.endsWith('?') || lastLine.endsWith('!'); } /** * Build display text from parsed content */ private buildDisplayText(): string { let displayText = ''; if (this.parsedContent.header) { displayText += `# ${this.parsedContent.header}\n\n`; } if (this.parsedContent.bodyText) { displayText += this.parsedContent.bodyText; } if (this.parsedContent.optionText && this.parsedContent.options.length > 0) { displayText += `\n\n---\n\n**${this.parsedContent.optionText}**`; } return displayText; } /** * Get processed options */ getOptions(): ProcessedOption[] { if (!this.parsedContent.options || this.parsedContent.options.length === 0) { return []; } const processedOptions = this.parsedContent.options.map((option, index) => ({ value: option.id || String(index + 1), title: option.title, description: option.description || '', isRecommended: option.recommended || false, })); // Add custom response option processedOptions.push({ value: 'custom', title: 'Enter your own response', description: '', isRecommended: false, }); return processedOptions; } /** * Check if response has options */ hasOptions(): boolean { return this.parsedContent.options && this.parsedContent.options.length > 0; } } // Legacy compatibility - provide the old StreamingJsonParser interface export class StreamingJsonParser extends SimpleStreamProcessor { constructor() { super(); } // Keep the old interface for backward compatibility get parsedData(): ParsedContent { return this.parsedContent; } set parsedData(data: ParsedContent) { this.parsedContent = data; } }