UNPKG

termcode

Version:

Superior terminal AI coding agent with enterprise-grade security, intelligent error recovery, performance monitoring, and plugin system - Advanced Claude Code alternative

220 lines (219 loc) 7.87 kB
import { EventEmitter } from "node:events"; import { getProvider } from "../providers/index.js"; import { log } from "../util/logging.js"; export class StreamProcessor extends EventEmitter { buffer = ""; isStreaming = false; cancelled = false; startTime = 0; tokensGenerated = 0; updateTimer; currentLine = ""; config; constructor(config = {}) { super(); this.config = { showCursor: true, colorizeCode: true, showTokenCount: true, bufferSize: 10, updateInterval: 50, ...config }; } async streamChat(provider, model, messages, options = {}) { if (this.isStreaming) { throw new Error("Stream already active"); } this.isStreaming = true; this.cancelled = false; this.buffer = ""; this.tokensGenerated = 0; this.startTime = Date.now(); this.currentLine = ""; this.emit("start"); try { // Check if provider supports streaming const providerInstance = getProvider(provider); if (providerInstance.streamChat) { return await this.handleNativeStreaming(providerInstance, model, messages, options); } else { return await this.handlePolledStreaming(providerInstance, model, messages, options); } } catch (error) { this.isStreaming = false; this.emit("error", error); throw error; } } async handleNativeStreaming(provider, model, messages, options) { return new Promise((resolve, reject) => { const stream = provider.streamChat(messages, { model, ...options }); stream.on('data', (chunk) => { if (this.cancelled) { stream.destroy(); return; } this.processChunk(chunk); }); stream.on('end', () => { this.finishStreaming(); resolve(this.buffer); }); stream.on('error', (error) => { this.isStreaming = false; this.clearUpdateTimer(); reject(error); }); this.startUpdateTimer(); }); } async handlePolledStreaming(provider, model, messages, options) { // Fallback: simulate streaming by breaking up the response const fullResponse = await provider.chat(messages, { model, ...options }); return new Promise((resolve) => { const words = fullResponse.split(' '); let wordIndex = 0; const simulateStream = () => { if (this.cancelled || wordIndex >= words.length) { this.finishStreaming(); resolve(this.buffer); return; } const word = words[wordIndex]; this.processChunk(word + ' '); wordIndex++; // Random delay to simulate realistic streaming const delay = Math.random() * 100 + 20; setTimeout(simulateStream, delay); }; this.startUpdateTimer(); simulateStream(); }); } processChunk(chunk) { this.buffer += chunk; this.tokensGenerated += this.estimateTokens(chunk); // Add to current line for display this.currentLine += chunk; // If we have a newline, process the complete line if (chunk.includes('\n')) { const lines = this.currentLine.split('\n'); this.currentLine = lines.pop() || ""; for (const line of lines.slice(0, -1)) { this.displayLine(line); } } this.emit("chunk", chunk); this.emit("stats", this.getStats()); } displayLine(line) { if (this.config.colorizeCode) { // Simple syntax highlighting line = this.highlightSyntax(line); } // Clear current line and write new content process.stdout.write('\r\x1b[K'); process.stdout.write(line + '\n'); } startUpdateTimer() { this.updateTimer = setInterval(() => { this.updateDisplay(); }, this.config.updateInterval); } clearUpdateTimer() { if (this.updateTimer) { clearInterval(this.updateTimer); this.updateTimer = undefined; } } updateDisplay() { if (!this.isStreaming) return; const stats = this.getStats(); const cursor = this.config.showCursor ? (Date.now() % 1000 > 500 ? '▊' : ' ') : ''; // Update current line with cursor process.stdout.write('\r\x1b[K'); process.stdout.write(this.currentLine + cursor); // Show stats in a separate line if enabled if (this.config.showTokenCount) { const statsLine = `\n${log.colors.dim(`Tokens: ${stats.tokensGenerated} | Speed: ${stats.tokensPerSecond.toFixed(1)}/s | Time: ${(stats.timeElapsed / 1000).toFixed(1)}s`)}`; process.stdout.write(statsLine); process.stdout.write('\x1b[1A'); // Move cursor back up } } finishStreaming() { this.isStreaming = false; this.clearUpdateTimer(); // Display final line if (this.currentLine) { this.displayLine(this.currentLine); } // Clear cursor and show final stats process.stdout.write('\r\x1b[K'); const stats = this.getStats(); this.emit("complete", { content: this.buffer, stats }); if (this.config.showTokenCount) { const finalStats = `\n${log.colors.green(`✓ Generated ${stats.tokensGenerated} tokens in ${(stats.timeElapsed / 1000).toFixed(2)}s (${stats.tokensPerSecond.toFixed(1)} tokens/s)`)}`; process.stdout.write(finalStats + '\n'); } } highlightSyntax(line) { // Simple syntax highlighting for common languages return line .replace(/\b(function|const|let|var|class|interface|type)\b/g, log.colors.magenta('$1')) .replace(/\b(if|else|for|while|return|import|export)\b/g, log.colors.blue('$1')) .replace(/(['"`])((?:(?!\1)[^\\]|\\.)*)(\1)/g, log.colors.green('$1$2$3')) .replace(/\/\/.*$/g, log.colors.dim('$&')) .replace(/\/\*[\s\S]*?\*\//g, log.colors.dim('$&')); } estimateTokens(text) { // Simple token estimation return Math.ceil(text.length / 4); } getStats() { const timeElapsed = Date.now() - this.startTime; const tokensPerSecond = timeElapsed > 0 ? (this.tokensGenerated / timeElapsed) * 1000 : 0; return { tokensGenerated: this.tokensGenerated, timeElapsed, tokensPerSecond }; } cancel() { if (!this.isStreaming) return; this.cancelled = true; this.isStreaming = false; this.clearUpdateTimer(); process.stdout.write('\r\x1b[K'); log.warn("Generation cancelled by user"); this.emit("cancelled", { partialContent: this.buffer, stats: this.getStats() }); } isActive() { return this.isStreaming; } } // Global stream processor instance let globalStreamProcessor = null; export function getStreamProcessor() { if (!globalStreamProcessor) { globalStreamProcessor = new StreamProcessor(); } return globalStreamProcessor; } export function cancelCurrentStream() { if (globalStreamProcessor && globalStreamProcessor.isActive()) { globalStreamProcessor.cancel(); return true; } return false; }