diff --git a/Components/ChatArea.svelte b/Components/ChatArea.svelte index 22984ae..1cec65b 100644 --- a/Components/ChatArea.svelte +++ b/Components/ChatArea.svelte @@ -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(Services.StreamingMarkdownService); let messageElements: { element: HTMLElement, index: number, role: Role }[] = []; - let lastProcessedContent: Map = new Map(); - 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 `
${message.content}
`; - } - - // 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 `
${content}
`; + 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) || `
${content}
`; - } catch (error) { - Exception.log(error); - return `
${content}
`; - } - } - - 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}
{/if} -
+
{#each messages as message, index} {@const content = message.getDisplayContent()} {#if message.shouldDisplayContent && content.trim() !== ""} {#if message.role === Role.User}
-
+
{@html content}
@@ -297,12 +220,8 @@ {@const messageId = message.timestamp.getTime().toString()}
-
- {#if currentStreamingMessageId === messageId} -
- {:else} - {@html getStaticHTML(message)} - {/if} +
+
diff --git a/Components/ChatInput.svelte b/Components/ChatInput.svelte index 6a825a6..f0dd40e 100644 --- a/Components/ChatInput.svelte +++ b/Components/ChatInput.svelte @@ -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 | 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 @@
(Services.VaultkeeperAIPlugin); @@ -29,7 +27,6 @@ const workSpaceService: WorkSpaceService = Resolve(Services.WorkSpaceService); const conversationService: ConversationFileSystemService = Resolve(Services.ConversationFileSystemService); const streamingMarkdownService: StreamingMarkdownService = Resolve(Services.StreamingMarkdownService); - const htmlService: HTMLService = Resolve(Services.HTMLService); const abortService: AbortService = Resolve(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((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 @@
- +
{ 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 @@
- {#if content !== ""} + {#if contentVisible} +
- {@html content} {#if selectedTopic === 1} 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 | 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); diff --git a/Services/StreamingMarkdownService.ts b/Services/StreamingMarkdownService.ts index 4e20a09..9025507 100644 --- a/Services/StreamingMarkdownService.ts +++ b/Services/StreamingMarkdownService.ts @@ -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(Services.HTMLService); - private readonly vaultCacheService: VaultCacheService = Resolve(Services.VaultCacheService); + private readonly plugin: VaultkeeperAIPlugin = Resolve(Services.VaultkeeperAIPlugin); + private readonly states = new WeakMap(); - private readonly processor: Processor; - private streamingStates: Map = new Map(); - - 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 { + 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>(); - - 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, ">"); - - 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(""); - inCodeBlock = false; - } else { - html.push("
");
-                    inCodeBlock = true;
-                }
-                continue;
-            }
-            
-            if (inCodeBlock) {
-                html.push(line + "\n");
-                continue;
-            }
-            
-            // Basic list support
-            if (/^[*+-]\s/.test(line)) {
-                if (!inList) {
-                    html.push("
    "); - inList = true; - } - html.push(`
  • ${line.substring(2)}
  • `); - } else if (inList && line.trim() === "") { - html.push("
"); - inList = false; - } else { - if (inList) { - html.push(""); - inList = false; - } - - // Basic formatting - const formatted = line - .replace(/\*\*(.+?)\*\*/g, "$1") - .replace(/\*(.+?)\*/g, "$1") - .replace(/`(.+?)`/g, "$1"); - - if (line.trim()) { - html.push(`

${formatted}

`); - } + 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(""); - if (inCodeBlock) html.push("
"); - - 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; } } \ No newline at end of file diff --git a/Styles/custom_styles.css b/Styles/custom_styles.css index 829f48c..1c02a11 100644 --- a/Styles/custom_styles.css +++ b/Styles/custom_styles.css @@ -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; - } } \ No newline at end of file diff --git a/__tests__/Services/StreamingMarkdownService.test.ts b/__tests__/Services/StreamingMarkdownService.test.ts index 3b999fc..777bb51 100644 --- a/__tests__/Services/StreamingMarkdownService.test.ts +++ b/__tests__/Services/StreamingMarkdownService.test.ts @@ -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(); + 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 `

${block.slice(2)}

`; + if (block.startsWith('```')) { + const lines = block.split('\n'); + const code = lines.slice(1, lines[lines.length - 1] === '```' ? -1 : undefined).join('\n'); + return `
${code}
`; + } + return `

${block}

`; + }).join(''); + }) + } + }; +}); describe('StreamingMarkdownService', () => { - let service: StreamingMarkdownService; - let mockHTMLService: Partial; - let mockVaultCacheService: Partial; - - 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('

'); - expect(result).toContain('Hello World'); - expect(result).toContain('

'); - expect(result).toContain('This is a test.'); - }); - - it('should handle bold text', () => { - const markdown = '**bold text**'; - const result = service.formatText(markdown); - - expect(result).toContain(''); - expect(result).toContain('bold text'); - }); - - it('should handle italic text', () => { - const markdown = '*italic text*'; - const result = service.formatText(markdown); - - expect(result).toContain(''); - 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(' { - const markdown = 'Use `console.log()` to debug.'; - const result = service.formatText(markdown); - - expect(result).toContain(''); - 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('

    '); - expect(result).toContain('
  • '); - 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('
      '); - expect(result).toContain('
    1. '); - expect(result).toContain('First'); - }); - - it('should handle links', () => { - const markdown = '[Google](https://google.com)'; - const result = service.formatText(markdown); - - expect(result).toContain(' { - 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('

      '); - }); - - 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(''); - - // Should escape HTML in fallback - expect(result).not.toContain('