mirror of
https://github.com/LostRuins/koboldcpp.git
synced 2026-09-19 09:15:18 +02:00
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:
Generated
+4
-3
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
+18
-9
@@ -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
|
||||
|
||||
+80
@@ -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;
|
||||
});
|
||||
};
|
||||
};
|
||||
+4
-64
@@ -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);
|
||||
|
||||
+79
@@ -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}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export const MEGAPIXELS_TO_PIXELS = 1_000_000;
|
||||
|
||||
export const HEIC_JPEG_QUALITY = 0.85;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}';
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}` : '';
|
||||
}
|
||||
@@ -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
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user