andy-stack_vaultkeeper-ai/Services/StreamingMarkdownService.ts
Andrew Beal 7757800127 refactor: simplify chat layout with turn-based grouping
Replace complex dynamic padding system with turn-based message grouping that scrolls to the latest user/assistant exchange. Remove manual height calculations and message element tracking in favor of CSS-based layout using .message-group containers. Neutralize leading frontmatter markers in streaming markdown to prevent Obsidian from hiding content.
2026-07-10 22:57:25 +01:00

113 lines
No EOL
4.2 KiB
TypeScript

import { Component, MarkdownRenderer } from "obsidian";
import type VaultkeeperAIPlugin from "main";
import { Resolve } from "./DependencyService";
import { Services } from "./Services";
interface RenderState {
frozenContainer: HTMLElement;
liveContainer: HTMLElement;
frozenUpTo: number;
frozenText: string;
component: Component;
}
export class StreamingMarkdownService {
private readonly plugin: VaultkeeperAIPlugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
private readonly states = new WeakMap<HTMLElement, RenderState>();
public async render(markdown: string, container: HTMLElement, isFinal: boolean = false): Promise<void> {
markdown = this.neutraliseFrontmatter(markdown);
if (isFinal) {
const existing = this.states.get(container);
if (existing) {
existing.component.unload();
this.states.delete(container);
}
const component = new Component();
component.load();
container.empty();
await MarkdownRenderer.render(this.plugin.app, markdown, container, "", component);
component.unload();
return;
}
const state = this.getOrCreateState(container);
const splitPoint = this.findSplitPoint(markdown);
const frozenCandidate = markdown.slice(0, splitPoint);
const liveTail = markdown.slice(splitPoint);
if (frozenCandidate.length > state.frozenUpTo) {
const newSlice = frozenCandidate.slice(state.frozenUpTo);
const tempDiv = createDiv();
await MarkdownRenderer.render(this.plugin.app, newSlice, tempDiv, "", state.component);
while (tempDiv.firstChild) {
state.frozenContainer.appendChild(tempDiv.firstChild);
}
state.frozenUpTo = frozenCandidate.length;
state.frozenText = frozenCandidate;
}
state.liveContainer.empty();
if (liveTail.trim()) {
await MarkdownRenderer.render(this.plugin.app, liveTail, state.liveContainer, "", state.component);
}
}
// Obsidian's renderer treats a leading "---" line as the start of YAML
// frontmatter and hides everything up to the closing "---". Swap it for
// "***" — an identical <hr> that can't open frontmatter. Same length, so
// frozenUpTo offsets from earlier incremental renders stay valid.
private neutraliseFrontmatter(markdown: string): string {
return markdown.replace(/^---(?=[ \t]*(?:\r?\n|$))/, "***");
}
private getOrCreateState(container: HTMLElement): RenderState {
if (this.states.has(container)) {
return this.states.get(container)!;
}
container.empty();
const frozenContainer = createDiv();
const liveContainer = createDiv();
container.appendChild(frozenContainer);
container.appendChild(liveContainer);
const component = new Component();
component.load();
const state: RenderState = { frozenContainer, liveContainer, frozenUpTo: 0, frozenText: "", component };
this.states.set(container, state);
return state;
}
// Returns the char index at which the "live tail" begins.
// Everything before this index is safe to freeze (complete blocks).
private findSplitPoint(text: string): number {
const lastDoubleNewline = text.lastIndexOf("\n\n");
if (lastDoubleNewline === -1) {
return 0;
}
const candidate = text.slice(0, lastDoubleNewline);
// Count unmatched opening code fences in the candidate.
// If odd, there's an open fence — walk back to before it.
const fenceRegex = /^```/gm;
let fenceCount = 0;
let lastOpenFenceIndex = -1;
for (const match of candidate.matchAll(fenceRegex)) {
fenceCount++;
if (fenceCount % 2 === 1) {
lastOpenFenceIndex = match.index!;
}
}
if (fenceCount % 2 === 1) {
// Odd number of fences — open code block, back up to before it
return lastOpenFenceIndex > 0 ? lastOpenFenceIndex : 0;
}
return lastDoubleNewline;
}
}