mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
refactor: replace custom markdown pipeline with Obsidian's native MarkdownRenderer.
Fix reactive issue for web and chat mode display. Rewrites StreamingMarkdownService to use Obsidian's built-in MarkdownRenderer instead of the unified/remark/rehype/KaTeX stack, eliminating ~700 lines of custom markdown processing, CSS, and streaming state management. Internal link handling now relies on Obsidian's native .internal-link class and data-href attributes. The streaming split-point algorithm (freeze completed blocks, re-render the live tail) is preserved.
This commit is contained in:
parent
857adbbf1c
commit
aed4caf347
11 changed files with 645 additions and 3394 deletions
|
|
@ -8,21 +8,16 @@
|
|||
import { Role } from "Enums/Role";
|
||||
import type { ConversationContent } from "Conversations/ConversationContent";
|
||||
import { tick } from "svelte";
|
||||
import { Selector } from "Enums/Selector";
|
||||
import { Exception } from "Helpers/Exception";
|
||||
import { getOuterHeight, setElementIcon } from "Helpers/ElementHelper";
|
||||
|
||||
export let messages: ConversationContent[] = [];
|
||||
export let currentThought: string | null = null;
|
||||
export let isSubmitting: boolean = false;
|
||||
export let chatContainer: HTMLDivElement;
|
||||
export let currentStreamingMessageId: string | null = null;
|
||||
|
||||
export function resetChatArea() {
|
||||
autoScroll = true;
|
||||
messageElements = [];
|
||||
lastProcessedContent.clear();
|
||||
currentStreamFinalized = false;
|
||||
if (chatAreaPaddingElement) {
|
||||
chatAreaPaddingElement.style.padding = "0px";
|
||||
}
|
||||
|
|
@ -115,8 +110,6 @@
|
|||
let streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
|
||||
|
||||
let messageElements: { element: HTMLElement, index: number, role: Role }[] = [];
|
||||
let lastProcessedContent: Map<string, string> = new Map<string, string>();
|
||||
let currentStreamFinalized: boolean = false;
|
||||
|
||||
function getGreetingByTime(): string {
|
||||
const hour = new Date().getHours();
|
||||
|
|
@ -139,66 +132,19 @@
|
|||
}
|
||||
}
|
||||
|
||||
function updateMessageContent(messageId: string, content: string, isCurrentlyStreaming: boolean) {
|
||||
if (isCurrentlyStreaming) {
|
||||
streamingMarkdownService.streamChunk(messageId, content);
|
||||
currentStreamFinalized = false;
|
||||
} else if (!currentStreamFinalized) {
|
||||
streamingMarkdownService.finalizeStream(messageId, content);
|
||||
currentStreamFinalized = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Process static messages (user messages and initial load)
|
||||
function getStaticHTML(message: ConversationContent): string {
|
||||
if (message.role === Role.User) {
|
||||
return `<div>${message.content}</div>`;
|
||||
}
|
||||
|
||||
// For assistant messages, check if this specific message is currently streaming
|
||||
const messageId = message.timestamp.getTime().toString();
|
||||
const isCurrentlyStreaming = currentStreamingMessageId === messageId;
|
||||
|
||||
// For assistant messages that aren't streaming, use traditional parsing
|
||||
if (!isCurrentlyStreaming) {
|
||||
const content = message.getDisplayContent();
|
||||
if (message.errorType) {
|
||||
return `<div class="${Selector.ErrorSelector}">${content}</div>`;
|
||||
function messageRenderAction(element: HTMLElement, message: ConversationContent) {
|
||||
streamingMarkdownService.render(message.getDisplayContent(), element);
|
||||
return {
|
||||
update(newMessage: ConversationContent) {
|
||||
streamingMarkdownService.render(newMessage.getDisplayContent(), element, !isSubmitting);
|
||||
}
|
||||
|
||||
try {
|
||||
return streamingMarkdownService.formatText(content) || `<div>${content}</div>`;
|
||||
} catch (error) {
|
||||
Exception.log(error);
|
||||
return `<div>${content}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
return ""; // Streaming messages will be handled by the streaming service
|
||||
}
|
||||
|
||||
function streamingAction(element: HTMLElement, messageId: string) {
|
||||
streamingMarkdownService.initializeStream(messageId, element);
|
||||
};
|
||||
}
|
||||
|
||||
function trackingAction(element: HTMLElement, { index, role }: { index: number, role: Role }) {
|
||||
messageElements.push({ index: index, element: element, role: role });
|
||||
}
|
||||
|
||||
function observeResize(element: HTMLElement) {
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateChatAreaLayout("smooth");
|
||||
});
|
||||
|
||||
observer.observe(element);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
observer.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// decide if we should be auto scrolling
|
||||
function handleScroll() {
|
||||
if (!chatContainer) {
|
||||
|
|
@ -228,29 +174,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Track streaming messages and update them incrementally
|
||||
$: {
|
||||
messages.forEach((message) => {
|
||||
if (message.role !== Role.User) {
|
||||
const messageId = message.timestamp.getTime().toString();
|
||||
const lastContent = lastProcessedContent.get(messageId) || "";
|
||||
|
||||
// Only update if content has changed
|
||||
if (message.content !== lastContent) {
|
||||
// Check if this specific message is currently streaming
|
||||
const isCurrentlyStreaming = currentStreamingMessageId === messageId;
|
||||
|
||||
const content = message.getDisplayContent();
|
||||
// Only process through streaming service if actively streaming
|
||||
if (isCurrentlyStreaming) {
|
||||
updateMessageContent(messageId, content, isCurrentlyStreaming);
|
||||
}
|
||||
lastProcessedContent.set(messageId, content);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$: {
|
||||
if (messages.length === 0 && chatAreaPaddingElement) {
|
||||
chatAreaPaddingElement.style.padding = "0px";
|
||||
|
|
@ -262,14 +185,14 @@
|
|||
{#if messages.length > 0}
|
||||
<div class="top-fade"></div>
|
||||
{/if}
|
||||
<div class="chat-area" bind:this={chatContainer} on:scroll={handleScroll} use:observeResize>
|
||||
<div class="chat-area" bind:this={chatContainer} on:scroll={handleScroll}>
|
||||
{#each messages as message, index}
|
||||
{@const content = message.getDisplayContent()}
|
||||
{#if message.shouldDisplayContent && content.trim() !== ""}
|
||||
{#if message.role === Role.User}
|
||||
<div class="message-container {Role.User}" use:trackingAction={{ index, role: Role.User }}>
|
||||
<div class="message-bubble {Role.User}">
|
||||
<div class="message-text-user-container content-fade-in" contenteditable="false">
|
||||
<div class="message-text-user-container" contenteditable="false">
|
||||
<div class="message-text-user">
|
||||
{@html content}
|
||||
</div>
|
||||
|
|
@ -297,12 +220,8 @@
|
|||
{@const messageId = message.timestamp.getTime().toString()}
|
||||
<div class="message-container {Role.Assistant}" use:trackingAction={{ index, role: Role.Assistant }}>
|
||||
<div class="message-bubble {Role.Assistant}">
|
||||
<div class="markdown-content content-fade-in {currentStreamingMessageId === messageId ? "streaming" : ""}">
|
||||
{#if currentStreamingMessageId === messageId}
|
||||
<div use:streamingAction={messageId} class="streaming-content"></div>
|
||||
{:else}
|
||||
{@html getStaticHTML(message)}
|
||||
{/if}
|
||||
<div class="markdown-content">
|
||||
<div use:messageRenderAction={message} class="streaming-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@
|
|||
let inputMode: InputMode = InputMode.Normal;
|
||||
let questionResolver: ((answer: string) => void) | null = null;
|
||||
|
||||
let webSearchActive: boolean = settingsService.settings.enableWebSearch;
|
||||
let editsAllowed: boolean = chatModeAllowsEdits(chatMode);
|
||||
|
||||
let countdownIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
let countdownSecondsRemaining: number = 0;
|
||||
let inputInitialHeight: number = 0;
|
||||
|
|
@ -72,7 +75,13 @@
|
|||
const diffClosedRef: EventRef = eventService.on(Event.DiffClosed, () => { inputMode = InputMode.Normal; focusInput(); });
|
||||
const rateLimitCountdownRef: EventRef = eventService.on(Event.RateLimitCountdown, (delayMs: number) => { startCountdown(delayMs); });
|
||||
|
||||
settingsService.subscribeToSettingsChanged(componentToken, () => chatMode = settingsService.settings.chatMode);
|
||||
settingsService.subscribeToSettingsChanged(componentToken, () => {
|
||||
chatMode = settingsService.settings.chatMode;
|
||||
editsAllowed = chatModeAllowsEdits(chatMode);
|
||||
if (chatModeButton){
|
||||
setIcon(chatModeButton, iconForChatMode(chatMode));
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
userInstructionActive = (await aiPrompt.userInstruction()).trim() !== "";
|
||||
|
|
@ -294,13 +303,16 @@
|
|||
}
|
||||
|
||||
function toggleWebSearch() {
|
||||
const newState = !settingsService.settings.enableWebSearch;
|
||||
settingsService.updateSettings(settings => {
|
||||
settings.enableWebSearch = !settingsService.settings.enableWebSearch;
|
||||
settings.enableWebSearch = newState;
|
||||
});
|
||||
webSearchActive = newState;
|
||||
}
|
||||
|
||||
function toggleChatModeSelectionArea() {
|
||||
chatModeSelectionAreaActive = !chatModeSelectionAreaActive;
|
||||
|
||||
}
|
||||
|
||||
async function handleKeydown(e: KeyboardEvent) {
|
||||
|
|
@ -559,10 +571,10 @@
|
|||
|
||||
<button
|
||||
id="web-search-button"
|
||||
class:input-button-highlight={settingsService.settings.enableWebSearch}
|
||||
class:input-button-highlight={webSearchActive}
|
||||
bind:this={webSearchButton}
|
||||
on:click={toggleWebSearch}
|
||||
aria-label={settingsService.settings.enableWebSearch ? Copy.ButtonTurnOffWebSearch : Copy.ButtonTurnOnWebSearch}>
|
||||
aria-label={webSearchActive ? Copy.ButtonTurnOffWebSearch : Copy.ButtonTurnOnWebSearch}>
|
||||
</button>
|
||||
|
||||
<div
|
||||
|
|
@ -599,7 +611,7 @@
|
|||
|
||||
<button
|
||||
id="chat-mode-button"
|
||||
class:input-button-highlight={chatModeAllowsEdits(chatMode)}
|
||||
class:input-button-highlight={editsAllowed}
|
||||
bind:this={chatModeButton}
|
||||
on:click={toggleChatModeSelectionArea}
|
||||
disabled={isSubmitting}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
import { Conversation } from "Conversations/Conversation";
|
||||
import type VaultkeeperAIPlugin from "main";
|
||||
import { openPluginSettings } from "Helpers/Helpers";
|
||||
import { Selector } from "Enums/Selector";
|
||||
import type { WorkSpaceService } from "Services/WorkSpaceService";
|
||||
import type { ChatService } from "Services/ChatService";
|
||||
import type { ConversationFileSystemService } from "Services/ConversationFileSystemService";
|
||||
|
|
@ -19,7 +18,6 @@
|
|||
import ChatPlanArea from "./ChatPlanArea.svelte";
|
||||
import type { ExecutionPlanStore } from "Stores/ExecutionPlanStore";
|
||||
import type { StreamingMarkdownService } from "Services/StreamingMarkdownService";
|
||||
import { HTMLService } from "Services/HTMLService";
|
||||
import { AITool, fromString } from "Enums/AITool";
|
||||
|
||||
const plugin: VaultkeeperAIPlugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
|
||||
|
|
@ -29,7 +27,6 @@
|
|||
const workSpaceService: WorkSpaceService = Resolve<WorkSpaceService>(Services.WorkSpaceService);
|
||||
const conversationService: ConversationFileSystemService = Resolve<ConversationFileSystemService>(Services.ConversationFileSystemService);
|
||||
const streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
|
||||
const htmlService: HTMLService = Resolve<HTMLService>(Services.HTMLService);
|
||||
const abortService: AbortService = Resolve<AbortService>(Services.AbortService);
|
||||
|
||||
let chatContainer: HTMLDivElement;
|
||||
|
|
@ -39,7 +36,6 @@
|
|||
let hasNoApiKey = false;
|
||||
let isSubmitting = false;
|
||||
let busyPlanning = false;
|
||||
let currentStreamingMessageId: string | null = null;
|
||||
|
||||
let conversation: Conversation = new Conversation();
|
||||
let attachments: Attachment[] = [];
|
||||
|
|
@ -63,21 +59,19 @@
|
|||
async function handleLinkClick(evt: MouseEvent) {
|
||||
const target = evt.target as HTMLElement;
|
||||
|
||||
const link = target.closest(`.${Selector.MarkDownLink}`) as HTMLAnchorElement | null;
|
||||
const link = target.closest('.internal-link') as HTMLAnchorElement | null;
|
||||
if (!link) {
|
||||
return;
|
||||
return;
|
||||
}
|
||||
|
||||
const href = link.getAttribute('href');
|
||||
if (!href || !href.startsWith('#/page/')) {
|
||||
const notePath = link.getAttribute('data-href');
|
||||
if (!notePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
|
||||
const encodedPath = href.replace('#/page/', '');
|
||||
const notePath = decodeURIComponent(encodedPath);
|
||||
await workSpaceService.openNote(notePath);
|
||||
}
|
||||
|
||||
|
|
@ -107,9 +101,8 @@
|
|||
attachments = [];
|
||||
chatArea.resetAutoScroll();
|
||||
},
|
||||
onStreamingUpdate: (streamingId) => {
|
||||
onStreamingUpdate: () => {
|
||||
conversation = conversation;
|
||||
currentStreamingMessageId = streamingId;
|
||||
chatArea.updateChatAreaLayout("smooth");
|
||||
},
|
||||
onThoughtUpdate: (thought) => {
|
||||
|
|
@ -141,8 +134,7 @@
|
|||
},
|
||||
onUserQuestion: async (question) => {
|
||||
const displayEl = createEl("div");
|
||||
const formattedHtml = streamingMarkdownService.formatText(question);
|
||||
htmlService.setHTMLContent(displayEl, formattedHtml);
|
||||
await streamingMarkdownService.render(question, displayEl, true);
|
||||
chatInput.setDisplayItem(displayEl);
|
||||
return new Promise<string>((resolve) => {
|
||||
chatInput.enterQuestionMode(resolve);
|
||||
|
|
@ -176,7 +168,6 @@
|
|||
conversationService.resetCurrentConversation();
|
||||
|
||||
isSubmitting = false;
|
||||
currentStreamingMessageId = null;
|
||||
currentThought = null;
|
||||
|
||||
chatService.onNameChanged?.("");
|
||||
|
|
@ -187,7 +178,6 @@
|
|||
conversation.contents = [];
|
||||
|
||||
isSubmitting = false;
|
||||
currentStreamingMessageId = null;
|
||||
currentThought = null;
|
||||
|
||||
chatArea.resetChatArea();
|
||||
|
|
@ -210,8 +200,7 @@
|
|||
<ChatPlanArea executionPlanState={executionPlanStore.executionPlanState} {busyPlanning}/>
|
||||
|
||||
<div id="chat-container">
|
||||
<ChatArea messages={conversation.contents} bind:this={chatArea} bind:currentThought bind:isSubmitting bind:chatContainer
|
||||
currentStreamingMessageId={currentStreamingMessageId}/>
|
||||
<ChatArea messages={conversation.contents} bind:this={chatArea} bind:currentThought bind:isSubmitting bind:chatContainer/>
|
||||
</div>
|
||||
|
||||
<ChatInput
|
||||
|
|
|
|||
|
|
@ -41,18 +41,27 @@
|
|||
|
||||
let selectedTopic: number = initialTopic;
|
||||
let title: string = topics[selectedTopic].title;
|
||||
let content: string = streamingMarkdownService.formatText(topics[selectedTopic].content);
|
||||
let contentVisible: boolean = true;
|
||||
|
||||
function selectTopic(topicNumber: number) {
|
||||
title = "";
|
||||
content = "";
|
||||
contentVisible = false;
|
||||
selectedTopic = topicNumber;
|
||||
setTimeout(() => {
|
||||
title = topics[selectedTopic].title;
|
||||
content = streamingMarkdownService.formatText(topics[selectedTopic].content);
|
||||
contentVisible = true;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function helpContentAction(element: HTMLElement, topic: number) {
|
||||
streamingMarkdownService.render(topics[topic].content, element, true);
|
||||
return {
|
||||
update(newTopic: number) {
|
||||
streamingMarkdownService.render(topics[newTopic].content, element, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$: if (closeButton) {
|
||||
setIcon(closeButton, 'circle-x');
|
||||
}
|
||||
|
|
@ -60,22 +69,19 @@
|
|||
async function handleLinkClick(evt: MouseEvent) {
|
||||
const target = evt.target as HTMLElement;
|
||||
|
||||
// Check for both internal wikilinks and regular markdown links
|
||||
const link = target.closest('a') as HTMLAnchorElement | null;
|
||||
const link = target.closest('.internal-link') as HTMLAnchorElement | null;
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = link.getAttribute('href');
|
||||
if (!href || !href.startsWith('#/page/')) {
|
||||
const notePath = link.getAttribute('data-href');
|
||||
if (!notePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
|
||||
const encodedPath = href.replace('#/page/', '');
|
||||
const notePath = decodeURIComponent(encodedPath);
|
||||
await workSpaceService.openNote(notePath);
|
||||
onClose();
|
||||
}
|
||||
|
|
@ -176,9 +182,9 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="help-modal-content" bind:this={contentContainer}>
|
||||
{#if content !== ""}
|
||||
{#if contentVisible}
|
||||
<div transition:fade={{ duration: 100 }} use:helpContentAction={selectedTopic}></div>
|
||||
<div transition:fade={{ duration: 100 }}>
|
||||
{@html content}
|
||||
{#if selectedTopic === 1}
|
||||
<a
|
||||
href="{plugin.manifest.authorUrl}/vaultkeeper-ai"
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ export abstract class BaseAgent {
|
|||
this.debugService?.log("StreamError", `AI stream error for ${agentType}: ${chunk.errorType}`);
|
||||
conversationContent.content = chunk.error;
|
||||
conversationContent.errorType = chunk.errorType;
|
||||
callbacks.onStreamingUpdate(null);
|
||||
callbacks.onStreamingUpdate();
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -197,13 +197,13 @@ export abstract class BaseAgent {
|
|||
}
|
||||
|
||||
if (conversationContent.content?.trim() !== "") {
|
||||
callbacks.onStreamingUpdate(conversationContent.timestamp.getTime().toString());
|
||||
callbacks.onStreamingUpdate();
|
||||
} else {
|
||||
conversationContent.shouldDisplayContent = false;
|
||||
}
|
||||
}
|
||||
|
||||
callbacks.onStreamingUpdate(null);
|
||||
callbacks.onStreamingUpdate();
|
||||
|
||||
return { toolCall: capturedToolCall, shouldContinue: capturedShouldContinue };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import type { ChatMode } from "Enums/ChatMode";
|
|||
|
||||
export interface IChatServiceCallbacks {
|
||||
onSubmit: () => void;
|
||||
onStreamingUpdate: (streamingMessageId: string | null) => void;
|
||||
onStreamingUpdate: () => void;
|
||||
onThoughtUpdate: (thought: string | null) => void;
|
||||
onToolCallStarted: (toolName: string) => void;
|
||||
onPlanningStarted: () => void;
|
||||
|
|
@ -85,7 +85,7 @@ export class ChatService {
|
|||
conversation.contents.push(conversationContent);
|
||||
|
||||
await this.saveConversation(conversation);
|
||||
callbacks.onStreamingUpdate(conversationContent.timestamp.getTime().toString());
|
||||
callbacks.onStreamingUpdate();
|
||||
|
||||
let namingPromise: Promise<void> | undefined;
|
||||
if (firstMessage) {
|
||||
|
|
@ -107,7 +107,7 @@ export class ChatService {
|
|||
await this.saveConversation(conversation);
|
||||
|
||||
callbacks.onSubmit();
|
||||
callbacks.onStreamingUpdate(null);
|
||||
callbacks.onStreamingUpdate();
|
||||
|
||||
await this.mainAgent.runMainAgent(conversation, chatMode, callbacks);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,240 +1,103 @@
|
|||
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 { Component, MarkdownRenderer } from "obsidian";
|
||||
import type VaultkeeperAIPlugin from "main";
|
||||
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;
|
||||
interface RenderState {
|
||||
frozenContainer: HTMLElement;
|
||||
liveContainer: HTMLElement;
|
||||
frozenUpTo: number;
|
||||
frozenText: string;
|
||||
component: Component;
|
||||
}
|
||||
|
||||
export class StreamingMarkdownService {
|
||||
private readonly htmlService: HTMLService = Resolve<HTMLService>(Services.HTMLService);
|
||||
private readonly vaultCacheService: VaultCacheService = Resolve<VaultCacheService>(Services.VaultCacheService);
|
||||
private readonly plugin: VaultkeeperAIPlugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
|
||||
private readonly states = new WeakMap<HTMLElement, RenderState>();
|
||||
|
||||
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) {
|
||||
public async render(markdown: string, container: HTMLElement, isFinal: boolean = false): Promise<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
state.buffer = fullText;
|
||||
const state = this.getOrCreateState(container);
|
||||
const splitPoint = this.findSplitPoint(markdown);
|
||||
const frozenCandidate = markdown.slice(0, splitPoint);
|
||||
const liveTail = markdown.slice(splitPoint);
|
||||
|
||||
// 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) {
|
||||
window.clearTimeout(existingTimeout);
|
||||
if (frozenCandidate.length > state.frozenUpTo) {
|
||||
const newSlice = frozenCandidate.slice(state.frozenUpTo);
|
||||
const tempDiv = activeDocument.createElement("div");
|
||||
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;
|
||||
}
|
||||
|
||||
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 = window.setTimeout(render, 50); // 50ms debounce
|
||||
this.renderTimeouts.set(messageId, timeout);
|
||||
state.liveContainer.empty();
|
||||
if (liveTail.trim()) {
|
||||
await MarkdownRenderer.render(this.plugin.app, liveTail, state.liveContainer, "", state.component);
|
||||
}
|
||||
}
|
||||
|
||||
public finalizeStream(messageId: string, fullText: string) {
|
||||
const state = this.streamingStates.get(messageId);
|
||||
if (!state) {
|
||||
return;
|
||||
private getOrCreateState(container: HTMLElement): RenderState {
|
||||
if (this.states.has(container)) {
|
||||
return this.states.get(container)!;
|
||||
}
|
||||
|
||||
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) {
|
||||
window.clearTimeout(timeout);
|
||||
this.renderTimeouts.delete(messageId);
|
||||
}
|
||||
container.empty();
|
||||
const frozenContainer = activeDocument.createElement("div");
|
||||
const liveContainer = activeDocument.createElement("div");
|
||||
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;
|
||||
}
|
||||
|
||||
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]");
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
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>`);
|
||||
}
|
||||
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 (inList) html.push("</ul>");
|
||||
if (inCodeBlock) html.push("</code></pre>");
|
||||
|
||||
return html.join("");
|
||||
|
||||
if (fenceCount % 2 === 1) {
|
||||
// Odd number of fences — open code block, back up to before it
|
||||
return lastOpenFenceIndex > 0 ? lastOpenFenceIndex : 0;
|
||||
}
|
||||
|
||||
return lastDoubleNewline;
|
||||
}
|
||||
}
|
||||
|
|
@ -258,10 +258,6 @@ body {
|
|||
--table-border: var(--table-border-color);
|
||||
--table-header-background: var(--table-header-background);
|
||||
--hr-color: var(--hr-color);
|
||||
--checkbox-bg: var(--background-primary);
|
||||
--checkbox-border: var(--background-modifier-border);
|
||||
--checkbox-checked-bg: var(--interactive-accent);
|
||||
--footnote-border: var(--background-modifier-border);
|
||||
--alt-background-primary: color-mix(
|
||||
in hsl shorter hue,
|
||||
var(--background-primary) 75%,
|
||||
|
|
@ -278,703 +274,6 @@ body {
|
|||
var(--background-modifier-border-hover) 50%,
|
||||
var(--background-primary) 40%
|
||||
);
|
||||
counter-reset: katexEqnNo mmlEqnNo;
|
||||
}
|
||||
|
||||
/* ============================== */
|
||||
/* GFM Task Lists */
|
||||
/* ============================== */
|
||||
|
||||
/* Task list container */
|
||||
.contains-task-list,
|
||||
ul.contains-task-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* Task list items */
|
||||
.task-list-item,
|
||||
li.task-list-item {
|
||||
position: relative;
|
||||
list-style: none;
|
||||
padding-left: 1.75em;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Task list checkboxes */
|
||||
.task-list-item > input[type="checkbox"],
|
||||
.task-list-item input[type="checkbox"] {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.25em;
|
||||
margin: 0;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
cursor: pointer;
|
||||
background-color: var(--checkbox-bg);
|
||||
border: 1px solid var(--checkbox-border);
|
||||
border-radius: 3px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
}
|
||||
|
||||
/* Checked state */
|
||||
.task-list-item > input[type="checkbox"]:checked,
|
||||
.task-list-item input[type="checkbox"]:checked {
|
||||
background-color: var(--checkbox-checked-bg);
|
||||
border-color: var(--checkbox-checked-bg);
|
||||
}
|
||||
|
||||
/* Checkmark for checked boxes */
|
||||
.task-list-item > input[type="checkbox"]:checked::after,
|
||||
.task-list-item input[type="checkbox"]:checked::after {
|
||||
content: "✓";
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: var(--text-on-accent);
|
||||
font-size: 0.75em;
|
||||
line-height: 1em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Disabled state */
|
||||
.task-list-item > input[type="checkbox"]:disabled,
|
||||
.task-list-item input[type="checkbox"]:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Nested task lists */
|
||||
.task-list-item .contains-task-list {
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
/* ============================== */
|
||||
/* GFM Footnotes */
|
||||
/* ============================== */
|
||||
|
||||
/* Footnote references */
|
||||
sup[data-footnote-ref],
|
||||
a[data-footnote-ref] {
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
color: var(--link-color);
|
||||
}
|
||||
|
||||
sup[data-footnote-ref]::before {
|
||||
content: "[";
|
||||
}
|
||||
|
||||
sup[data-footnote-ref]::after {
|
||||
content: "]";
|
||||
}
|
||||
|
||||
/* Footnote section */
|
||||
section[data-footnotes],
|
||||
.footnotes {
|
||||
margin-top: 2em;
|
||||
padding-top: 1em;
|
||||
border-top: 1px solid var(--footnote-border);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Footnote list */
|
||||
section[data-footnotes] ol,
|
||||
.footnotes ol {
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
/* Footnote back references */
|
||||
a[data-footnote-backref] {
|
||||
text-decoration: none;
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
|
||||
/* ============================== */
|
||||
/* KaTeX Math Rendering Styles */
|
||||
/* ============================== */
|
||||
|
||||
.katex {
|
||||
font: normal 1.21em "KaTeX_Main", "Times New Roman", Times, serif;
|
||||
line-height: 1.2;
|
||||
text-rendering: auto;
|
||||
}
|
||||
|
||||
.katex * {
|
||||
-ms-high-contrast-adjust: none !important;
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
.katex .katex-mathml {
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
border: 0;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.katex .katex-html > .newline {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.katex .base {
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
width: -webkit-min-content;
|
||||
width: -moz-min-content;
|
||||
width: min-content;
|
||||
}
|
||||
|
||||
.katex .base,
|
||||
.katex .strut {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.katex .textbf {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.katex .textit {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.katex .textrm {
|
||||
font-family: KaTeX_Main;
|
||||
}
|
||||
|
||||
.katex .texttt {
|
||||
font-family: KaTeX_Typewriter;
|
||||
}
|
||||
|
||||
.katex .mathnormal {
|
||||
font-family: "KaTeX_Math", "Times New Roman", Times, serif;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.katex .mathit {
|
||||
font-family: "KaTeX_Main", "Times New Roman", Times, serif;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.katex .mathrm {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.katex .mathbf {
|
||||
font-family: "KaTeX_Main", "Times New Roman", Times, serif;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.katex .boldsymbol {
|
||||
font-family: KaTeX_Math;
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.katex .amsrm,
|
||||
.katex .mathbb,
|
||||
.katex .textbb {
|
||||
font-family: "KaTeX_AMS", "Times New Roman", Times, serif;
|
||||
}
|
||||
|
||||
.katex .mathcal {
|
||||
font-family: "KaTeX_Caligraphic", "Lucida Calligraphy", "Brush Script MT", cursive;
|
||||
}
|
||||
|
||||
.katex .mathfrak,
|
||||
.katex .textfrak {
|
||||
font-family: "KaTeX_Fraktur", "Lucida Blackletter", fantasy;
|
||||
}
|
||||
|
||||
.katex .mathboldfrak,
|
||||
.katex .textboldfrak {
|
||||
font-family: KaTeX_Fraktur;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.katex .mathtt {
|
||||
font-family: "KaTeX_Typewriter", "Courier New", Courier, monospace;
|
||||
}
|
||||
|
||||
.katex .mathscr,
|
||||
.katex .textscr {
|
||||
font-family: "KaTeX_Script", "Lucida Handwriting", "Segoe Script", cursive;
|
||||
}
|
||||
|
||||
.katex .mathsf,
|
||||
.katex .textsf {
|
||||
font-family: "KaTeX_SansSerif", Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
.katex .mathboldsf,
|
||||
.katex .textboldsf {
|
||||
font-family: KaTeX_SansSerif;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.katex .mathitsf,
|
||||
.katex .mathsfit,
|
||||
.katex .textitsf {
|
||||
font-family: KaTeX_SansSerif;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.katex .mainrm {
|
||||
font-family: KaTeX_Main;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* KaTeX Layout */
|
||||
.katex .vlist-t {
|
||||
border-collapse: collapse;
|
||||
display: inline-table;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.katex .vlist-r {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
.katex .vlist {
|
||||
display: table-cell;
|
||||
position: relative;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.katex .vlist > span {
|
||||
display: block;
|
||||
height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.katex .vlist > span > span {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.katex .vlist > span > .pstrut {
|
||||
overflow: hidden;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.katex .vlist-t2 {
|
||||
margin-right: -2px;
|
||||
}
|
||||
|
||||
.katex .vlist-s {
|
||||
display: table-cell;
|
||||
font-size: 1px;
|
||||
min-width: 2px;
|
||||
vertical-align: bottom;
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
.katex .vbox {
|
||||
align-items: baseline;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.katex .hbox {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.katex .thinbox {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
max-width: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
/* KaTeX Fractions and Lines */
|
||||
.katex .msupsub {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.katex .mfrac > span > span {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.katex .mfrac .frac-line {
|
||||
border-bottom-style: solid;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.katex .hdashline,
|
||||
.katex .hline,
|
||||
.katex .mfrac .frac-line,
|
||||
.katex .overline .overline-line,
|
||||
.katex .rule,
|
||||
.katex .underline .underline-line {
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.katex .mspace {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* KaTeX Positioning */
|
||||
.katex .clap,
|
||||
.katex .llap,
|
||||
.katex .rlap {
|
||||
position: relative;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.katex .clap > .inner,
|
||||
.katex .llap > .inner,
|
||||
.katex .rlap > .inner {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.katex .clap > .fix,
|
||||
.katex .llap > .fix,
|
||||
.katex .rlap > .fix {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.katex .llap > .inner {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.katex .clap > .inner,
|
||||
.katex .rlap > .inner {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.katex .clap > .inner > span {
|
||||
margin-left: -50%;
|
||||
margin-right: 50%;
|
||||
}
|
||||
|
||||
/* KaTeX Rules and Lines */
|
||||
.katex .rule {
|
||||
border: 0 solid;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.katex .hline,
|
||||
.katex .overline .overline-line,
|
||||
.katex .underline .underline-line {
|
||||
border-bottom-style: solid;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.katex .hdashline {
|
||||
border-bottom-style: dashed;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* KaTeX Square Root */
|
||||
.katex .sqrt > .root {
|
||||
margin-left: 0.27777778em;
|
||||
margin-right: -0.55555556em;
|
||||
}
|
||||
|
||||
/* KaTeX Sizing - Essential subset */
|
||||
.katex .fontsize-ensurer.reset-size1.size1,
|
||||
.katex .sizing.reset-size1.size1 {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.katex .fontsize-ensurer.reset-size1.size2,
|
||||
.katex .sizing.reset-size1.size2 {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.katex .fontsize-ensurer.reset-size1.size3,
|
||||
.katex .sizing.reset-size1.size3 {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
.katex .fontsize-ensurer.reset-size1.size4,
|
||||
.katex .sizing.reset-size1.size4 {
|
||||
font-size: 1.6em;
|
||||
}
|
||||
|
||||
.katex .fontsize-ensurer.reset-size1.size5,
|
||||
.katex .sizing.reset-size1.size5 {
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
.katex .fontsize-ensurer.reset-size1.size6,
|
||||
.katex .sizing.reset-size1.size6 {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.katex .fontsize-ensurer.reset-size1.size7,
|
||||
.katex .sizing.reset-size1.size7 {
|
||||
font-size: 2.4em;
|
||||
}
|
||||
|
||||
/* KaTeX Delimiters */
|
||||
.katex .delimsizing.size1 {
|
||||
font-family: KaTeX_Size1;
|
||||
}
|
||||
|
||||
.katex .delimsizing.size2 {
|
||||
font-family: KaTeX_Size2;
|
||||
}
|
||||
|
||||
.katex .delimsizing.size3 {
|
||||
font-family: KaTeX_Size3;
|
||||
}
|
||||
|
||||
.katex .delimsizing.size4 {
|
||||
font-family: KaTeX_Size4;
|
||||
}
|
||||
|
||||
.katex .nulldelimiter {
|
||||
display: inline-block;
|
||||
width: 0.12em;
|
||||
}
|
||||
|
||||
.katex .delimcenter,
|
||||
.katex .op-symbol {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.katex .op-symbol.small-op {
|
||||
font-family: KaTeX_Size1;
|
||||
}
|
||||
|
||||
.katex .op-symbol.large-op {
|
||||
font-family: KaTeX_Size2;
|
||||
}
|
||||
|
||||
/* KaTeX Accents and Symbols */
|
||||
.katex .accent > .vlist-t,
|
||||
.katex .op-limits > .vlist-t {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.katex .accent .accent-body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.katex .accent .accent-body:not(.accent-full) {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.katex .overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* KaTeX Tables */
|
||||
.katex .mtable .vertical-separator {
|
||||
display: inline-block;
|
||||
min-width: 1px;
|
||||
}
|
||||
|
||||
.katex .mtable .arraycolsep {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.katex .mtable .col-align-c > .vlist-t {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.katex .mtable .col-align-l > .vlist-t {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.katex .mtable .col-align-r > .vlist-t {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* KaTeX SVG Support */
|
||||
.katex .svg-align {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.katex svg {
|
||||
fill: currentColor;
|
||||
stroke: currentColor;
|
||||
fill-rule: nonzero;
|
||||
fill-opacity: 1;
|
||||
stroke-width: 1;
|
||||
stroke-linecap: butt;
|
||||
stroke-linejoin: miter;
|
||||
stroke-miterlimit: 4;
|
||||
stroke-dasharray: none;
|
||||
stroke-dashoffset: 0;
|
||||
stroke-opacity: 1;
|
||||
display: block;
|
||||
height: inherit;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.katex svg path {
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.katex img {
|
||||
border-style: none;
|
||||
max-height: none;
|
||||
max-width: none;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* KaTeX Stretchy Elements */
|
||||
.katex .stretchy {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.katex .stretchy::after,
|
||||
.katex .stretchy::before {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.katex .hide-tail {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* KaTeX Arrows and Braces */
|
||||
.katex .halfarrow-left {
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
width: 50.2%;
|
||||
}
|
||||
|
||||
.katex .halfarrow-right {
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: 50.2%;
|
||||
}
|
||||
|
||||
.katex .brace-left {
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
width: 25.1%;
|
||||
}
|
||||
|
||||
.katex .brace-center {
|
||||
left: 25%;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.katex .brace-right {
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: 25.1%;
|
||||
}
|
||||
|
||||
/* KaTeX Padding and Boxes */
|
||||
.katex .x-arrow-pad {
|
||||
padding: 0 0.5em;
|
||||
}
|
||||
|
||||
.katex .cd-arrow-pad {
|
||||
padding: 0 0.55556em 0 0.27778em;
|
||||
}
|
||||
|
||||
.katex .mover,
|
||||
.katex .munder,
|
||||
.katex .x-arrow {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.katex .boxpad {
|
||||
padding: 0 0.3em;
|
||||
}
|
||||
|
||||
.katex .fbox,
|
||||
.katex .fcolorbox {
|
||||
border: 0.04em solid;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* KaTeX Cancel */
|
||||
.katex .cancel-pad {
|
||||
padding: 0 0.2em;
|
||||
}
|
||||
|
||||
.katex .cancel-lap {
|
||||
margin-left: -0.2em;
|
||||
margin-right: -0.2em;
|
||||
}
|
||||
|
||||
.katex .sout {
|
||||
border-bottom-style: solid;
|
||||
border-bottom-width: 0.08em;
|
||||
}
|
||||
|
||||
/* KaTeX Angles */
|
||||
.katex .angl {
|
||||
border-right: 0.049em solid;
|
||||
border-top: 0.049em solid;
|
||||
box-sizing: border-box;
|
||||
margin-right: 0.03889em;
|
||||
}
|
||||
|
||||
.katex .anglpad {
|
||||
padding: 0 0.03889em;
|
||||
}
|
||||
|
||||
/* KaTeX Equation Numbers */
|
||||
.katex .eqn-num::before {
|
||||
content: "(" counter(katexEqnNo) ")";
|
||||
counter-increment: katexEqnNo;
|
||||
}
|
||||
|
||||
.katex .mml-eqn-num::before {
|
||||
content: "(" counter(mmlEqnNo) ")";
|
||||
counter-increment: mmlEqnNo;
|
||||
}
|
||||
|
||||
/* KaTeX Display Mode */
|
||||
.katex-display {
|
||||
display: block;
|
||||
margin: 1em 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.katex-display > .katex {
|
||||
display: block;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.katex-display > .katex > .katex-html {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.katex-display > .katex > .katex-html > .tag {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.katex-display.leqno > .katex > .katex-html > .tag {
|
||||
left: 0;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.katex-display.fleqn > .katex {
|
||||
padding-left: 2em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* ============================== */
|
||||
|
|
@ -1260,37 +559,6 @@ a[data-footnote-backref] {
|
|||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
/* ============================== */
|
||||
/* Emoji Support */
|
||||
/* ============================== */
|
||||
|
||||
.message-bubble.assistant .emoji {
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
font-weight: normal;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ============================== */
|
||||
/* Line Numbers for Code Blocks (optional) */
|
||||
/* ============================== */
|
||||
|
||||
/* If using line numbers with rehype-highlight */
|
||||
.hljs .code-line {
|
||||
display: block;
|
||||
padding-left: 1em;
|
||||
margin-left: -1em;
|
||||
}
|
||||
|
||||
.hljs .numbered-code-line::before {
|
||||
content: attr(data-line-number);
|
||||
display: inline-block;
|
||||
width: 2em;
|
||||
text-align: right;
|
||||
margin-right: 1em;
|
||||
color: var(--text-faint);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ============================== */
|
||||
/* Responsive adjustments */
|
||||
/* ============================== */
|
||||
|
|
@ -1330,9 +598,4 @@ a[data-footnote-backref] {
|
|||
background-color: #f6f8fa;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.hljs {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,570 +2,152 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|||
import { StreamingMarkdownService } from '../../Services/StreamingMarkdownService';
|
||||
import * as DependencyService from '../../Services/DependencyService';
|
||||
import { Services } from '../../Services/Services';
|
||||
import type { HTMLService } from '../../Services/HTMLService';
|
||||
import type { VaultCacheService } from '../../Services/VaultCacheService';
|
||||
import { Exception } from '../../Helpers/Exception';
|
||||
|
||||
const mockPlugin = { app: {} };
|
||||
|
||||
vi.mock('obsidian', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('obsidian')>();
|
||||
return {
|
||||
...original,
|
||||
MarkdownRenderer: {
|
||||
render: vi.fn(async (_app: unknown, markdown: string, container: HTMLElement) => {
|
||||
const blocks = markdown.split(/\n{2,}/).filter(b => b.length > 0);
|
||||
container.innerHTML = blocks.map(block => {
|
||||
if (block.startsWith('# ')) return `<h1>${block.slice(2)}</h1>`;
|
||||
if (block.startsWith('```')) {
|
||||
const lines = block.split('\n');
|
||||
const code = lines.slice(1, lines[lines.length - 1] === '```' ? -1 : undefined).join('\n');
|
||||
return `<pre><code>${code}</code></pre>`;
|
||||
}
|
||||
return `<p>${block}</p>`;
|
||||
}).join('');
|
||||
})
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
describe('StreamingMarkdownService', () => {
|
||||
let service: StreamingMarkdownService;
|
||||
let mockHTMLService: Partial<HTMLService>;
|
||||
let mockVaultCacheService: Partial<VaultCacheService>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock VaultCacheService to provide wikiLinks
|
||||
mockVaultCacheService = {
|
||||
wikiLinks: {
|
||||
links: ['file1', 'file2', 'folder/file3']
|
||||
}
|
||||
} as any;
|
||||
|
||||
// Mock HTMLService
|
||||
mockHTMLService = {
|
||||
clearElement: vi.fn((element: HTMLElement) => {
|
||||
while (element.firstChild) {
|
||||
element.removeChild(element.firstChild);
|
||||
}
|
||||
}),
|
||||
setHTMLContent: vi.fn((container: HTMLElement, htmlString: string) => {
|
||||
// Clear the container
|
||||
while (container.firstChild) {
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
// Parse and append HTML
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlString, 'text/html');
|
||||
while (doc.body.firstChild) {
|
||||
container.appendChild(doc.body.firstChild);
|
||||
}
|
||||
}),
|
||||
parseHTMLString: vi.fn(),
|
||||
parseHTMLToContainer: vi.fn()
|
||||
};
|
||||
|
||||
// Mock DependencyService.Resolve to return our mocks
|
||||
vi.spyOn(DependencyService, 'Resolve').mockImplementation((serviceId: symbol) => {
|
||||
if (serviceId === Services.VaultCacheService) {
|
||||
return mockVaultCacheService as VaultCacheService;
|
||||
}
|
||||
if (serviceId === Services.HTMLService) {
|
||||
return mockHTMLService as HTMLService;
|
||||
}
|
||||
throw new Error(`Unexpected service request: ${serviceId.toString()}`);
|
||||
});
|
||||
|
||||
// Mock Exception.log
|
||||
vi.spyOn(Exception, 'log').mockImplementation(() => {});
|
||||
|
||||
service = new StreamingMarkdownService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('formatText', () => {
|
||||
it('should convert basic markdown to HTML', () => {
|
||||
const markdown = '# Hello World\n\nThis is a test.';
|
||||
const result = service.formatText(markdown);
|
||||
|
||||
expect(result).toContain('<h1>');
|
||||
expect(result).toContain('Hello World');
|
||||
expect(result).toContain('<p>');
|
||||
expect(result).toContain('This is a test.');
|
||||
});
|
||||
|
||||
it('should handle bold text', () => {
|
||||
const markdown = '**bold text**';
|
||||
const result = service.formatText(markdown);
|
||||
|
||||
expect(result).toContain('<strong>');
|
||||
expect(result).toContain('bold text');
|
||||
});
|
||||
|
||||
it('should handle italic text', () => {
|
||||
const markdown = '*italic text*';
|
||||
const result = service.formatText(markdown);
|
||||
|
||||
expect(result).toContain('<em>');
|
||||
expect(result).toContain('italic text');
|
||||
});
|
||||
|
||||
it('should handle code blocks', () => {
|
||||
const markdown = '```javascript\nconst x = 1;\n```';
|
||||
const result = service.formatText(markdown);
|
||||
|
||||
expect(result).toContain('<code');
|
||||
// rehype-highlight adds span tags for syntax highlighting
|
||||
expect(result).toContain('const');
|
||||
expect(result).toContain('x');
|
||||
});
|
||||
|
||||
it('should handle inline code', () => {
|
||||
const markdown = 'Use `console.log()` to debug.';
|
||||
const result = service.formatText(markdown);
|
||||
|
||||
expect(result).toContain('<code>');
|
||||
expect(result).toContain('console.log()');
|
||||
});
|
||||
|
||||
it('should handle lists', () => {
|
||||
const markdown = '- Item 1\n- Item 2\n- Item 3';
|
||||
const result = service.formatText(markdown);
|
||||
|
||||
expect(result).toContain('<ul>');
|
||||
expect(result).toContain('<li>');
|
||||
expect(result).toContain('Item 1');
|
||||
expect(result).toContain('Item 2');
|
||||
});
|
||||
|
||||
it('should handle numbered lists', () => {
|
||||
const markdown = '1. First\n2. Second\n3. Third';
|
||||
const result = service.formatText(markdown);
|
||||
|
||||
expect(result).toContain('<ol>');
|
||||
expect(result).toContain('<li>');
|
||||
expect(result).toContain('First');
|
||||
});
|
||||
|
||||
it('should handle links', () => {
|
||||
const markdown = '[Google](https://google.com)';
|
||||
const result = service.formatText(markdown);
|
||||
|
||||
expect(result).toContain('<a');
|
||||
expect(result).toContain('href="https://google.com"');
|
||||
expect(result).toContain('Google');
|
||||
});
|
||||
|
||||
it('should handle LaTeX math with double dollar signs', () => {
|
||||
const markdown = '$$E = mc^2$$';
|
||||
const result = service.formatText(markdown);
|
||||
|
||||
// rehype-katex converts LaTeX to HTML with math markup
|
||||
expect(result).toContain('E');
|
||||
expect(result).toContain('mc');
|
||||
});
|
||||
|
||||
it('should handle inline LaTeX with parentheses notation', () => {
|
||||
const markdown = 'The formula \\(x^2 + y^2 = z^2\\) is correct.';
|
||||
const result = service.formatText(markdown);
|
||||
|
||||
expect(result).toContain('formula');
|
||||
expect(result).toContain('correct');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const result = service.formatText('');
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
|
||||
it('should handle plain text without markdown', () => {
|
||||
const text = 'Just plain text';
|
||||
const result = service.formatText(text);
|
||||
|
||||
expect(result).toContain('Just plain text');
|
||||
expect(result).toContain('<p>');
|
||||
});
|
||||
|
||||
it('should return fallback HTML on processing error', () => {
|
||||
// Mock processor to throw error
|
||||
const originalProcessor = (service as any).processor;
|
||||
(service as any).processor = {
|
||||
processSync: vi.fn().mockImplementation(() => {
|
||||
throw new Error('Processing failed');
|
||||
})
|
||||
};
|
||||
|
||||
const result = service.formatText('test');
|
||||
|
||||
// Should use fallback HTML generation
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toContain('test');
|
||||
|
||||
// Restore processor
|
||||
(service as any).processor = originalProcessor;
|
||||
});
|
||||
|
||||
it('should handle special HTML characters in fallback', () => {
|
||||
// Force fallback by making processor fail
|
||||
const originalProcessor = (service as any).processor;
|
||||
(service as any).processor = {
|
||||
processSync: vi.fn().mockImplementation(() => {
|
||||
throw new Error('Processing failed');
|
||||
})
|
||||
};
|
||||
|
||||
const result = service.formatText('<script>alert("xss")</script>');
|
||||
|
||||
// Should escape HTML in fallback
|
||||
expect(result).not.toContain('<script>');
|
||||
expect(result).toContain('<');
|
||||
expect(result).toContain('>');
|
||||
|
||||
// Restore processor
|
||||
(service as any).processor = originalProcessor;
|
||||
});
|
||||
});
|
||||
|
||||
describe('preprocessContent', () => {
|
||||
it('should normalize line endings', () => {
|
||||
const content = 'Line 1\r\nLine 2\rLine 3\n';
|
||||
const result = (service as any).preprocessContent(content);
|
||||
|
||||
// All line endings should be normalized to \n
|
||||
expect(result).not.toContain('\r');
|
||||
expect(result).toContain('Line 1\nLine 2\nLine 3');
|
||||
});
|
||||
|
||||
it('should convert LaTeX bracket notation to dollar signs', () => {
|
||||
const content = 'Formula: \\[x^2 + y^2 = z^2\\]';
|
||||
const result = (service as any).preprocessContent(content);
|
||||
|
||||
expect(result).toContain('$$');
|
||||
expect(result).toContain('x^2 + y^2 = z^2');
|
||||
});
|
||||
|
||||
it('should convert LaTeX parentheses to inline math', () => {
|
||||
const content = 'Inline \\(a + b = c\\) formula';
|
||||
const result = (service as any).preprocessContent(content);
|
||||
|
||||
// Converts \(...\) to $...$ (single dollar, not double)
|
||||
expect(result).toContain('$a + b = c$');
|
||||
});
|
||||
|
||||
it('should add blank lines before headers', () => {
|
||||
const content = 'Paragraph\n# Header';
|
||||
const result = (service as any).preprocessContent(content);
|
||||
|
||||
expect(result).toContain('Paragraph\n\n# Header');
|
||||
});
|
||||
|
||||
it('should not add blank lines before headers at start', () => {
|
||||
const content = '# Header at start';
|
||||
const result = (service as any).preprocessContent(content);
|
||||
|
||||
// Should not have extra blank line at the start
|
||||
expect(result.startsWith('# Header')).toBe(true);
|
||||
});
|
||||
|
||||
it('should collapse excessive newlines', () => {
|
||||
const content = 'Line 1\n\n\n\n\nLine 2';
|
||||
const result = (service as any).preprocessContent(content);
|
||||
|
||||
// Should reduce to max 3 newlines
|
||||
expect(result).not.toContain('\n\n\n\n');
|
||||
expect(result).toContain('Line 1');
|
||||
expect(result).toContain('Line 2');
|
||||
});
|
||||
|
||||
it('should normalize list formatting', () => {
|
||||
const content = '- Item with extra spaces\n* Another item';
|
||||
const result = (service as any).preprocessContent(content);
|
||||
|
||||
// Should normalize to single space after bullet
|
||||
expect(result).toContain('- Item with extra spaces');
|
||||
expect(result).toContain('* Another item');
|
||||
});
|
||||
|
||||
it('should format task list checkboxes', () => {
|
||||
const content = '- [x] Done\n- [ ]Not done';
|
||||
const result = (service as any).preprocessContent(content);
|
||||
|
||||
// Should normalize task list format
|
||||
expect(result).toContain('- [x]');
|
||||
expect(result).toContain('- [ ]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFallbackHTML', () => {
|
||||
it('should escape HTML entities', () => {
|
||||
const text = '<div>Test & "quotes"</div>';
|
||||
const result = (service as any).getFallbackHTML(text);
|
||||
|
||||
expect(result).toContain('<div>');
|
||||
expect(result).toContain('&');
|
||||
expect(result).toContain('>');
|
||||
});
|
||||
|
||||
it('should convert code blocks', () => {
|
||||
const text = '```\ncode here\n```';
|
||||
const result = (service as any).getFallbackHTML(text);
|
||||
|
||||
expect(result).toContain('<pre>');
|
||||
expect(result).toContain('<code>');
|
||||
expect(result).toContain('code here');
|
||||
});
|
||||
|
||||
it('should handle unordered lists', () => {
|
||||
const text = '* Item 1\n* Item 2';
|
||||
const result = (service as any).getFallbackHTML(text);
|
||||
|
||||
expect(result).toContain('<ul>');
|
||||
expect(result).toContain('<li>');
|
||||
expect(result).toContain('Item 1');
|
||||
});
|
||||
|
||||
it('should close unclosed code blocks', () => {
|
||||
const text = '```\ncode\nmore code';
|
||||
const result = (service as any).getFallbackHTML(text);
|
||||
|
||||
expect(result).toContain('</code></pre>');
|
||||
});
|
||||
|
||||
it('should close unclosed lists', () => {
|
||||
const text = '* Item 1\n* Item 2';
|
||||
const result = (service as any).getFallbackHTML(text);
|
||||
|
||||
expect(result).toContain('</ul>');
|
||||
});
|
||||
|
||||
it('should convert bold markdown', () => {
|
||||
const text = '**bold text**';
|
||||
const result = (service as any).getFallbackHTML(text);
|
||||
|
||||
expect(result).toContain('<strong>');
|
||||
expect(result).toContain('bold text');
|
||||
});
|
||||
|
||||
it('should convert italic markdown', () => {
|
||||
const text = '*italic text*';
|
||||
const result = (service as any).getFallbackHTML(text);
|
||||
|
||||
expect(result).toContain('<em>');
|
||||
expect(result).toContain('italic text');
|
||||
});
|
||||
|
||||
it('should convert inline code', () => {
|
||||
const text = 'Use `code` here';
|
||||
const result = (service as any).getFallbackHTML(text);
|
||||
|
||||
expect(result).toContain('<code>');
|
||||
expect(result).toContain('code');
|
||||
});
|
||||
|
||||
it('should wrap text in paragraphs', () => {
|
||||
const text = 'Line 1\nLine 2';
|
||||
const result = (service as any).getFallbackHTML(text);
|
||||
|
||||
expect(result).toContain('<p>');
|
||||
});
|
||||
|
||||
it('should not wrap empty lines in paragraphs', () => {
|
||||
const text = 'Line 1\n\nLine 2';
|
||||
const result = (service as any).getFallbackHTML(text);
|
||||
|
||||
const paragraphCount = (result.match(/<p>/g) || []).length;
|
||||
expect(paragraphCount).toBe(2); // One for each non-empty line
|
||||
});
|
||||
});
|
||||
|
||||
describe('streaming - initializeStream', () => {
|
||||
it('should create streaming state for message', () => {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = '<p>Old content</p>';
|
||||
|
||||
service.initializeStream('msg-1', container);
|
||||
|
||||
expect(container.innerHTML).toBe('');
|
||||
|
||||
// Verify state exists internally
|
||||
const state = (service as any).streamingStates.get('msg-1');
|
||||
expect(state).toBeDefined();
|
||||
expect(state.buffer).toBe('');
|
||||
expect(state.isComplete).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear existing content in container', () => {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = '<p>Existing content</p>';
|
||||
|
||||
service.initializeStream('msg-1', container);
|
||||
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('should handle multiple stream initializations', () => {
|
||||
const container1 = document.createElement('div');
|
||||
const container2 = document.createElement('div');
|
||||
|
||||
service.initializeStream('msg-1', container1);
|
||||
service.initializeStream('msg-2', container2);
|
||||
|
||||
const states = (service as any).streamingStates;
|
||||
expect(states.get('msg-1')).toBeDefined();
|
||||
expect(states.get('msg-2')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('streaming - streamChunk', () => {
|
||||
it('should update buffer with new content', () => {
|
||||
const container = document.createElement('div');
|
||||
service.initializeStream('msg-1', container);
|
||||
|
||||
service.streamChunk('msg-1', '# Title');
|
||||
|
||||
const state = (service as any).streamingStates.get('msg-1');
|
||||
expect(state.buffer).toBe('# Title');
|
||||
});
|
||||
|
||||
it('should render content after debounce', async () => {
|
||||
const container = document.createElement('div');
|
||||
service.initializeStream('msg-1', container);
|
||||
|
||||
service.streamChunk('msg-1', '**bold**');
|
||||
|
||||
// Wait for debounce (50ms + buffer)
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
expect(container.innerHTML).toContain('bold');
|
||||
});
|
||||
|
||||
it('should handle multiple chunks for same message', () => {
|
||||
const container = document.createElement('div');
|
||||
service.initializeStream('msg-1', container);
|
||||
|
||||
service.streamChunk('msg-1', 'Part 1');
|
||||
service.streamChunk('msg-1', 'Part 1\nPart 2');
|
||||
service.streamChunk('msg-1', 'Part 1\nPart 2\nPart 3');
|
||||
|
||||
const state = (service as any).streamingStates.get('msg-1');
|
||||
expect(state.buffer).toBe('Part 1\nPart 2\nPart 3');
|
||||
});
|
||||
|
||||
it('should do nothing if message not initialized', () => {
|
||||
expect(() => {
|
||||
service.streamChunk('non-existent', 'test');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should do nothing if stream already complete', () => {
|
||||
const container = document.createElement('div');
|
||||
service.initializeStream('msg-1', container);
|
||||
|
||||
const state = (service as any).streamingStates.get('msg-1');
|
||||
state.isComplete = true;
|
||||
|
||||
service.streamChunk('msg-1', 'new content');
|
||||
|
||||
// Buffer should not update
|
||||
expect(state.buffer).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('streaming - finalizeStream', () => {
|
||||
it('should render final content immediately', () => {
|
||||
const container = document.createElement('div');
|
||||
service.initializeStream('msg-1', container);
|
||||
|
||||
service.finalizeStream('msg-1', '# Final Content');
|
||||
|
||||
expect(container.innerHTML).toContain('Final Content');
|
||||
});
|
||||
|
||||
it('should mark stream as complete', () => {
|
||||
const container = document.createElement('div');
|
||||
service.initializeStream('msg-1', container);
|
||||
|
||||
service.finalizeStream('msg-1', 'Done');
|
||||
|
||||
const state = (service as any).streamingStates.get('msg-1');
|
||||
// State should be deleted after finalization
|
||||
expect(state).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should cleanup streaming state', () => {
|
||||
const container = document.createElement('div');
|
||||
service.initializeStream('msg-1', container);
|
||||
|
||||
service.finalizeStream('msg-1', 'Done');
|
||||
|
||||
const states = (service as any).streamingStates;
|
||||
expect(states.has('msg-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should cleanup render timeout', () => {
|
||||
const container = document.createElement('div');
|
||||
service.initializeStream('msg-1', container);
|
||||
|
||||
service.streamChunk('msg-1', 'test');
|
||||
service.finalizeStream('msg-1', 'Done');
|
||||
|
||||
const timeouts = (service as any).renderTimeouts;
|
||||
expect(timeouts.has('msg-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should do nothing if message not initialized', () => {
|
||||
expect(() => {
|
||||
service.finalizeStream('non-existent', 'test');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle finalization with empty content', () => {
|
||||
const container = document.createElement('div');
|
||||
service.initializeStream('msg-1', container);
|
||||
|
||||
service.finalizeStream('msg-1', '');
|
||||
|
||||
expect(container.innerHTML).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('streaming - debounce behavior', () => {
|
||||
it('should debounce rapid updates', async () => {
|
||||
const container = document.createElement('div');
|
||||
service.initializeStream('msg-1', container);
|
||||
|
||||
// Send multiple rapid updates
|
||||
service.streamChunk('msg-1', 'v1');
|
||||
service.streamChunk('msg-1', 'v2');
|
||||
service.streamChunk('msg-1', 'v3');
|
||||
|
||||
// Wait for debounce
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Should render final version
|
||||
expect(container.innerHTML).toContain('v3');
|
||||
});
|
||||
|
||||
it('should clear pending timeout on new chunk', () => {
|
||||
const container = document.createElement('div');
|
||||
service.initializeStream('msg-1', container);
|
||||
|
||||
service.streamChunk('msg-1', 'first');
|
||||
const timeouts = (service as any).renderTimeouts;
|
||||
const firstTimeout = timeouts.get('msg-1');
|
||||
|
||||
service.streamChunk('msg-1', 'second');
|
||||
const secondTimeout = timeouts.get('msg-1');
|
||||
|
||||
// Should have different timeout IDs
|
||||
expect(firstTimeout).not.toBe(secondTimeout);
|
||||
});
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize unified processor', () => {
|
||||
const testService = new StreamingMarkdownService();
|
||||
|
||||
expect((testService as any).processor).toBeDefined();
|
||||
expect((testService as any).processor).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should use VaultCacheService wikiLinks for permalink resolution', () => {
|
||||
const testService = new StreamingMarkdownService();
|
||||
|
||||
// Verify the service was initialized with VaultCacheService
|
||||
expect((testService as any).vaultCacheService).toBeDefined();
|
||||
expect((testService as any).vaultCacheService.wikiLinks).toBeDefined();
|
||||
expect((testService as any).vaultCacheService.wikiLinks.links).toEqual(['file1', 'file2', 'folder/file3']);
|
||||
});
|
||||
|
||||
it('should initialize empty streaming states', () => {
|
||||
const testService = new StreamingMarkdownService();
|
||||
|
||||
const states = (testService as any).streamingStates;
|
||||
expect(states).toBeInstanceOf(Map);
|
||||
expect(states.size).toBe(0);
|
||||
});
|
||||
});
|
||||
let service: StreamingMarkdownService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(DependencyService, 'Resolve').mockImplementation((serviceId: symbol) => {
|
||||
if (serviceId === Services.VaultkeeperAIPlugin) return mockPlugin as any;
|
||||
throw new Error(`Unexpected service request: ${serviceId.toString()}`);
|
||||
});
|
||||
service = new StreamingMarkdownService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// --- Existing contract (isFinal=true behaviour matches old render) ---
|
||||
|
||||
it('clears container and renders markdown when final', async () => {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = '<span>old</span>';
|
||||
await service.render('hello world', container, true);
|
||||
expect(container.innerHTML).not.toContain('old');
|
||||
expect(container.innerHTML).toContain('hello world');
|
||||
});
|
||||
|
||||
it('replaces previous content on each final call', async () => {
|
||||
const container = document.createElement('div');
|
||||
await service.render('first', container, true);
|
||||
await service.render('first\n\nsecond', container, true);
|
||||
const paragraphs = container.querySelectorAll('p');
|
||||
expect(paragraphs).toHaveLength(2);
|
||||
expect(paragraphs[0].textContent).toBe('first');
|
||||
expect(paragraphs[1].textContent).toBe('second');
|
||||
});
|
||||
|
||||
// --- Incremental rendering ---
|
||||
|
||||
it('renders single incomplete block in live tail only', async () => {
|
||||
const container = document.createElement('div');
|
||||
// No \n\n yet — everything is live tail
|
||||
await service.render('Hello world', container);
|
||||
expect(container.textContent).toContain('Hello world');
|
||||
});
|
||||
|
||||
it('freezes completed blocks and only re-renders live tail', async () => {
|
||||
const { MarkdownRenderer } = await import('obsidian');
|
||||
const renderSpy = vi.mocked(MarkdownRenderer.render);
|
||||
|
||||
const container = document.createElement('div');
|
||||
|
||||
// First chunk: one complete paragraph + live tail
|
||||
await service.render('First paragraph\n\nLive tail', container);
|
||||
const callsAfterFirst = renderSpy.mock.calls.length;
|
||||
|
||||
// Second chunk: same frozen block, live tail grows
|
||||
await service.render('First paragraph\n\nLive tail continues', container);
|
||||
const callsAfterSecond = renderSpy.mock.calls.length;
|
||||
|
||||
// The frozen block should NOT have been re-rendered (only live tail re-renders)
|
||||
// So calls added == 1 (just the live tail), not 2
|
||||
expect(callsAfterSecond - callsAfterFirst).toBe(1);
|
||||
expect(container.textContent).toContain('First paragraph');
|
||||
expect(container.textContent).toContain('Live tail continues');
|
||||
});
|
||||
|
||||
it('does not split an open code fence into frozen content', async () => {
|
||||
const container = document.createElement('div');
|
||||
|
||||
// The \n\n is inside an open code block — should NOT freeze anything
|
||||
await service.render('```js\nconst x = 1;\n\nconst y = 2;', container);
|
||||
|
||||
// Split point should be 0 (nothing frozen), all in live tail
|
||||
const children = Array.from(container.children);
|
||||
const frozenDiv = children[0] as HTMLElement;
|
||||
expect(frozenDiv.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('freezes content before a code block and keeps open fence in live tail', async () => {
|
||||
const container = document.createElement('div');
|
||||
const text = 'Intro paragraph\n\n```js\nconst x = 1;';
|
||||
await service.render(text, container);
|
||||
|
||||
// "Intro paragraph" should be frozen, code block stays in live tail
|
||||
const children = Array.from(container.children);
|
||||
const frozenDiv = children[0] as HTMLElement;
|
||||
const liveDiv = children[1] as HTMLElement;
|
||||
expect(frozenDiv.textContent).toContain('Intro paragraph');
|
||||
expect(liveDiv.textContent).toContain('const x = 1;');
|
||||
});
|
||||
|
||||
it('freezes a complete code block once closing fence arrives', async () => {
|
||||
const { MarkdownRenderer } = await import('obsidian');
|
||||
const renderSpy = vi.mocked(MarkdownRenderer.render);
|
||||
|
||||
const container = document.createElement('div');
|
||||
|
||||
// Open fence — nothing frozen yet
|
||||
await service.render('Before\n\n```js\ncode here', container);
|
||||
const callsWithOpenFence = renderSpy.mock.calls.length;
|
||||
|
||||
// Close the fence with a following \n\n — now the whole block is freezable
|
||||
await service.render('Before\n\n```js\ncode here\n```\n\nAfter content', container);
|
||||
|
||||
expect(container.textContent).toContain('Before');
|
||||
expect(container.textContent).toContain('code here');
|
||||
expect(container.textContent).toContain('After content');
|
||||
// Render was called again (live tail updated)
|
||||
expect(renderSpy.mock.calls.length).toBeGreaterThan(callsWithOpenFence);
|
||||
});
|
||||
|
||||
it('isFinal=true resets state and does a clean full render', async () => {
|
||||
const container = document.createElement('div');
|
||||
|
||||
// Simulate streaming
|
||||
await service.render('Paragraph one\n\nLive', container);
|
||||
// Finalize
|
||||
await service.render('Paragraph one\n\nLive tail complete', container, true);
|
||||
|
||||
// After finalize, container should have clean output (no frozen/live divs)
|
||||
const paragraphs = container.querySelectorAll('p');
|
||||
expect(paragraphs.length).toBeGreaterThanOrEqual(1);
|
||||
expect(container.textContent).toContain('Paragraph one');
|
||||
expect(container.textContent).toContain('Live tail complete');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
2071
package-lock.json
generated
2071
package-lock.json
generated
File diff suppressed because it is too large
Load diff
40
package.json
40
package.json
|
|
@ -20,55 +20,45 @@
|
|||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "^4.7.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/path-browserify": "^1.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.59.3",
|
||||
"@typescript-eslint/parser": "8.59.3",
|
||||
"@vitest/ui": "^4.1.6",
|
||||
"esbuild": "^0.28.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.59.4",
|
||||
"@typescript-eslint/parser": "8.59.4",
|
||||
"@vitest/ui": "^4.1.7",
|
||||
"esbuild": "^0.28.0",
|
||||
"esbuild-svelte": "^0.9.5",
|
||||
"eslint": "^10.4.0",
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "^4.7.1",
|
||||
"eslint-plugin-obsidianmd": "^0.3.0",
|
||||
"globals": "^14.0.0",
|
||||
"globals": "^17.6.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"obsidian": "latest",
|
||||
"svelte": "^5.55.7",
|
||||
"svelte": "^5.55.9",
|
||||
"svelte-check": "^4.4.8",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"svelte-preprocess": "^6.0.5",
|
||||
"tslib": "2.8.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.6"
|
||||
"vitest": "^4.1.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.96.0",
|
||||
"@google/genai": "^2.3.0",
|
||||
"@shikijs/rehype": "^4.0.2",
|
||||
"@anthropic-ai/sdk": "^0.98.0",
|
||||
"@google/genai": "^2.6.0",
|
||||
"@shikijs/rehype": "^4.1.0",
|
||||
"core-js": "^3.49.0",
|
||||
"diff": "^9.0.0",
|
||||
"diff2html": "^3.4.56",
|
||||
"express": "^5.2.1",
|
||||
"fuzzysort": "^3.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.47",
|
||||
"katex": "^0.17.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"officeparser": "^7.0.3",
|
||||
"openai": "^6.38.0",
|
||||
"openai": "^6.39.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"regex-parser": "^2.3.1",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
"remark-emoji": "^5.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"remark-wiki-link": "^2.0.1",
|
||||
"unified": "^11.0.5",
|
||||
"unpdf": "^1.6.2",
|
||||
"uuid": "^14.0.0",
|
||||
"zod": "^4.4.3"
|
||||
|
|
|
|||
Loading…
Reference in a new issue