Safetensors
GGUF
Turkish
llama
Llama-3
instruct
finetune
chatml
gpt4
synthetic data
distillation
function calling
json mode
axolotl
roleplaying
chat
Instructions to use tda45/TdAI with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- llama.cpp
How to use tda45/TdAI with llama.cpp:
Install (macOS, Linux)
curl -LsSf https://llama.app/install.sh | sh # Start a local OpenAI-compatible server with a web UI: llama serve -hf tda45/TdAI # Run inference directly in the terminal: llama cli -hf tda45/TdAI
Install from WinGet (Windows)
winget install llama.cpp # Start a local OpenAI-compatible server with a web UI: llama serve -hf tda45/TdAI # Run inference directly in the terminal: llama cli -hf tda45/TdAI
Use pre-built binary
# Download pre-built binary from: # https://github.com/ggerganov/llama.cpp/releases # Start a local OpenAI-compatible server with a web UI: ./llama-server -hf tda45/TdAI # Run inference directly in the terminal: ./llama-cli -hf tda45/TdAI
Build from source code
git clone https://github.com/ggerganov/llama.cpp.git cd llama.cpp cmake -B build cmake --build build -j --target llama-server llama-cli # Start a local OpenAI-compatible server with a web UI: ./build/bin/llama-server -hf tda45/TdAI # Run inference directly in the terminal: ./build/bin/llama-cli -hf tda45/TdAI
Use Docker
docker model run hf.co/tda45/TdAI
- LM Studio
- Jan
- Ollama
How to use tda45/TdAI with Ollama:
ollama run hf.co/tda45/TdAI
- Unsloth Desktop
- Docker Model Runner
How to use tda45/TdAI with Docker Model Runner:
docker model run hf.co/tda45/TdAI
- Lemonade
How to use tda45/TdAI with Lemonade:
Pull the model
# Download Lemonade from https://lemonade-server.ai/ lemonade pull tda45/TdAI
Run and chat with the model
lemonade run user.TdAI-{{QUANT_TAG}}List all available models
lemonade list
- Atomic Chat
File size: 29,405 Bytes
8efb28e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 | <script lang="ts">
import { remark } from 'remark';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeHighlight from 'rehype-highlight';
import { all as lowlightAll } from 'lowlight';
import remarkRehype from 'remark-rehype';
import rehypeKatex from 'rehype-katex';
import rehypeStringify from 'rehype-stringify';
import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
import type { Root as MdastRoot } from 'mdast';
import { browser } from '$app/environment';
import { onDestroy, tick } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import { rehypeRestoreTableHtml } from './plugins/rehype/table-html-restorer';
import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links';
import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks';
import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks';
import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre';
import { rehypeSvgPre } from './plugins/rehype/svg-pre';
import { rehypeEnhanceSvgBlocks } from './plugins/rehype/enhance-svg-blocks';
import { rehypeResolveAttachmentImages } from './plugins/rehype/resolve-attachment-images';
import { rehypeRtlSupport } from './plugins/rehype/rehype-rtl-support';
import { remarkLiteralHtml } from './plugins/remark/literal-html';
import {
getHastNodeId,
getMdastNodeHash,
isAppendMode,
getCodeInfoFromTarget
} from './markdown-utils';
import {
preprocessLaTeX,
getImageErrorFallbackHtml,
copyCodeToClipboard,
copyToClipboard
} from '$lib/utils';
import {
IMAGE_NOT_ERROR_BOUND_SELECTOR,
DATA_ERROR_BOUND_ATTR,
DATA_ERROR_HANDLED_ATTR,
BOOL_TRUE_STRING,
SETTINGS_KEYS,
CODE_BLOCK_HEADER_CLASS,
MERMAID_WRAPPER_CLASS,
MERMAID_BLOCK_CLASS,
MERMAID_LANGUAGE,
MERMAID_SYNTAX_ATTR,
MERMAID_RENDERED_ATTR,
SVG_WRAPPER_CLASS,
SVG_BLOCK_CLASS,
SVG_LANGUAGE,
XML_LANGUAGE,
SVG_TAG_PREFIX,
SVG_SOURCE_ATTR,
SVG_RENDERED_ATTR,
SVG_INLINE_SHADOW_STYLE,
TOGGLE_SOURCE_BTN_CLASS,
DIAGRAM_VIEW_MODE_ATTR,
DIAGRAM_VIEW_RENDERED,
DIAGRAM_VIEW_SOURCE
} from '$lib/constants';
import { ColorMode, UrlProtocol } from '$lib/enums';
import { FileTypeText } from '$lib/enums/files.enums';
import { highlightCode, detectIncompleteCodeBlock, type IncompleteCodeBlock } from '$lib/utils';
import { sanitizeSvg } from '$lib/utils/sanitize-svg';
import { mountSvgShadow } from '$lib/utils/svg-shadow';
import '$styles/katex-custom.scss';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import githubLightCss from 'highlight.js/styles/github.css?inline';
import { mode } from 'mode-watcher';
import {
CodeBlockActions,
DialogCodePreview,
DialogMermaidPreview,
ActionIconCopyToClipboard
} from '$lib/components/app';
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import type { DatabaseMessageExtra } from '$lib/types/database';
import { config } from '$lib/stores/settings.svelte';
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
interface Props {
attachments?: DatabaseMessageExtra[];
content: string;
class?: string;
disableMath?: boolean;
}
interface MarkdownBlock {
id: string;
html: string;
contentHash?: string;
}
let { content, attachments, class: className = '', disableMath = false }: Props = $props();
let containerRef = $state<HTMLDivElement>();
let renderedBlocks = $state<MarkdownBlock[]>([]);
let unstableBlockHtml = $state('');
let incompleteCodeBlock = $state<IncompleteCodeBlock | null>(null);
const streamingSvgCode = $derived.by(() => {
const block = incompleteCodeBlock;
if (!block) return null;
if (block.language === SVG_LANGUAGE) return block.code;
if (block.language === XML_LANGUAGE && block.code.trimStart().startsWith(SVG_TAG_PREFIX))
return block.code;
return null;
});
const liveSvgHtml = $derived(streamingSvgCode !== null ? sanitizeSvg(streamingSvgCode) : '');
let previewDialogOpen = $state(false);
let previewCode = $state('');
let previewLanguage = $state('text');
let mermaidPreviewOpen = $state(false);
let mermaidPreviewSvgHtml = $state('');
let svgPreviewLive = $state(false);
let streamingSvgHost = $state<HTMLDivElement | null>(null);
// While the zoom dialog is open on a streaming svg, mirror the live render into it
$effect(() => {
if (svgPreviewLive && liveSvgHtml) mermaidPreviewSvgHtml = liveSvgHtml;
});
// Mount the streaming svg into its shadow host on every chunk so it renders live
$effect(() => {
if (streamingSvgHost) mountSvgShadow(streamingSvgHost, liveSvgHtml, SVG_INLINE_SHADOW_STYLE);
});
let streamingCodeScrollContainer = $state<HTMLDivElement>();
// Auto-scroll controller for streaming code block content
const streamingAutoScroll = createAutoScrollController();
let pendingMarkdown: string | null = null;
let isProcessing = false;
// Per-instance transform cache, avoids re-transforming stable blocks during streaming
// Garbage collected when component is destroyed (on conversation change)
const transformCache = new SvelteMap<string, string>();
let previousContent = '';
const themeStyleId = `highlight-theme-${(window.idxThemeStyle = (window.idxThemeStyle ?? 0) + 1)}`;
let processor = $derived(() => {
void attachments;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let proc: any = remark().use(remarkGfm); // GitHub Flavored Markdown
if (!disableMath) {
proc = proc.use(remarkMath); // Parse $inline$ and $$block$$ math
}
proc = proc
.use(remarkBreaks) // Convert line breaks to <br>
.use(remarkLiteralHtml) // Treat raw HTML as literal text with preserved indentation
.use(remarkRehype); // Convert Markdown AST to rehype
if (!disableMath) {
proc = proc.use(rehypeKatex); // Render math using KaTeX
}
return proc
.use(rehypeHighlight, {
languages: lowlightAll,
aliases: { [FileTypeText.XML]: [FileTypeText.SVELTE, FileTypeText.VUE] }
}) // Add syntax highlighting
.use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
.use(rehypeEnhanceLinks) // Add target="_blank" to links
.use(rehypeMermaidPre) // Convert mermaid blocks to <pre class="mermaid">
.use(rehypeSvgPre) // Convert svg blocks to <pre class="svg-block">
.use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
.use(rehypeEnhanceMermaidBlocks) // Wrap mermaid blocks with header and actions
.use(rehypeEnhanceSvgBlocks) // Wrap svg blocks with header and actions
.use(rehypeResolveAttachmentImages, { attachments })
.use(rehypeRtlSupport) // Add bidirectional text support
.use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
});
/**
* Removes click event listeners from copy and preview buttons.
* Called on component destroy.
*/
function cleanupEventListeners() {
if (!containerRef) return;
const copyButtons = containerRef.querySelectorAll<HTMLButtonElement>('.copy-code-btn');
const previewButtons = containerRef.querySelectorAll<HTMLButtonElement>('.preview-code-btn');
for (const button of copyButtons) {
button.removeEventListener('click', handleCopyClick);
}
for (const button of previewButtons) {
button.removeEventListener('click', handlePreviewClick);
}
}
/**
* Removes this component's highlight.js theme style from the document head.
* Called on component destroy to clean up injected styles.
*/
function cleanupHighlightTheme() {
if (!browser) return;
const existingTheme = document.getElementById(themeStyleId);
existingTheme?.remove();
}
/**
* Loads the appropriate highlight.js theme based on dark/light mode.
* Injects a scoped style element into the document head.
* @param isDark - Whether to load the dark theme (true) or light theme (false)
*/
function loadHighlightTheme(isDark: boolean) {
if (!browser) return;
const existingTheme = document.getElementById(themeStyleId);
existingTheme?.remove();
const style = document.createElement('style');
style.id = themeStyleId;
style.textContent = isDark ? githubDarkCss : githubLightCss;
document.head.appendChild(style);
}
/**
* Transforms a single MDAST node to HTML string with caching.
/**
* Transforms a single MDAST node to HTML string with caching.
* Runs the full remark/rehype plugin pipeline (GFM, math, syntax highlighting, etc.)
* on an isolated single-node tree, then stringifies the resulting HAST to HTML.
* Results are cached by node position hash for streaming performance.
* @param processorInstance - The remark/rehype processor instance
* @param node - The MDAST node to transform
* @param index - Node index for hash fallback
* @returns Object containing the HTML string and cache hash
*/
async function transformMdastNode(
processorInstance: ReturnType<typeof processor>,
node: unknown,
index: number
): Promise<{ html: string; hash: string }> {
const hash = getMdastNodeHash(node, index);
const cached = transformCache.get(hash);
if (cached) {
return { html: cached, hash };
}
const singleNodeRoot = { type: 'root', children: [node] };
const transformedRoot = (await processorInstance.run(singleNodeRoot as MdastRoot)) as HastRoot;
const html = processorInstance.stringify(transformedRoot);
transformCache.set(hash, html);
return { html, hash };
}
/**
* Handles click events on copy buttons within code blocks.
* Copies the raw code content to the clipboard.
* @param event - The click event from the copy button
*/
async function handleCopyClick(event: Event) {
event.preventDefault();
event.stopPropagation();
const target = event.currentTarget as HTMLButtonElement | null;
if (!target) {
return;
}
const info = getCodeInfoFromTarget(target);
if (!info) {
return;
}
try {
await copyCodeToClipboard(info.rawCode);
} catch (error) {
console.error('Failed to copy code:', error);
}
}
/**
* Handles preview dialog open state changes.
* Clears preview content when dialog is closed.
* @param open - Whether the dialog is being opened or closed
*/
function handlePreviewDialogOpenChange(open: boolean) {
previewDialogOpen = open;
if (!open) {
previewCode = '';
previewLanguage = 'text';
}
}
/**
* Handles click events on preview buttons within HTML code blocks.
* Opens a preview dialog with the rendered HTML content.
* @param event - The click event from the preview button
*/
function handlePreviewClick(event: Event) {
event.preventDefault();
event.stopPropagation();
const target = event.currentTarget as HTMLButtonElement | null;
if (!target) {
return;
}
const info = getCodeInfoFromTarget(target);
if (!info) {
return;
}
previewCode = info.rawCode;
previewLanguage = info.language;
previewDialogOpen = true;
}
/**
* Processes markdown content into stable and unstable HTML blocks.
* Uses incremental rendering: stable blocks are cached, unstable block is re-rendered.
* Incomplete code blocks are rendered using SyntaxHighlightedCode to maintain interactivity.
* @param markdown - The raw markdown string to process
*/
async function processMarkdown(markdown: string) {
// Early exit if content unchanged (can happen with rapid coalescing)
if (markdown === previousContent) {
return;
}
if (!markdown) {
renderedBlocks = [];
unstableBlockHtml = '';
incompleteCodeBlock = null;
previousContent = '';
return;
}
// Check for incomplete code block at the end of content
const incompleteBlock = detectIncompleteCodeBlock(markdown);
if (incompleteBlock) {
// Process only the prefix (content before the incomplete code block)
const prefixMarkdown = markdown.slice(0, incompleteBlock.openingIndex);
if (prefixMarkdown.trim()) {
const normalizedPrefix = preprocessLaTeX(prefixMarkdown);
const processorInstance = processor();
const ast = processorInstance.parse(normalizedPrefix) as MdastRoot;
const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
const nextBlocks: MarkdownBlock[] = [];
// Check if we're in append mode for cache reuse
const appendMode = isAppendMode(prefixMarkdown, previousContent);
const previousBlockCount = appendMode ? renderedBlocks.length : 0;
// All prefix blocks are now stable since code block is separate
for (let index = 0; index < mdastChildren.length; index++) {
const child = mdastChildren[index];
// In append mode, reuse previous blocks if unchanged
if (appendMode && index < previousBlockCount) {
const prevBlock = renderedBlocks[index];
const currentHash = getMdastNodeHash(child, index);
if (prevBlock?.contentHash === currentHash) {
nextBlocks.push(prevBlock);
continue;
}
}
// Transform this block (with caching)
const { html, hash } = await transformMdastNode(processorInstance, child, index);
const id = getHastNodeId(
{ position: (child as { position?: unknown }).position } as HastRootContent,
index
);
nextBlocks.push({ id, html, contentHash: hash });
}
renderedBlocks = nextBlocks;
} else {
renderedBlocks = [];
}
previousContent = prefixMarkdown;
unstableBlockHtml = '';
incompleteCodeBlock = incompleteBlock;
return;
}
// No incomplete code block - use standard processing
incompleteCodeBlock = null;
const normalized = preprocessLaTeX(markdown);
const processorInstance = processor();
const ast = processorInstance.parse(normalized) as MdastRoot;
const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
const stableCount = Math.max(mdastChildren.length - 1, 0);
const nextBlocks: MarkdownBlock[] = [];
// Check if we're in append mode for cache reuse
const appendMode = isAppendMode(markdown, previousContent);
const previousBlockCount = appendMode ? renderedBlocks.length : 0;
for (let index = 0; index < stableCount; index++) {
const child = mdastChildren[index];
// In append mode, reuse previous blocks if unchanged
if (appendMode && index < previousBlockCount) {
const prevBlock = renderedBlocks[index];
const currentHash = getMdastNodeHash(child, index);
if (prevBlock?.contentHash === currentHash) {
nextBlocks.push(prevBlock);
continue;
}
}
// Transform this block (with caching)
const { html, hash } = await transformMdastNode(processorInstance, child, index);
const id = getHastNodeId(
{ position: (child as { position?: unknown }).position } as HastRootContent,
index
);
nextBlocks.push({ id, html, contentHash: hash });
}
let unstableHtml = '';
if (mdastChildren.length > stableCount) {
const unstableChild = mdastChildren[stableCount];
const singleNodeRoot = { type: 'root', children: [unstableChild] };
const transformedRoot = (await processorInstance.run(
singleNodeRoot as MdastRoot
)) as HastRoot;
unstableHtml = processorInstance.stringify(transformedRoot);
}
renderedBlocks = nextBlocks;
previousContent = markdown;
await tick(); // Force DOM sync before updating unstable HTML block
unstableBlockHtml = unstableHtml;
}
/**
* Attaches click event listeners to copy and preview buttons in code blocks.
* Uses data-listener-bound attribute to prevent duplicate bindings.
*/
function setupCodeBlockActions() {
if (!containerRef) return;
const wrappers = containerRef.querySelectorAll<HTMLElement>('.code-block-wrapper');
for (const wrapper of wrappers) {
const copyButton = wrapper.querySelector<HTMLButtonElement>('.copy-code-btn');
const previewButton = wrapper.querySelector<HTMLButtonElement>('.preview-code-btn');
if (copyButton && copyButton.dataset.listenerBound !== 'true') {
copyButton.dataset.listenerBound = 'true';
copyButton.addEventListener('click', handleCopyClick);
}
if (previewButton && previewButton.dataset.listenerBound !== 'true') {
previewButton.dataset.listenerBound = 'true';
previewButton.addEventListener('click', handlePreviewClick);
}
}
}
/**
* Attaches error handlers to images to show fallback UI when loading fails (e.g., CORS).
* Uses data-error-bound attribute to prevent duplicate bindings.
*/
function setupImageErrorHandlers() {
if (!containerRef) return;
const images = containerRef.querySelectorAll<HTMLImageElement>(IMAGE_NOT_ERROR_BOUND_SELECTOR);
for (const img of images) {
img.dataset[DATA_ERROR_BOUND_ATTR] = BOOL_TRUE_STRING;
img.addEventListener('error', handleImageError);
}
}
/**
* Opens the mermaid diagram in a full-screen preview dialog with zoom/pan support.
* Also handles copy and preview button clicks for mermaid blocks.
* Uses event delegation: a single handler on the container.
*/
async function handleMermaidClick(event: MouseEvent) {
const target = event.target as HTMLElement;
// Toggle a diagram block between its rendered view and its source view.
// Shared by mermaid and svg, css drives the visibility from the wrapper mode.
const toggleBtn = target.closest(`.${TOGGLE_SOURCE_BTN_CLASS}`);
if (toggleBtn) {
event.preventDefault();
event.stopPropagation();
const wrapper = toggleBtn.closest(`.${MERMAID_WRAPPER_CLASS}, .${SVG_WRAPPER_CLASS}`);
if (!wrapper) return;
const isSource = wrapper.getAttribute(DIAGRAM_VIEW_MODE_ATTR) === DIAGRAM_VIEW_SOURCE;
const next = isSource ? DIAGRAM_VIEW_RENDERED : DIAGRAM_VIEW_SOURCE;
wrapper.setAttribute(DIAGRAM_VIEW_MODE_ATTR, next);
toggleBtn.setAttribute('aria-pressed', String(!isSource));
return;
}
// Check if clicking on copy or preview button in mermaid block
const copyBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .copy-code-btn`);
const previewBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .preview-code-btn`);
if (copyBtn || previewBtn) {
const wrapper = target.closest(`.${MERMAID_WRAPPER_CLASS}`);
if (!wrapper) return;
const preElement = wrapper.querySelector<HTMLElement>(
`pre.${MERMAID_BLOCK_CLASS}[${MERMAID_SYNTAX_ATTR}]`
);
if (!preElement) return;
const mermaidSyntax = preElement.getAttribute(MERMAID_SYNTAX_ATTR) ?? '';
if (copyBtn) {
event.preventDefault();
event.stopPropagation();
try {
await copyToClipboard(mermaidSyntax);
} catch (error) {
console.error('Failed to copy mermaid syntax:', error);
}
return;
}
if (previewBtn) {
event.preventDefault();
event.stopPropagation();
const svg = preElement.querySelector('svg');
if (!svg) return;
mermaidPreviewSvgHtml = svg.outerHTML;
svgPreviewLive = false;
mermaidPreviewOpen = true;
return;
}
}
// Check if clicking on copy or preview button in svg block
const svgCopyBtn = target.closest(`.${SVG_WRAPPER_CLASS} .copy-code-btn`);
const svgPreviewBtn = target.closest(`.${SVG_WRAPPER_CLASS} .preview-code-btn`);
if (svgCopyBtn || svgPreviewBtn) {
const wrapper = target.closest(`.${SVG_WRAPPER_CLASS}`);
if (!wrapper) return;
const preElement = wrapper.querySelector<HTMLElement>(
`pre.${SVG_BLOCK_CLASS}[${SVG_SOURCE_ATTR}]`
);
if (!preElement) return;
if (svgCopyBtn) {
event.preventDefault();
event.stopPropagation();
try {
await copyToClipboard(preElement.getAttribute(SVG_SOURCE_ATTR) ?? '');
} catch (error) {
console.error('Failed to copy svg source:', error);
}
return;
}
if (svgPreviewBtn) {
event.preventDefault();
event.stopPropagation();
mermaidPreviewSvgHtml = sanitizeSvg(preElement.getAttribute(SVG_SOURCE_ATTR) ?? '');
svgPreviewLive = false;
mermaidPreviewOpen = true;
return;
}
}
// A click on the header chrome targets the action buttons, never the
// diagram. Guard so a header click can not fall through to the click to
// zoom branches below, whatever the scroll position or stacking.
if (target.closest(`.${CODE_BLOCK_HEADER_CLASS}`)) return;
// Open preview when clicking the svg block itself. A final block carries its
// source, a streaming block does not and is mirrored live into the dialog.
const svgEl = target.closest(`.${SVG_BLOCK_CLASS}`);
if (svgEl) {
const source = svgEl.getAttribute(SVG_SOURCE_ATTR);
if (source !== null) {
mermaidPreviewSvgHtml = sanitizeSvg(source);
svgPreviewLive = false;
} else {
svgPreviewLive = true;
}
mermaidPreviewOpen = true;
return;
}
// Otherwise, open preview when clicking on the mermaid diagram itself
const mermaidEl = target.closest(`.${MERMAID_BLOCK_CLASS}`);
if (!mermaidEl) return;
const svg = mermaidEl.querySelector('svg');
if (!svg) return;
mermaidPreviewSvgHtml = svg.outerHTML;
svgPreviewLive = false;
mermaidPreviewOpen = true;
}
/**
* Handles mermaid preview dialog open state changes.
* Cleans up SVG content when dialog is closed.
*/
function handleMermaidPreviewOpenChange(open: boolean) {
mermaidPreviewOpen = open;
if (!open) {
mermaidPreviewSvgHtml = '';
svgPreviewLive = false;
}
}
/**
* Renders mermaid diagrams that haven't been rendered yet.
* Called after each markdown content update.
* Marks nodes immediately to prevent duplicate renders during streaming.
* Reads mode.current before await to ensure reactive tracking.
*/
async function renderMermaidDiagrams() {
if (!containerRef) return;
const nodes = containerRef.querySelectorAll(
`pre.${MERMAID_BLOCK_CLASS}:not([${MERMAID_RENDERED_ATTR}])`
);
if (nodes.length === 0) return;
// Mark nodes immediately to prevent duplicate renders if called again during streaming.
// This avoids needing a guard that would block node discovery.
nodes.forEach((node) => node.setAttribute(MERMAID_RENDERED_ATTR, 'true'));
// Read mode before await so Svelte tracks it reactively.
const isDark = mode.current === ColorMode.DARK;
// lazy load the mermaid dependecy only when needed to reduce bundle size.
const { default: mermaid } = await import('mermaid');
mermaid.initialize({
startOnLoad: false,
theme: isDark ? 'dark' : 'default',
securityLevel: 'strict',
flowchart: {
useMaxWidth: false,
htmlLabels: true
},
sequence: {
useMaxWidth: false
},
gantt: {
useMaxWidth: false
}
});
try {
await mermaid.run({
nodes: Array.from(nodes) as unknown as NodeListOf<HTMLElement>
});
} catch (error) {
console.error('Failed to render mermaid diagram:', error);
}
}
/**
* Renders svg diagrams that haven't been rendered yet.
* Sanitizes the source before injecting and marks each node so it renders once.
* An empty sanitize result keeps the raw source as escaped text.
*/
function renderSvgDiagrams() {
if (!containerRef) return;
const nodes = containerRef.querySelectorAll<HTMLElement>(
`pre.${SVG_BLOCK_CLASS}:not([${SVG_RENDERED_ATTR}])`
);
if (nodes.length === 0) return;
nodes.forEach((node) => {
node.setAttribute(SVG_RENDERED_ATTR, 'true');
const source = node.getAttribute(SVG_SOURCE_ATTR) ?? node.textContent ?? '';
const clean = sanitizeSvg(source);
if (clean) {
node.textContent = '';
const host = document.createElement('div');
node.appendChild(host);
mountSvgShadow(host, clean, SVG_INLINE_SHADOW_STYLE);
}
});
}
/**
* Handles image load errors by replacing the image with a fallback UI.
* Shows a placeholder with a link to open the image in a new tab.
*/
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement;
if (!img || !img.src) return;
// Don't handle data URLs or already-handled images
if (
img.src.startsWith(UrlProtocol.DATA) ||
img.dataset[DATA_ERROR_HANDLED_ATTR] === BOOL_TRUE_STRING
)
return;
img.dataset[DATA_ERROR_HANDLED_ATTR] = BOOL_TRUE_STRING;
const src = img.src;
// Create fallback element
const fallback = document.createElement('div');
fallback.className = 'image-load-error';
fallback.innerHTML = getImageErrorFallbackHtml(src);
// Replace image with fallback
img.parentNode?.replaceChild(fallback, img);
}
/**
* Queues markdown for processing with coalescing support.
* Only processes the latest markdown when multiple updates arrive quickly.
* Uses requestAnimationFrame to yield to browser paint between batches.
* @param markdown - The markdown content to render
*/
async function updateRenderedBlocks(markdown: string) {
pendingMarkdown = markdown;
if (isProcessing) {
return;
}
isProcessing = true;
try {
while (pendingMarkdown !== null) {
const nextMarkdown = pendingMarkdown;
pendingMarkdown = null;
await processMarkdown(nextMarkdown);
// Yield to browser for paint. During this, new chunks coalesce
// into pendingMarkdown, so we always render the latest state.
if (pendingMarkdown !== null) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
}
} catch (error) {
console.error('Failed to process markdown:', error);
renderedBlocks = [];
unstableBlockHtml = markdown.replace(/\n/g, '<br>');
} finally {
isProcessing = false;
}
}
$effect(() => {
const currentMode = mode.current;
const isDark = currentMode === ColorMode.DARK;
loadHighlightTheme(isDark);
});
$effect(() => {
updateRenderedBlocks(content);
});
$effect(() => {
const hasRenderedBlocks = renderedBlocks.length > 0;
const hasUnstableBlock = Boolean(unstableBlockHtml);
if ((hasRenderedBlocks || hasUnstableBlock) && containerRef) {
setupCodeBlockActions();
setupImageErrorHandlers();
renderMermaidDiagrams();
renderSvgDiagrams();
}
});
// Auto-scroll for streaming code block
$effect(() => {
streamingAutoScroll.setContainer(streamingCodeScrollContainer);
});
$effect(() => {
streamingAutoScroll.updateInterval(incompleteCodeBlock !== null);
});
onDestroy(() => {
cleanupEventListeners();
cleanupHighlightTheme();
streamingAutoScroll.destroy();
});
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={containerRef}
onclick={handleMermaidClick}
class="markdown-content {className}{config()[SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS]
? ' full-height-code-blocks'
: ''}"
>
{#each renderedBlocks as block (block.id)}
<div class="markdown-block" data-block-id={block.id} use:fadeInView={{ skipIfVisible: true }}>
{@html block.html}
</div>
{/each}
{#if unstableBlockHtml}
<div class="markdown-block markdown-block--unstable" data-block-id="unstable">
<!-- eslint-disable-next-line no-at-html-tags -->
{@html unstableBlockHtml}
</div>
{/if}
{#if incompleteCodeBlock}
{#if incompleteCodeBlock.language === MERMAID_LANGUAGE}
<div class="mermaid-block-wrapper streaming-mermaid-block">
<div class="code-block-header">
<span class="code-language">mermaid</span>
<div class="code-block-actions">
<ActionIconCopyToClipboard
text={incompleteCodeBlock.code}
canCopy={false}
ariaLabel="Diagram incomplete"
/>
</div>
</div>
<div class="mermaid-loading-placeholder">
<span class="mermaid-loading-text">Generating diagram...</span>
</div>
</div>
{:else if streamingSvgCode !== null}
<div class="svg-block-wrapper streaming-svg-block">
<div class="code-block-header">
<span class="code-language">svg</span>
<div class="code-block-actions">
<ActionIconCopyToClipboard
text={incompleteCodeBlock.code}
canCopy={false}
ariaLabel="Diagram incomplete"
/>
</div>
</div>
{#if liveSvgHtml}
<div class="svg-scroll-container">
<div class={SVG_BLOCK_CLASS}>
<div bind:this={streamingSvgHost}></div>
</div>
</div>
{:else}
<div class="mermaid-loading-placeholder">
<span class="mermaid-loading-text">Rendering svg...</span>
</div>
{/if}
</div>
{:else}
<div class="code-block-wrapper streaming-code-block relative">
<div class="code-block-header">
<span class="code-language">{incompleteCodeBlock.language || 'text'}</span>
<CodeBlockActions
code={incompleteCodeBlock.code}
language={incompleteCodeBlock.language || 'text'}
disabled
onPreview={(code, lang) => {
previewCode = code;
previewLanguage = lang;
previewDialogOpen = true;
}}
/>
</div>
<div
bind:this={streamingCodeScrollContainer}
class="streaming-code-scroll-container"
onscroll={() => streamingAutoScroll.handleScroll()}
>
<pre class="streaming-code-pre"><code
class="hljs language-{incompleteCodeBlock.language || 'text'}"
>{@html highlightCode(
incompleteCodeBlock.code,
incompleteCodeBlock.language || 'text'
)}</code
></pre>
</div>
</div>
{/if}
{/if}
</div>
<DialogCodePreview
open={previewDialogOpen}
code={previewCode}
language={previewLanguage}
onOpenChange={handlePreviewDialogOpenChange}
/>
<DialogMermaidPreview
open={mermaidPreviewOpen}
svgHtml={mermaidPreviewSvgHtml}
onOpenChange={handleMermaidPreviewOpenChange}
/>
<style>
@import './markdown-content.css';
</style>
|