mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
218 lines
No EOL
7.1 KiB
TypeScript
218 lines
No EOL
7.1 KiB
TypeScript
import { unified, type Processor } from 'unified';
|
|
import remarkParse from 'remark-parse';
|
|
import remarkMath from 'remark-math';
|
|
import remarkGfm from 'remark-gfm';
|
|
import remarkEmoji from 'remark-emoji';
|
|
import remarkRehype from 'remark-rehype';
|
|
import rehypeKatex from 'rehype-katex';
|
|
import rehypeHighlight from 'rehype-highlight';
|
|
import rehypeStringify from 'rehype-stringify';
|
|
|
|
interface StreamingState {
|
|
element: HTMLElement;
|
|
buffer: string;
|
|
lastProcessedLength: number;
|
|
isComplete: boolean;
|
|
}
|
|
|
|
export class StreamingMarkdownService {
|
|
private readonly processor: Processor<any, any, any, any, any> | null = null;
|
|
private streamingStates = new Map<string, StreamingState>();
|
|
|
|
constructor() {
|
|
this.processor = unified()
|
|
.use(remarkParse)
|
|
.use(remarkGfm)
|
|
.use(remarkEmoji)
|
|
.use(remarkMath)
|
|
.use(remarkRehype, {
|
|
allowDangerousHtml: false
|
|
})
|
|
.use(rehypeKatex)
|
|
.use(rehypeHighlight, {
|
|
detect: true,
|
|
plainText: ['txt', 'text'],
|
|
aliases: {
|
|
javascript: ['js', 'jsx'],
|
|
typescript: ['ts', 'tsx'],
|
|
python: ['py'],
|
|
markdown: ['md', 'mdx'],
|
|
shell: ['bash', 'sh', 'zsh']
|
|
}
|
|
})
|
|
.use(rehypeStringify, {
|
|
allowDangerousHtml: false,
|
|
allowDangerousCharacters: false,
|
|
closeSelfClosing: true
|
|
});
|
|
}
|
|
|
|
public formatText(text: string): string {
|
|
try {
|
|
const preprocessed = this.preprocessContent(text);
|
|
const result = this.processor!.processSync(preprocessed);
|
|
return String(result);
|
|
} catch (error) {
|
|
console.warn('Markdown processing failed:', error);
|
|
return this.getFallbackHTML(text);
|
|
}
|
|
}
|
|
|
|
// Simplified streaming approach
|
|
public initializeStream(messageId: string, container: HTMLElement): void {
|
|
container.innerHTML = '';
|
|
|
|
this.streamingStates.set(messageId, {
|
|
element: container,
|
|
buffer: '',
|
|
lastProcessedLength: 0,
|
|
isComplete: false
|
|
});
|
|
}
|
|
|
|
public streamChunk(messageId: string, fullText: string): void {
|
|
const state = this.streamingStates.get(messageId);
|
|
if (!state || state.isComplete) return;
|
|
|
|
// Update buffer
|
|
state.buffer = fullText;
|
|
|
|
// Use debounced rendering for better performance
|
|
this.debouncedRender(messageId);
|
|
}
|
|
|
|
private renderTimeouts = new Map<string, NodeJS.Timeout>();
|
|
|
|
private debouncedRender(messageId: string, immediate: boolean = false): void {
|
|
const existingTimeout = this.renderTimeouts.get(messageId);
|
|
if (existingTimeout) {
|
|
clearTimeout(existingTimeout);
|
|
}
|
|
|
|
const render = () => {
|
|
const state = this.streamingStates.get(messageId);
|
|
if (!state) return;
|
|
|
|
try {
|
|
const html = this.formatText(state.buffer);
|
|
state.element.innerHTML = html;
|
|
state.lastProcessedLength = state.buffer.length;
|
|
} catch (error) {
|
|
console.warn('Streaming render failed:', error);
|
|
}
|
|
|
|
this.renderTimeouts.delete(messageId);
|
|
};
|
|
|
|
if (immediate) {
|
|
render();
|
|
} else {
|
|
const timeout = setTimeout(render, 50); // 50ms debounce
|
|
this.renderTimeouts.set(messageId, timeout);
|
|
}
|
|
}
|
|
|
|
public finalizeStream(messageId: string, fullText: string): void {
|
|
const state = this.streamingStates.get(messageId);
|
|
if (!state) return;
|
|
|
|
state.isComplete = true;
|
|
state.buffer = fullText;
|
|
|
|
// Final render without debounce
|
|
this.debouncedRender(messageId, true);
|
|
|
|
// Cleanup
|
|
this.streamingStates.delete(messageId);
|
|
const timeout = this.renderTimeouts.get(messageId);
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
this.renderTimeouts.delete(messageId);
|
|
}
|
|
}
|
|
|
|
private preprocessContent(content: string): string {
|
|
// Simplified and safer preprocessing
|
|
return content
|
|
// Normalize line endings
|
|
.replace(/\r\n/g, '\n')
|
|
.replace(/\r/g, '\n')
|
|
// Convert LaTeX delimiters
|
|
.replace(/\\\[([\s\S]*?)\\\]/g, (match, math) => {
|
|
// Ensure math blocks are on their own lines
|
|
return '\n$$\n' + math.trim() + '\n$$\n';
|
|
})
|
|
.replace(/\\\(([\s\S]*?)\\\)/g, '$$$1$$')
|
|
// Ensure headers have blank lines before them (but not at start)
|
|
.replace(/([^\n])\n(#{1,6}\s)/g, '$1\n\n$2')
|
|
// Collapse excessive newlines but preserve intentional spacing
|
|
.replace(/\n{4,}/g, '\n\n\n')
|
|
// Clean up list formatting - ensure consistent spacing
|
|
.replace(/^(\s*)([*+-]|\d+\.)\s+/gm, '$1$2 ')
|
|
// Ensure task list checkboxes are properly formatted
|
|
.replace(/^(\s*)([*+-])\s*\[([ x])\]/gm, '$1$2 [$3]');
|
|
}
|
|
|
|
private getFallbackHTML(text: string): string {
|
|
// Improved fallback with basic markdown support
|
|
const escaped = text
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
|
|
const lines = escaped.split('\n');
|
|
const html: string[] = [];
|
|
let inList = false;
|
|
let inCodeBlock = false;
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('```')) {
|
|
if (inCodeBlock) {
|
|
html.push('</code></pre>');
|
|
inCodeBlock = false;
|
|
} else {
|
|
html.push('<pre><code>');
|
|
inCodeBlock = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (inCodeBlock) {
|
|
html.push(line + '\n');
|
|
continue;
|
|
}
|
|
|
|
// Basic list support
|
|
if (/^[*+-]\s/.test(line)) {
|
|
if (!inList) {
|
|
html.push('<ul>');
|
|
inList = true;
|
|
}
|
|
html.push(`<li>${line.substring(2)}</li>`);
|
|
} else if (inList && line.trim() === '') {
|
|
html.push('</ul>');
|
|
inList = false;
|
|
} else {
|
|
if (inList) {
|
|
html.push('</ul>');
|
|
inList = false;
|
|
}
|
|
|
|
// Basic formatting
|
|
const formatted = line
|
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
.replace(/`(.+?)`/g, '<code>$1</code>');
|
|
|
|
if (line.trim()) {
|
|
html.push(`<p>${formatted}</p>`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (inList) html.push('</ul>');
|
|
if (inCodeBlock) html.push('</code></pre>');
|
|
|
|
return html.join('');
|
|
}
|
|
} |