Merge branch 'upstream' into concedo_experimental

# Conflicts:
#	.devops/cpu.Dockerfile
#	.devops/cuda.Dockerfile
#	.devops/intel.Dockerfile
#	.devops/llama-cli-cann.Dockerfile
#	.devops/musa.Dockerfile
#	.devops/openvino.Dockerfile
#	.devops/rocm.Dockerfile
#	.devops/s390x.Dockerfile
#	.devops/vulkan.Dockerfile
#	.devops/zendnn.Dockerfile
#	.github/labeler.yml
#	docs/backend/CUDA-FEDORA.md
#	docs/ops.md
#	docs/ops/SYCL.csv
#	examples/sycl/build.sh
#	examples/sycl/win-build-sycl.bat
#	ggml/src/ggml-sycl/common.cpp
#	ggml/src/ggml-sycl/common.hpp
#	ggml/src/ggml-sycl/cpy.hpp
#	ggml/src/ggml-sycl/dmmv.cpp
#	ggml/src/ggml-sycl/ggml-sycl.cpp
#	ggml/src/ggml-sycl/mmvq.cpp
#	ggml/src/ggml-sycl/presets.hpp
#	ggml/src/ggml-sycl/set_rows.cpp
#	ggml/src/ggml-sycl/softmax.cpp
#	ggml/src/ggml-webgpu/wgsl-shaders/mul_mat_decls.tmpl
#	grammars/README.md
#	tests/test-backend-ops.cpp
#	tests/test-chat-auto-parser.cpp
#	tests/test-chat.cpp
#	tests/test-jinja.cpp
#	tools/cli/cli.cpp
This commit is contained in:
Concedo
2026-06-15 20:00:57 +08:00
40 changed files with 811 additions and 171 deletions
+4 -3
View File
@@ -35,6 +35,7 @@
"bits-ui": "2.18.1",
"clsx": "2.1.1",
"dexie": "4.4.3",
"dompurify": "3.4.5",
"eslint": "9.39.4",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-storybook": "10.4.2",
@@ -8651,9 +8652,9 @@
"peer": true
},
"node_modules/dompurify": {
"version": "3.4.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
"version": "3.4.5",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
"dev": true,
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
+1
View File
@@ -54,6 +54,7 @@
"bits-ui": "2.18.1",
"clsx": "2.1.1",
"dexie": "4.4.3",
"dompurify": "3.4.5",
"eslint": "9.39.4",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-storybook": "10.4.2",
+4 -1
View File
@@ -9,7 +9,10 @@
<link rel="manifest" href="./manifest.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content"
/>
%sveltekit.head%
</head>
@@ -56,6 +56,7 @@
const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
const renderThinkingAsMarkdown = $derived(config().renderThinkingAsMarkdown as boolean);
const hasReasoningError = $derived(
isLastAssistantMessage ? !!agenticLastError(message.convId) : false
@@ -316,9 +317,13 @@
onToggle={() => toggleExpanded(index, section)}
>
<div class="pt-3">
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
{section.content}
</div>
{#if renderThinkingAsMarkdown}
<MarkdownContent content={section.content} attachments={message?.extra} />
{:else}
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
{section.content}
</div>
{/if}
</div>
</CollapsibleContentBlock>
{:else if section.type === AgenticSectionType.REASONING_PENDING}
@@ -336,9 +341,13 @@
onToggle={() => toggleExpanded(index, section)}
>
<div class="pt-3">
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
{section.content}
</div>
{#if renderThinkingAsMarkdown}
<MarkdownContent content={section.content} attachments={message?.extra} />
{:else}
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
{section.content}
</div>
{/if}
</div>
</CollapsibleContentBlock>
{/if}
@@ -18,6 +18,8 @@
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';
@@ -38,11 +40,26 @@
DATA_ERROR_BOUND_ATTR,
DATA_ERROR_HANDLED_ATTR,
BOOL_TRUE_STRING,
SETTINGS_KEYS
SETTINGS_KEYS,
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
} 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';
@@ -77,11 +94,32 @@
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>();
@@ -124,8 +162,10 @@
.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
@@ -462,17 +502,19 @@
const target = event.target as HTMLElement;
// Check if clicking on copy or preview button in mermaid block
const copyBtn = target.closest('.mermaid-block-wrapper .copy-code-btn');
const previewBtn = target.closest('.mermaid-block-wrapper .preview-code-btn');
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-block-wrapper');
const wrapper = target.closest(`.${MERMAID_WRAPPER_CLASS}`);
if (!wrapper) return;
const preElement = wrapper.querySelector<HTMLElement>('pre.mermaid[data-mermaid-syntax]');
const preElement = wrapper.querySelector<HTMLElement>(
`pre.${MERMAID_BLOCK_CLASS}[${MERMAID_SYNTAX_ATTR}]`
);
if (!preElement) return;
const mermaidSyntax = preElement.dataset.mermaidSyntax ?? '';
const mermaidSyntax = preElement.getAttribute(MERMAID_SYNTAX_ATTR) ?? '';
if (copyBtn) {
event.preventDefault();
@@ -491,19 +533,70 @@
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;
}
}
// 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');
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;
}
@@ -515,6 +608,7 @@
mermaidPreviewOpen = open;
if (!open) {
mermaidPreviewSvgHtml = '';
svgPreviewLive = false;
}
}
@@ -527,12 +621,14 @@
async function renderMermaidDiagrams() {
if (!containerRef) return;
const nodes = containerRef.querySelectorAll('pre.mermaid:not([data-mermaid-rendered])');
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('data-mermaid-rendered', 'true'));
nodes.forEach((node) => node.setAttribute(MERMAID_RENDERED_ATTR, 'true'));
// Read mode before await so Svelte tracks it reactively.
const isDark = mode.current === ColorMode.DARK;
@@ -565,6 +661,34 @@
}
}
/**
* 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.
@@ -647,6 +771,7 @@
setupCodeBlockActions();
setupImageErrorHandlers();
renderMermaidDiagrams();
renderSvgDiagrams();
}
});
@@ -689,7 +814,7 @@
{/if}
{#if incompleteCodeBlock}
{#if incompleteCodeBlock.language === 'mermaid'}
{#if incompleteCodeBlock.language === MERMAID_LANGUAGE}
<div class="mermaid-block-wrapper streaming-mermaid-block">
<div class="code-block-header">
<span class="code-language">mermaid</span>
@@ -705,6 +830,30 @@
<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">
@@ -560,8 +560,9 @@ div.markdown-user-content :global(.table-wrapper) {
border-color: var(--primary);
}
/* Mermaid diagrams */
.markdown-content :global(pre.mermaid) {
/* Mermaid and svg blocks share the same block styling */
.markdown-content :global(pre.mermaid),
.markdown-content :global(.svg-block) {
background: transparent;
border: none;
padding: 0;
@@ -572,13 +573,25 @@ div.markdown-user-content :global(.table-wrapper) {
position: relative;
}
/* The svg block fills its flex container so the shadow host has a definite width to render into */
.markdown-content :global(.svg-block) {
width: 100%;
}
/* Hide mermaid code text until rendered - prevents flash */
.markdown-content :global(pre.mermaid:not([data-mermaid-rendered])),
.markdown-content :global(pre.mermaid[data-mermaid-rendered]:not(:has(svg))) {
display: none;
}
.markdown-content :global(pre.mermaid:hover) {
/* Hide svg source until rendered - prevents flash. A rendered-but-unsanitized
block (oversized source) keeps its raw text visible as a safe fallback. */
.markdown-content :global(pre.svg-block:not([data-svg-rendered])) {
display: none;
}
.markdown-content :global(pre.mermaid:hover),
.markdown-content :global(.svg-block:hover) {
opacity: 0.85;
}
@@ -590,8 +603,9 @@ div.markdown-user-content :global(.table-wrapper) {
padding: 3rem 1rem;
}
/* Mermaid block wrapper - matches code block styling */
.markdown-content :global(.mermaid-block-wrapper) {
/* Diagram block wrapper - matches code block styling */
.markdown-content :global(.mermaid-block-wrapper),
.markdown-content :global(.svg-block-wrapper) {
margin: 1.5rem 0;
border-radius: 0.75rem;
overflow: hidden;
@@ -603,11 +617,13 @@ div.markdown-user-content :global(.table-wrapper) {
max-height: var(--max-message-height);
}
.markdown-content:global(.dark) :global(.mermaid-block-wrapper) {
.markdown-content:global(.dark) :global(.mermaid-block-wrapper),
.markdown-content:global(.dark) :global(.svg-block-wrapper) {
border-color: color-mix(in oklch, var(--border) 20%, transparent);
}
.markdown-content :global(.mermaid-scroll-container) {
.markdown-content :global(.mermaid-scroll-container),
.markdown-content :global(.svg-scroll-container) {
min-height: 350px;
max-height: var(--max-message-height);
overflow-y: auto;
@@ -618,17 +634,20 @@ div.markdown-user-content :global(.table-wrapper) {
padding: 3rem 1rem 1rem;
}
.full-height-code-blocks :global(.mermaid-block-wrapper) {
.full-height-code-blocks :global(.mermaid-block-wrapper),
.full-height-code-blocks :global(.svg-block-wrapper) {
max-height: none;
}
.full-height-code-blocks :global(.mermaid-scroll-container) {
.full-height-code-blocks :global(.mermaid-scroll-container),
.full-height-code-blocks :global(.svg-scroll-container) {
max-height: none;
overflow-y: visible;
}
/* Mermaid block uses same header styling as code blocks */
.markdown-content :global(.mermaid-block-wrapper .code-block-header) {
/* Diagram block uses same header styling as code blocks */
.markdown-content :global(.mermaid-block-wrapper .code-block-header),
.markdown-content :global(.svg-block-wrapper .code-block-header) {
display: flex;
justify-content: space-between;
align-items: center;
@@ -640,14 +659,16 @@ div.markdown-user-content :global(.table-wrapper) {
right: 0;
}
.markdown-content :global(.mermaid-block-wrapper .code-block-actions) {
.markdown-content :global(.mermaid-block-wrapper .code-block-actions),
.markdown-content :global(.svg-block-wrapper .code-block-actions) {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Mermaid pre element - remove default margins */
.markdown-content :global(.mermaid-block-wrapper pre.mermaid) {
/* Diagram pre element - remove default margins */
.markdown-content :global(.mermaid-block-wrapper pre.mermaid),
.markdown-content :global(.svg-block-wrapper pre.svg-block) {
background: transparent;
border: none;
padding: 0;
@@ -655,7 +676,6 @@ div.markdown-user-content :global(.table-wrapper) {
text-align: center;
}
/* Mermaid SVG should be bigger */
.markdown-content :global(.mermaid-block-wrapper pre.mermaid svg) {
width: unset !important;
height: auto;
@@ -4,6 +4,7 @@
*/
import { copyCodeToClipboard, copyToClipboard } from '$lib/utils';
import { MERMAID_WRAPPER_CLASS, MERMAID_BLOCK_CLASS, MERMAID_SYNTAX_ATTR } from '$lib/constants';
export interface PreviewState {
previewDialogOpen: boolean;
@@ -106,17 +107,19 @@ export function createHandleMermaidClick(mermaidState: MermaidPreviewState) {
const target = event.target as HTMLElement;
// Check if clicking on copy or preview button in mermaid block
const copyBtn = target.closest('.mermaid-block-wrapper .copy-code-btn');
const previewBtn = target.closest('.mermaid-block-wrapper .preview-code-btn');
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-block-wrapper');
const wrapper = target.closest(`.${MERMAID_WRAPPER_CLASS}`);
if (!wrapper) return;
const preElement = wrapper.querySelector<HTMLElement>('pre.mermaid[data-mermaid-syntax]');
const preElement = wrapper.querySelector<HTMLElement>(
`pre.${MERMAID_BLOCK_CLASS}[${MERMAID_SYNTAX_ATTR}]`
);
if (!preElement) return;
const mermaidSyntax = preElement.dataset.mermaidSyntax ?? '';
const mermaidSyntax = preElement.getAttribute(MERMAID_SYNTAX_ATTR) ?? '';
if (copyBtn) {
event.preventDefault();
@@ -141,7 +144,7 @@ export function createHandleMermaidClick(mermaidState: MermaidPreviewState) {
}
// Otherwise, open preview when clicking on the mermaid diagram itself
const mermaidEl = target.closest('.mermaid');
const mermaidEl = target.closest(`.${MERMAID_BLOCK_CLASS}`);
if (!mermaidEl) return;
const svg = mermaidEl.querySelector('svg');
@@ -13,7 +13,14 @@
import type { Plugin } from 'unified';
import type { Root, Element, ElementContent } from 'hast';
import { visit } from 'unist-util-visit';
import { MERMAID_WRAPPER_CLASS, MERMAID_SCROLL_CONTAINER_CLASS } from '$lib/constants';
import {
MERMAID_WRAPPER_CLASS,
MERMAID_SCROLL_CONTAINER_CLASS,
MERMAID_BLOCK_CLASS,
MERMAID_LANGUAGE,
MERMAID_SYNTAX_ATTR,
MERMAID_ID_ATTR
} from '$lib/constants';
import {
createBlockHeader,
createCopyButton,
@@ -43,11 +50,13 @@ export const rehypeEnhanceMermaidBlocks: Plugin<[], Root> = () => {
const className = node.properties?.className;
if (!Array.isArray(className)) return;
const isMermaid = className.some((cls) => typeof cls === 'string' && cls === 'mermaid');
const isMermaid = className.some(
(cls) => typeof cls === 'string' && cls === MERMAID_BLOCK_CLASS
);
if (!isMermaid) return;
const mermaidId = generateBlockId('mermaid', 'idxMermaidBlock');
const mermaidId = generateBlockId(MERMAID_LANGUAGE, 'idxMermaidBlock');
// Extract the mermaid syntax (text content of the pre element)
const diagramText = node.children
@@ -60,22 +69,22 @@ export const rehypeEnhanceMermaidBlocks: Plugin<[], Root> = () => {
// Store the mermaid syntax in data attribute for copy functionality
node.properties = {
...node.properties,
'data-mermaid-syntax': diagramText,
'data-mermaid-id': mermaidId
[MERMAID_SYNTAX_ATTR]: diagramText,
[MERMAID_ID_ATTR]: mermaidId
};
const actions = [
createCopyButton(mermaidId, 'data-mermaid-id', 'Copy mermaid syntax'),
createPreviewButton(mermaidId, 'data-mermaid-id', 'Preview diagram')
createCopyButton(mermaidId, MERMAID_ID_ATTR, 'Copy mermaid syntax'),
createPreviewButton(mermaidId, MERMAID_ID_ATTR, 'Preview diagram')
];
const header = createBlockHeader('mermaid', mermaidId, 'data-mermaid-id', actions);
const header = createBlockHeader(MERMAID_LANGUAGE, mermaidId, MERMAID_ID_ATTR, actions);
const wrapper = createWrapper(
header,
node,
MERMAID_WRAPPER_CLASS,
MERMAID_SCROLL_CONTAINER_CLASS,
{ 'data-mermaid-id': mermaidId }
{ [MERMAID_ID_ATTR]: mermaidId }
);
// Replace pre with wrapper in parent
@@ -0,0 +1,80 @@
/**
* Rehype plugin to enhance svg blocks with wrapper, header, and action buttons.
*
* Wraps <pre class="svg-block"> elements with a container that includes:
* - Language label ("svg")
* - Copy button (copies svg source to clipboard)
* - Preview button (opens fullscreen preview dialog)
*
* Operates directly on the HAST tree and reuses the shared code-block builders.
*/
import type { Plugin } from 'unified';
import type { Root, Element, ElementContent } from 'hast';
import { visit } from 'unist-util-visit';
import {
SVG_WRAPPER_CLASS,
SVG_SCROLL_CONTAINER_CLASS,
SVG_BLOCK_CLASS,
SVG_LANGUAGE,
SVG_SOURCE_ATTR,
SVG_ID_ATTR
} from '$lib/constants';
import {
createBlockHeader,
createCopyButton,
createPreviewButton,
createWrapper,
generateBlockId
} from './code-block-utils';
declare global {
interface Window {
idxSvgBlock?: number;
}
}
export const rehypeEnhanceSvgBlocks: Plugin<[], Root> = () => {
return (tree: Root) => {
visit(tree, 'element', (node: Element, index, parent) => {
if (node.tagName !== 'pre' || !parent || index === undefined) return;
const className = node.properties?.className;
if (!Array.isArray(className)) return;
const isSvg = className.some((cls) => typeof cls === 'string' && cls === SVG_BLOCK_CLASS);
if (!isSvg) return;
const svgId = generateBlockId(SVG_LANGUAGE, 'idxSvgBlock');
// Extract the svg source (text content of the pre element)
const svgSource = node.children
.map((child) => {
if (child.type === 'text') return child.value;
return '';
})
.join('');
// Store the svg source in data attribute for copy and render
node.properties = {
...node.properties,
[SVG_SOURCE_ATTR]: svgSource,
[SVG_ID_ATTR]: svgId
};
const actions = [
createCopyButton(svgId, SVG_ID_ATTR, 'Copy svg source'),
createPreviewButton(svgId, SVG_ID_ATTR, 'Preview svg')
];
const header = createBlockHeader(SVG_LANGUAGE, svgId, SVG_ID_ATTR, actions);
const wrapper = createWrapper(header, node, SVG_WRAPPER_CLASS, SVG_SCROLL_CONTAINER_CLASS, {
[SVG_ID_ATTR]: svgId
});
// Replace pre with wrapper in parent
(parent.children as ElementContent[])[index] = wrapper;
});
};
};
@@ -1,67 +1,7 @@
import type { Plugin } from 'unified';
import type { Root, Element, ElementContent, Text } from 'hast';
import { visit } from 'unist-util-visit';
import { createPreTransform } from './pre-transform';
import { MERMAID_BLOCK_CLASS, MERMAID_LANGUAGE } from '$lib/constants';
/**
* Recursively extracts all text content from a HAST node.
* Handles nested elements (e.g., span wrappers from syntax highlighting).
* Converts mermaid code blocks to <pre class="mermaid"> for client-side rendering.
*/
function extractText(node: ElementContent): string {
if (node.type === 'text') return node.value;
if (node.type === 'element') {
return (node.children ?? []).map(extractText).join('');
}
return '';
}
/**
* Rehype plugin to convert mermaid code blocks to <pre class="mermaid"> elements.
*
* Transforms:
* <pre><code class="language-mermaid">graph TD; A-->B</code></pre>
* into:
* <pre class="mermaid">graph TD; A-->B</pre>
*
* The mermaid library renders these client-side via mermaid.run().
*
* Must run BEFORE rehypeEnhanceCodeBlocks so mermaid blocks are not wrapped
* with code block headers/buttons (they have no <code> child, so they're skipped).
*/
export const rehypeMermaidPre: Plugin<[], Root> = () => {
return (tree: Root) => {
visit(tree, 'element', (node: Element, index, parent) => {
if (node.tagName !== 'pre' || !parent || index === undefined) return;
const codeElement = node.children.find(
(child): child is Element => child.type === 'element' && child.tagName === 'code'
);
if (!codeElement) return;
const className = codeElement.properties?.className;
if (!Array.isArray(className)) return;
const isMermaid = className.some(
(cls) => typeof cls === 'string' && cls === 'language-mermaid'
);
if (!isMermaid) return;
// Recursively extract text to handle nested spans from syntax highlighting
const diagramText = codeElement.children.map(extractText).join('').trim();
if (!diagramText) return;
const mermaidPre: Element = {
type: 'element',
tagName: 'pre',
properties: {
className: ['mermaid']
},
children: [{ type: 'text', value: diagramText } as Text]
};
(parent.children as ElementContent[])[index] = mermaidPre;
});
};
};
export const rehypeMermaidPre = createPreTransform(MERMAID_LANGUAGE, MERMAID_BLOCK_CLASS);
@@ -0,0 +1,79 @@
import type { Plugin } from 'unified';
import type { Root, Element, ElementContent, Text } from 'hast';
import { visit } from 'unist-util-visit';
/**
* Recursively extracts all text content from a HAST node.
* Handles nested elements (e.g., span wrappers from syntax highlighting).
*/
function extractText(node: ElementContent): string {
if (node.type === 'text') return node.value;
if (node.type === 'element') {
return (node.children ?? []).map(extractText).join('');
}
return '';
}
/**
* Builds a rehype plugin that converts <pre><code class="language-{language}">
* blocks into <pre class="{targetClass}"> elements carrying the raw text.
*
* Accepts one or more source languages, and an optional contentGuard that
* receives the trimmed text and decides whether the block qualifies. The guard
* lets a shared fence language be claimed only when its content matches, e.g.
* an xml block is converted to svg only when it starts with <svg.
*
* The result has no <code> child, so rehypeEnhanceCodeBlocks skips it. Rendering
* happens client-side, so no markup is injected at this stage. Must run BEFORE
* rehypeEnhanceCodeBlocks.
*/
export function createPreTransform(
languages: string | string[],
targetClass: string,
contentGuard?: (text: string) => boolean
): Plugin<[], Root> {
const codeClasses = (Array.isArray(languages) ? languages : [languages]).map(
(language) => `language-${language}`
);
return () => {
return (tree: Root) => {
visit(tree, 'element', (node: Element, index, parent) => {
if (node.tagName !== 'pre' || !parent || index === undefined) return;
const codeElement = node.children.find(
(child): child is Element => child.type === 'element' && child.tagName === 'code'
);
if (!codeElement) return;
const className = codeElement.properties?.className;
if (!Array.isArray(className)) return;
const matches = className.some(
(cls) => typeof cls === 'string' && codeClasses.includes(cls)
);
if (!matches) return;
// Recursively extract text to handle nested spans from syntax highlighting
const text = codeElement.children.map(extractText).join('').trim();
if (!text) return;
if (contentGuard && !contentGuard(text)) return;
const pre: Element = {
type: 'element',
tagName: 'pre',
properties: {
className: [targetClass]
},
children: [{ type: 'text', value: text } as Text]
};
(parent.children as ElementContent[])[index] = pre;
});
};
};
}
@@ -0,0 +1,13 @@
import { createPreTransform } from './pre-transform';
import { SVG_BLOCK_CLASS, SVG_LANGUAGE, XML_LANGUAGE, SVG_TAG_PREFIX } from '$lib/constants';
/**
* Converts svg code blocks to <pre class="svg-block"> for client-side rendering.
* Also claims xml blocks whose content starts with <svg, since models often emit
* svg inside an xml fence.
*/
export const rehypeSvgPre = createPreTransform(
[SVG_LANGUAGE, XML_LANGUAGE],
SVG_BLOCK_CLASS,
(text) => text.startsWith(SVG_TAG_PREFIX)
);
@@ -1,5 +1,7 @@
<script lang="ts">
import MermaidPreviewControls from './MermaidPreviewControls.svelte';
import { mountSvgShadow } from '$lib/utils/svg-shadow';
import { SVG_DIALOG_SHADOW_STYLE } from '$lib/constants';
interface Props {
svgHtml: string;
@@ -7,6 +9,13 @@
let { svgHtml }: Props = $props();
let svgHost = $state<HTMLDivElement | null>(null);
// Re-mount on every svgHtml change so a live streaming svg keeps rendering while zoomed
$effect(() => {
if (svgHost) mountSvgShadow(svgHost, svgHtml, SVG_DIALOG_SHADOW_STYLE);
});
// Zoom and pan state
let scale = $state(1);
let translateX = $state(0);
@@ -99,8 +108,7 @@
onpointerup={handlePointerUp}
onpointerleave={handlePointerUp}
>
<!-- eslint-disable-next-line no-at-html-tags -->
{@html svgHtml}
<div bind:this={svgHost}></div>
</div>
<MermaidPreviewControls
@@ -111,16 +119,3 @@
onResetView={resetView}
/>
</div>
<style lang="postcss" scoped>
/* Styles for SVGs rendered via {@html} — no Tailwind class can target child elements */
.mermaid-preview-diagram :global(svg) {
min-height: min(50vh, 12rem);
min-width: min(80vw, 20rem);
max-width: none !important;
max-height: none !important;
height: auto !important;
width: auto !important;
display: block;
}
</style>
@@ -41,7 +41,7 @@
data-slot="sidebar-wrapper"
style="--sidebar-width: {sidebar.sidebarWidth}; --sidebar-min-width: {SIDEBAR_MIN_WIDTH}; --sidebar-max-width: {SIDEBAR_MAX_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON}; {style}"
class={cn(
'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar',
'group/sidebar-wrapper flex flex-col h-dvh w-full has-data-[variant=inset]:bg-sidebar',
className
)}
bind:this={ref}
+2
View File
@@ -1 +1,3 @@
export const MEGAPIXELS_TO_PIXELS = 1_000_000;
export const HEIC_JPEG_QUALITY = 0.85;
+1
View File
@@ -29,6 +29,7 @@ export * from './latex-protection';
export * from './literal-html';
export * from './markdown';
export * from './mermaid-blocks';
export * from './svg-blocks';
export * from './max-bundle-size';
export * from './mcp';
export * from './mcp-form';
@@ -1,2 +1,9 @@
export const MERMAID_WRAPPER_CLASS = 'mermaid-block-wrapper';
export const MERMAID_SCROLL_CONTAINER_CLASS = 'mermaid-scroll-container';
export const MERMAID_BLOCK_CLASS = 'mermaid';
export const MERMAID_LANGUAGE = 'mermaid';
export const MERMAID_SYNTAX_ATTR = 'data-mermaid-syntax';
export const MERMAID_ID_ATTR = 'data-mermaid-id';
export const MERMAID_RENDERED_ATTR = 'data-mermaid-rendered';
@@ -33,6 +33,7 @@ export const SETTINGS_KEYS = {
SHOW_MODEL_TAGS: 'showModelTags',
SHOW_BUILD_VERSION: 'showBuildVersion',
SHOW_SYSTEM_MESSAGE: 'showSystemMessage',
RENDER_THINKING_AS_MARKDOWN: 'renderThinkingAsMarkdown',
// Sampling
TEMPERATURE: 'temperature',
DYNATEMP_RANGE: 'dynatemp_range',
@@ -282,6 +282,18 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.RENDER_THINKING_AS_MARKDOWN,
label: 'Render thinking as Markdown',
help: 'Render the reasoning/thinking block content as formatted Markdown instead of plain text.',
defaultValue: true,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY,
sync: {
serverKey: SETTINGS_KEYS.RENDER_THINKING_AS_MARKDOWN,
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS,
label: 'Use full height code blocks',
@@ -63,6 +63,10 @@ export const IMAGE_FILE_TYPES = {
[FileTypeImage.SVG]: {
extensions: [FileExtensionImage.SVG],
mimeTypes: [MimeTypeImage.SVG]
},
[FileTypeImage.HEIC]: {
extensions: [FileExtensionImage.HEIC, FileExtensionImage.HEIF],
mimeTypes: [MimeTypeImage.HEIC, MimeTypeImage.HEIF]
}
} as const;
+49
View File
@@ -0,0 +1,49 @@
export const SVG_WRAPPER_CLASS = 'svg-block-wrapper';
export const SVG_SCROLL_CONTAINER_CLASS = 'svg-scroll-container';
export const SVG_BLOCK_CLASS = 'svg-block';
export const SVG_LANGUAGE = 'svg';
export const XML_LANGUAGE = 'xml';
export const SVG_TAG_PREFIX = '<svg';
export const SVG_SOURCE_ATTR = 'data-svg-source';
export const SVG_ID_ATTR = 'data-svg-id';
export const SVG_RENDERED_ATTR = 'data-svg-rendered';
/**
* Hard size ceiling for a single inline svg block.
* Above this the source is left as raw text instead of being rendered.
*/
export const SVG_MAX_BYTES = 256 * 1024;
/**
* DOMPurify config for untrusted svg coming from model output.
*
* foreignObject and script stay forbidden unconditionally, they are the only
* inline svg vectors that execute arbitrary html or js. Everything else is
* allowed for maximum rendering compatibility: href and xlink:href stay so
* use, image, a and animateMotion work, and DOMPurify still neutralizes
* javascript: and data: uri schemes natively. External resource refs are
* allowed by design on a local first tool, the user browser fetches them.
*
* The sanitized svg is always mounted inside a shadow root (see svg-shadow),
* so an author <style> stays scoped to that root and can not reach the page.
*/
export const SVG_SANITIZE_CONFIG = {
USE_PROFILES: { svg: true, svgFilters: true },
FORBID_TAGS: ['foreignObject', 'script']
};
/**
* Shadow root style for an inline svg block. Mirrors the centered, padded
* sizing the light dom used before the svg moved behind a shadow boundary.
*/
export const SVG_INLINE_SHADOW_STYLE =
':host{display:block;width:100%;text-align:center}svg{display:block;margin:0 auto;width:auto;height:auto;max-width:100%;max-height:70vh;min-height:8rem;padding:3rem 1rem}';
/**
* Shadow root style for the zoom dialog svg. Lets the svg grow past its
* intrinsic size so pan and zoom have room to work.
*/
export const SVG_DIALOG_SHADOW_STYLE =
':host{display:inline-block}svg{min-height:min(50vh,12rem);min-width:min(80vw,20rem);max-width:none;max-height:none;height:auto;width:auto;display:block}';
+9 -3
View File
@@ -25,7 +25,9 @@ export enum FileTypeImage {
PNG = 'png',
GIF = 'gif',
WEBP = 'webp',
SVG = 'svg'
SVG = 'svg',
HEIC = 'heic',
HEIF = 'heif'
}
export enum FileTypeAudio {
@@ -90,7 +92,9 @@ export enum FileExtensionImage {
PNG = '.png',
GIF = '.gif',
WEBP = '.webp',
SVG = '.svg'
SVG = '.svg',
HEIC = '.heic',
HEIF = '.heif'
}
export enum FileExtensionAudio {
@@ -205,7 +209,9 @@ export enum MimeTypeImage {
WEBP = 'image/webp',
SVG = 'image/svg+xml',
ICO = 'image/x-icon',
ICO_MICROSOFT = 'image/vnd.microsoft.icon'
ICO_MICROSOFT = 'image/vnd.microsoft.icon',
HEIC = 'image/heic',
HEIF = 'image/heif'
}
export enum MimeTypeText {
+2
View File
@@ -53,6 +53,8 @@ export function usePwa() {
// This comparison detects server upgrades for non-PWA users.
$effect(() => {
if (!browser) return;
// PWA pages update via the service worker path; the storage check is the non-PWA fallback only
if (navigator.serviceWorker?.controller) return;
const currentVersion = versionStore.value;
if (!currentVersion) return;
+4
View File
@@ -30,6 +30,8 @@ export function getFileTypeCategory(mimeType: string): FileTypeCategory | null {
case MimeTypeImage.GIF:
case MimeTypeImage.WEBP:
case MimeTypeImage.SVG:
case MimeTypeImage.HEIC:
case MimeTypeImage.HEIF:
return FileTypeCategory.IMAGE;
// Audio
@@ -118,6 +120,8 @@ export function getFileTypeCategoryByExtension(filename: string): FileTypeCatego
case FileExtensionImage.GIF:
case FileExtensionImage.WEBP:
case FileExtensionImage.SVG:
case FileExtensionImage.HEIC:
case FileExtensionImage.HEIF:
return FileTypeCategory.IMAGE;
// Audio
+56
View File
@@ -0,0 +1,56 @@
import { MimeTypeImage } from '$lib/enums';
import { HEIC_JPEG_QUALITY } from '$lib/constants/image-size';
// heic requires a relatively large decoder, in order to reduce primary bundle size
// we lazily load this decoder from a CDN when needed, and cache it for future conversions
const HEIC_TO_CDN_URL = 'https://cdn.jsdelivr.net/npm/heic-to@1.5.2/dist/heic-to.js';
interface HeicToModule {
heicTo(args: { blob: Blob; type: string; quality?: number }): Promise<Blob>;
}
let modulePromise: Promise<HeicToModule> | null = null;
/**
* Lazily load the heic-to decoder from the CDN and cache it
* @returns Promise resolving to the heic-to module
*/
function getHeicTo(): Promise<HeicToModule> {
if (!modulePromise) {
modulePromise = import(/* @vite-ignore */ HEIC_TO_CDN_URL) as Promise<HeicToModule>;
}
return modulePromise;
}
/**
* Convert a HEIC/HEIF file to a compressed JPEG data URL
* @param file - The HEIC/HEIF file to convert
* @returns Promise resolving to JPEG data URL
*/
export async function heicFileToJpegDataURL(file: File | Blob): Promise<string> {
const { heicTo } = await getHeicTo();
const jpegBlob = await heicTo({
blob: file,
type: MimeTypeImage.JPEG,
quality: HEIC_JPEG_QUALITY
});
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(jpegBlob);
});
}
/**
* Check if a MIME type represents a HEIC/HEIF image
* @param mimeType - The MIME type to check
* @returns True if the MIME type is image/heic or image/heif
*/
export function isHeicMimeType(mimeType: string): boolean {
const normalized = mimeType.trim().toLowerCase();
return normalized === MimeTypeImage.HEIC || normalized === MimeTypeImage.HEIF;
}
@@ -1,5 +1,6 @@
import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
import { heicFileToJpegDataURL, isHeicMimeType } from './heic-to-jpeg';
import { FileTypeCategory } from '$lib/enums';
import { SETTINGS_KEYS } from '$lib/constants';
import { modelsStore } from '$lib/stores/models.svelte';
@@ -68,7 +69,7 @@ export async function processFilesToChatUploaded(
if (getFileTypeCategory(file.type) === FileTypeCategory.IMAGE) {
let preview = await readFileAsDataURL(file);
// Normalize SVG and WebP to PNG in previews
// Normalize SVG and WebP to PNG, and HEIC to compressed JPEG, in previews
if (isSvgMimeType(file.type)) {
try {
preview = await svgBase64UrlToPngDataURL(preview);
@@ -81,6 +82,13 @@ export async function processFilesToChatUploaded(
} catch (err) {
console.error('Failed to convert WebP to PNG:', err);
}
} else if (isHeicMimeType(file.type)) {
try {
preview = await heicFileToJpegDataURL(file);
} catch (err) {
console.error('Failed to convert HEIC to PNG:', err);
continue;
}
}
results.push({ ...base, preview });
+22
View File
@@ -0,0 +1,22 @@
import DOMPurify from 'dompurify';
import { SVG_MAX_BYTES, SVG_SANITIZE_CONFIG, SVG_TAG_PREFIX } from '$lib/constants';
/**
* Sanitizes a raw svg string for safe inline rendering.
* Returns the cleaned svg markup, or an empty string when the input is not a
* usable svg, exceeds the size ceiling, or sanitizes to nothing. An empty
* return tells the caller to keep the raw code block instead of rendering.
*/
export function sanitizeSvg(source: string): string {
const trimmed = source.trim();
if (!trimmed || trimmed.length > SVG_MAX_BYTES) return '';
if (!trimmed.startsWith(SVG_TAG_PREFIX)) return '';
const clean = DOMPurify.sanitize(trimmed, SVG_SANITIZE_CONFIG) as unknown as string;
if (!clean || !clean.includes(SVG_TAG_PREFIX)) return '';
return clean;
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Mounts svg markup inside an open shadow root on the host element.
* The shadow boundary scopes the svg <style> and its animations to the host,
* so model authored css can not reach the surrounding page. The caller passes
* markup that is already sanitized, this only isolates and sizes it.
*/
export function mountSvgShadow(host: HTMLElement, markup: string, style: string): void {
const root = host.shadowRoot ?? host.attachShadow({ mode: 'open' });
root.innerHTML = markup ? `<style>${style}</style>${markup}` : '';
}
+1 -1
View File
@@ -312,7 +312,7 @@
/>
<Sidebar.Provider bind:open={sidebarOpen}>
<div class="flex h-screen w-full">
<div class="flex h-full w-full grow">
<Sidebar.Root variant="floating" class="h-full"
><SidebarNavigation bind:this={chatSidebar} /></Sidebar.Root
>