andy-stack_vaultkeeper-ai/Services/StreamingMarkdownService.ts
Andrew Beal 28c8ccb44b feat: add quick actions system with provider-aware model settings
Introduce QuickActionsService and QuickAgent for lightweight, single-shot AI
operations. Add a dedicated quickActionModel setting alongside a new top-level
provider setting, with validation ensuring all models match the selected provider
and provider-specific defaults. Refactor FileSystemService to offer both TFile
and path-based overloads (readFile/readFilePath, writeToFile/writeToFilePath,
patchFile/patchFileAtPath). Replace window/document globals with activeWindow/
activeDocument throughout InputService and VaultkeeperAISettingTab for Obsidian
mobile compatibility.
Update linting packages to latest.
2026-04-20 20:20:22 +01:00

240 lines
No EOL
8.3 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";
import wikiLinkPlugin from "remark-wiki-link";
import type { Root as MdastRoot } from "mdast";
import type { Root as HastRoot } from "hast";
import { Resolve } from "./DependencyService";
import { Services } from "./Services";
import { Selector } from "Enums/Selector";
import type { HTMLService } from "./HTMLService";
import type { VaultCacheService } from "./VaultCacheService";
import { Exception } from "Helpers/Exception";
interface IStreamingState {
element: HTMLElement;
buffer: string;
lastProcessedLength: number;
isComplete: boolean;
}
export class StreamingMarkdownService {
private readonly htmlService: HTMLService = Resolve<HTMLService>(Services.HTMLService);
private readonly vaultCacheService: VaultCacheService = Resolve<VaultCacheService>(Services.VaultCacheService);
private readonly processor: Processor<MdastRoot, MdastRoot, HastRoot, HastRoot, string>;
private streamingStates: Map<string, IStreamingState> = new Map<string, IStreamingState>();
constructor() {
this.processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkEmoji)
.use(remarkMath)
.use(wikiLinkPlugin, {
permalinks: this.vaultCacheService.wikiLinks.links,
wikiLinkClassName: Selector.MarkDownLink,
pageResolver: (pageName: string) => [pageName],
hrefTemplate: (permalink: string) => `#/page/${encodeURIComponent(permalink)}`
})
.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) {
Exception.warn(`Markdown processing failed:\n${Exception.messageFrom(error)}`);
return this.getFallbackHTML(text);
}
}
public initializeStream(messageId: string, container: HTMLElement) {
this.htmlService.clearElement(container);
this.streamingStates.set(messageId, {
element: container,
buffer: "",
lastProcessedLength: 0,
isComplete: false
});
}
public streamChunk(messageId: string, fullText: string) {
const state = this.streamingStates.get(messageId);
if (!state || state.isComplete) {
return;
}
state.buffer = fullText;
// Use debounced rendering for better performance
this.debouncedRender(messageId);
}
private renderTimeouts = new Map<string, ReturnType<typeof activeWindow.setTimeout>>();
private debouncedRender(messageId: string, immediate: boolean = false) {
const existingTimeout = this.renderTimeouts.get(messageId);
if (existingTimeout) {
activeWindow.clearTimeout(existingTimeout);
}
const render = () => {
const state = this.streamingStates.get(messageId);
if (!state) {
return;
}
try {
const html = this.formatText(state.buffer);
this.htmlService.setHTMLContent(state.element, html);
state.lastProcessedLength = state.buffer.length;
} catch (error) {
Exception.warn(`Streaming render failed:\n${Exception.messageFrom(error)}`);
}
this.renderTimeouts.delete(messageId);
};
if (immediate) {
render();
} else {
const timeout = activeWindow.setTimeout(render, 50); // 50ms debounce
this.renderTimeouts.set(messageId, timeout);
}
}
public finalizeStream(messageId: string, fullText: string) {
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) {
activeWindow.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: string, math: string) => {
// 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
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("");
}
}