]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
webui: Improve copy to clipboard with text attachments (#17969)
authorAleksander Grygier <redacted>
Tue, 16 Dec 2025 06:38:46 +0000 (07:38 +0100)
committerGitHub <redacted>
Tue, 16 Dec 2025 06:38:46 +0000 (07:38 +0100)
* feat: Create copy/paste user message including "pasted text" attachments

* chore: update webui build output

* chore: update webui static output

* fix: UI issues

* chore: update webui static output

* fix: Decode HTML entities using `DOMParser`

* chore: update webui build output

* chore: update webui static output

19 files changed:
tools/server/public/index.html.gz
tools/server/webui/README.md
tools/server/webui/package.json
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte
tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte
tools/server/webui/src/lib/components/app/misc/SyntaxHighlightedCode.svelte
tools/server/webui/src/lib/constants/settings-config.ts
tools/server/webui/src/lib/utils/clipboard.ts [new file with mode: 0644]
tools/server/webui/src/lib/utils/copy.ts [deleted file]
tools/server/webui/src/lib/utils/index.ts
tools/server/webui/src/lib/utils/latex-protection.test.ts [deleted file]
tools/server/webui/src/lib/utils/model-names.test.ts [deleted file]
tools/server/webui/tests/server/demo.spec.ts [deleted file]
tools/server/webui/tests/unit/clipboard.test.ts [new file with mode: 0644]
tools/server/webui/tests/unit/latex-protection.test.ts [new file with mode: 0644]
tools/server/webui/tests/unit/model-names.test.ts [new file with mode: 0644]
tools/server/webui/vite.config.ts

index a58285a1a2c0103fafc517f2e851913dd774d277..2ff90e800a9cdf1d5715382876a4fe760411cfc1 100644 (file)
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
index d995271fc4b8f806c6da56dc062d7a0df2332eb5..98b01fdcd7fd9ce23eea3b174d257198d51a749e 100644 (file)
@@ -619,11 +619,12 @@ flowchart TB
 
 ### Test Types
 
-| Type          | Tool               | Location                         | Command             |
-| ------------- | ------------------ | -------------------------------- | ------------------- |
-| **E2E**       | Playwright         | `tests/e2e/`                     | `npm run test:e2e`  |
-| **Unit**      | Vitest             | `tests/client/`, `tests/server/` | `npm run test:unit` |
-| **UI/Visual** | Storybook + Vitest | `tests/stories/`                 | `npm run test:ui`   |
+| Type          | Tool               | Location         | Command             |
+| ------------- | ------------------ | ---------------- | ------------------- |
+| **Unit**      | Vitest             | `tests/unit/`    | `npm run test:unit` |
+| **UI/Visual** | Storybook + Vitest | `tests/stories/` | `npm run test:ui`   |
+| **E2E**       | Playwright         | `tests/e2e/`     | `npm run test:e2e`  |
+| **Client**    | Vitest             | `tests/client/`. | `npm run test:unit` |
 
 ### Running Tests
 
index c20ab3cfde0e914c38bb4fcf83b5ea42dc19224f..1c970ae7a89727a2ad9d0c4b5d3e8f778e36f231 100644 (file)
                "reset": "rm -rf .svelte-kit node_modules",
                "format": "prettier --write .",
                "lint": "prettier --check . && eslint .",
-               "test": "npm run test:ui -- --run && npm run test:client -- --run && npm run test:server -- --run && npm run test:e2e",
+               "test": "npm run test:ui -- --run && npm run test:client -- --run && npm run test:unit -- --run && npm run test:e2e",
                "test:e2e": "playwright test",
                "test:client": "vitest --project=client",
-               "test:server": "vitest --project=server",
+               "test:unit": "vitest --project=unit",
                "test:ui": "vitest --project=ui",
-               "test:unit": "vitest",
                "storybook": "storybook dev -p 6006",
                "build-storybook": "storybook build",
                "cleanup": "rm -rf .svelte-kit build node_modules test-results"
index b5fe3fa9c44836bec23c44f9bb9a79dd9f14e6cd..0b0bf52ad98be7adc409cc755aa16cde1f40acc2 100644 (file)
                                </div>
                        {/if}
                {:else if (isText || (isPdf && pdfViewMode === 'text')) && displayTextContent}
-                       <SyntaxHighlightedCode code={displayTextContent} {language} maxWidth="69rem" />
+                       <SyntaxHighlightedCode code={displayTextContent} {language} maxWidth="calc(69rem - 2rem)" />
                {:else if isAudio}
                        <div class="flex items-center justify-center p-8">
                                <div class="w-full max-w-md text-center">
index 78cc1c47daa5e6b9690b6c767e22536955003865..3ad14ed3ab08dd778193e192a7a102be8c5a0b87 100644 (file)
@@ -24,7 +24,7 @@
                MimeTypeImage,
                MimeTypeText
        } from '$lib/enums';
-       import { isIMEComposing } from '$lib/utils';
+       import { isIMEComposing, parseClipboardContent } from '$lib/utils';
        import {
                AudioRecorder,
                convertToWav,
 
                        if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return;
 
-                       // Check if model is selected first
                        if (!checkModelSelected()) return;
 
                        const messageToSend = message.trim();
 
                const text = event.clipboardData.getData(MimeTypeText.PLAIN);
 
+               if (text.startsWith('"')) {
+                       const parsed = parseClipboardContent(text);
+
+                       if (parsed.textAttachments.length > 0) {
+                               event.preventDefault();
+
+                               message = parsed.message;
+
+                               const attachmentFiles = parsed.textAttachments.map(
+                                       (att) =>
+                                               new File([att.content], att.name, {
+                                                       type: MimeTypeText.PLAIN
+                                               })
+                               );
+
+                               onFileUpload?.(attachmentFiles);
+
+                               setTimeout(() => {
+                                       textareaRef?.focus();
+                               }, 10);
+
+                               return;
+                       }
+               }
+
                if (
                        text.length > 0 &&
                        pasteLongTextToFileLength > 0 &&
index 96ed56a7758aa6f09bc3ab5cfdbc75642b842205..0969a937ed24147b408dec2645925fb29d418501 100644 (file)
@@ -1,6 +1,7 @@
 <script lang="ts">
        import { chatStore } from '$lib/stores/chat.svelte';
-       import { copyToClipboard, isIMEComposing } from '$lib/utils';
+       import { config } from '$lib/stores/settings.svelte';
+       import { copyToClipboard, isIMEComposing, formatMessageForClipboard } from '$lib/utils';
        import ChatMessageAssistant from './ChatMessageAssistant.svelte';
        import ChatMessageUser from './ChatMessageUser.svelte';
        import ChatMessageSystem from './ChatMessageSystem.svelte';
@@ -87,7 +88,9 @@
        }
 
        async function handleCopy() {
-               await copyToClipboard(message.content, 'Message copied to clipboard');
+               const asPlainText = Boolean(config().copyTextAttachmentsAsPlainText);
+               const clipboardContent = formatMessageForClipboard(message.content, message.extra, asPlainText);
+               await copyToClipboard(clipboardContent, 'Message copied to clipboard');
                onCopy?.(message);
        }
 
index 6dfae599507862bb355e9a3b05e8db4f989e6fc3..4ec9b478fd2ed40114ff2a3dd4715368bef4752b 100644 (file)
                                        label: 'Paste long text to file length',
                                        type: 'input'
                                },
+                               {
+                                       key: 'copyTextAttachmentsAsPlainText',
+                                       label: 'Copy text attachments as plain text',
+                                       type: 'checkbox'
+                               },
                                {
                                        key: 'enableContinueGeneration',
                                        label: 'Enable "Continue" button',
index f36a9a20b9a3b57968cc576033204ab88653a91a..bc42f9dd1e84d9123b13beab464ff5b3057c8346 100644 (file)
 
 <div
        class="code-preview-wrapper overflow-auto rounded-lg border border-border bg-muted {className}"
-       style="max-height: {maxHeight};"
+       style="max-height: {maxHeight}; max-width: {maxWidth};"
 >
-       <pre class="m-0 overflow-x-auto p-4 max-w-[{maxWidth}]"><code class="hljs text-sm leading-relaxed"
+       <!-- Needs to be formatted as single line for proper rendering -->
+       <pre class="m-0 overflow-x-auto p-4"><code class="hljs text-sm leading-relaxed"
                        >{@html highlightedHtml}</code
                ></pre>
 </div>
index 8024be92e19a620255e23fed81864ddbd8479ce5..f9584d01d72dd233a2bb50fbb5c532a12eb85033 100644 (file)
@@ -12,6 +12,7 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
        showMessageStats: true,
        askForTitleConfirmation: false,
        pasteLongTextToFileLen: 2500,
+       copyTextAttachmentsAsPlainText: false,
        pdfAsImage: false,
        disableAutoScroll: false,
        renderUserContentAsMarkdown: false,
@@ -52,6 +53,8 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
                'Choose the color theme for the interface. You can choose between System (follows your device settings), Light, or Dark.',
        pasteLongTextToFileLen:
                'On pasting long text, it will be converted to a file. You can control the file length by setting the value of this parameter. Value 0 means disable.',
+       copyTextAttachmentsAsPlainText:
+               'When copying a message with text attachments, combine them into a single plain text string instead of a special format that can be pasted back as attachments.',
        samplers:
                'The order at which samplers are applied, in simplified way. Default is "top_k;typ_p;top_p;min_p;temperature": top_k->typ_p->top_p->min_p->temperature',
        temperature:
diff --git a/tools/server/webui/src/lib/utils/clipboard.ts b/tools/server/webui/src/lib/utils/clipboard.ts
new file mode 100644 (file)
index 0000000..91e8ea7
--- /dev/null
@@ -0,0 +1,262 @@
+import { toast } from 'svelte-sonner';
+import { AttachmentType } from '$lib/enums';
+import type {
+       DatabaseMessageExtra,
+       DatabaseMessageExtraTextFile,
+       DatabaseMessageExtraLegacyContext
+} from '$lib/types/database';
+
+/**
+ * Copy text to clipboard with toast notification
+ * Uses modern clipboard API when available, falls back to legacy method for non-secure contexts
+ * @param text - Text to copy to clipboard
+ * @param successMessage - Custom success message (optional)
+ * @param errorMessage - Custom error message (optional)
+ * @returns Promise<boolean> - True if successful, false otherwise
+ */
+export async function copyToClipboard(
+       text: string,
+       successMessage = 'Copied to clipboard',
+       errorMessage = 'Failed to copy to clipboard'
+): Promise<boolean> {
+       try {
+               // Try modern clipboard API first (secure contexts only)
+               if (navigator.clipboard && navigator.clipboard.writeText) {
+                       await navigator.clipboard.writeText(text);
+                       toast.success(successMessage);
+                       return true;
+               }
+
+               // Fallback for non-secure contexts
+               const textArea = document.createElement('textarea');
+               textArea.value = text;
+               textArea.style.position = 'fixed';
+               textArea.style.left = '-999999px';
+               textArea.style.top = '-999999px';
+               document.body.appendChild(textArea);
+               textArea.focus();
+               textArea.select();
+
+               const successful = document.execCommand('copy');
+               document.body.removeChild(textArea);
+
+               if (successful) {
+                       toast.success(successMessage);
+                       return true;
+               } else {
+                       throw new Error('execCommand failed');
+               }
+       } catch (error) {
+               console.error('Failed to copy to clipboard:', error);
+               toast.error(errorMessage);
+               return false;
+       }
+}
+
+/**
+ * Copy code with HTML entity decoding and toast notification
+ * @param rawCode - Raw code string that may contain HTML entities
+ * @param successMessage - Custom success message (optional)
+ * @param errorMessage - Custom error message (optional)
+ * @returns Promise<boolean> - True if successful, false otherwise
+ */
+export async function copyCodeToClipboard(
+       rawCode: string,
+       successMessage = 'Code copied to clipboard',
+       errorMessage = 'Failed to copy code'
+): Promise<boolean> {
+       const doc = new DOMParser().parseFromString(rawCode, 'text/html');
+       const decodedCode = doc.body.textContent ?? rawCode;
+
+       return copyToClipboard(decodedCode, successMessage, errorMessage);
+}
+
+/**
+ * Format for text attachments when copied to clipboard
+ */
+export interface ClipboardTextAttachment {
+       type: typeof AttachmentType.TEXT;
+       name: string;
+       content: string;
+}
+
+/**
+ * Parsed result from clipboard content
+ */
+export interface ParsedClipboardContent {
+       message: string;
+       textAttachments: ClipboardTextAttachment[];
+}
+
+/**
+ * Formats a message with text attachments for clipboard copying.
+ *
+ * Default format (asPlainText = false):
+ * ```
+ * "Text message content"
+ * [
+ *   {"type":"TEXT","name":"filename.txt","content":"..."},
+ *   {"type":"TEXT","name":"another.txt","content":"..."}
+ * ]
+ * ```
+ *
+ * Plain text format (asPlainText = true):
+ * ```
+ * Text message content
+ *
+ * file content here
+ *
+ * another file content
+ * ```
+ *
+ * @param content - The message text content
+ * @param extras - Optional array of message attachments
+ * @param asPlainText - If true, format as plain text without JSON structure
+ * @returns Formatted string for clipboard
+ */
+export function formatMessageForClipboard(
+       content: string,
+       extras?: DatabaseMessageExtra[],
+       asPlainText: boolean = false
+): string {
+       // Filter only text attachments (TEXT type and legacy CONTEXT type)
+       const textAttachments =
+               extras?.filter(
+                       (extra): extra is DatabaseMessageExtraTextFile | DatabaseMessageExtraLegacyContext =>
+                               extra.type === AttachmentType.TEXT || extra.type === AttachmentType.LEGACY_CONTEXT
+               ) ?? [];
+
+       if (textAttachments.length === 0) {
+               return content;
+       }
+
+       if (asPlainText) {
+               const parts = [content];
+               for (const att of textAttachments) {
+                       parts.push(att.content);
+               }
+               return parts.join('\n\n');
+       }
+
+       const clipboardAttachments: ClipboardTextAttachment[] = textAttachments.map((att) => ({
+               type: AttachmentType.TEXT,
+               name: att.name,
+               content: att.content
+       }));
+
+       return `${JSON.stringify(content)}\n${JSON.stringify(clipboardAttachments, null, 2)}`;
+}
+
+/**
+ * Parses clipboard content to extract message and text attachments.
+ * Supports both plain text and the special format with attachments.
+ *
+ * @param clipboardText - Raw text from clipboard
+ * @returns Parsed content with message and attachments
+ */
+export function parseClipboardContent(clipboardText: string): ParsedClipboardContent {
+       const defaultResult: ParsedClipboardContent = {
+               message: clipboardText,
+               textAttachments: []
+       };
+
+       if (!clipboardText.startsWith('"')) {
+               return defaultResult;
+       }
+
+       try {
+               let stringEndIndex = -1;
+               let escaped = false;
+
+               for (let i = 1; i < clipboardText.length; i++) {
+                       const char = clipboardText[i];
+
+                       if (escaped) {
+                               escaped = false;
+                               continue;
+                       }
+
+                       if (char === '\\') {
+                               escaped = true;
+                               continue;
+                       }
+
+                       if (char === '"') {
+                               stringEndIndex = i;
+                               break;
+                       }
+               }
+
+               if (stringEndIndex === -1) {
+                       return defaultResult;
+               }
+
+               const jsonStringPart = clipboardText.substring(0, stringEndIndex + 1);
+               const remainingPart = clipboardText.substring(stringEndIndex + 1).trim();
+
+               const message = JSON.parse(jsonStringPart) as string;
+
+               if (!remainingPart || !remainingPart.startsWith('[')) {
+                       return {
+                               message,
+                               textAttachments: []
+                       };
+               }
+
+               const attachments = JSON.parse(remainingPart) as unknown[];
+
+               const validAttachments: ClipboardTextAttachment[] = [];
+
+               for (const att of attachments) {
+                       if (isValidTextAttachment(att)) {
+                               validAttachments.push({
+                                       type: AttachmentType.TEXT,
+                                       name: att.name,
+                                       content: att.content
+                               });
+                       }
+               }
+
+               return {
+                       message,
+                       textAttachments: validAttachments
+               };
+       } catch {
+               return defaultResult;
+       }
+}
+
+/**
+ * Type guard to validate a text attachment object
+ * @param obj The object to validate
+ * @returns true if the object is a valid text attachment
+ */
+function isValidTextAttachment(
+       obj: unknown
+): obj is { type: string; name: string; content: string } {
+       if (typeof obj !== 'object' || obj === null) {
+               return false;
+       }
+
+       const record = obj as Record<string, unknown>;
+
+       return (
+               (record.type === AttachmentType.TEXT || record.type === 'TEXT') &&
+               typeof record.name === 'string' &&
+               typeof record.content === 'string'
+       );
+}
+
+/**
+ * Checks if clipboard content contains our special format with attachments
+ * @param clipboardText - Raw text from clipboard
+ * @returns true if the clipboard content contains our special format with attachments
+ */
+export function hasClipboardAttachments(clipboardText: string): boolean {
+       if (!clipboardText.startsWith('"')) {
+               return false;
+       }
+
+       const parsed = parseClipboardContent(clipboardText);
+       return parsed.textAttachments.length > 0;
+}
diff --git a/tools/server/webui/src/lib/utils/copy.ts b/tools/server/webui/src/lib/utils/copy.ts
deleted file mode 100644 (file)
index 16a4bbd..0000000
+++ /dev/null
@@ -1,71 +0,0 @@
-import { toast } from 'svelte-sonner';
-
-/**
- * Copy text to clipboard with toast notification
- * Uses modern clipboard API when available, falls back to legacy method for non-secure contexts
- * @param text - Text to copy to clipboard
- * @param successMessage - Custom success message (optional)
- * @param errorMessage - Custom error message (optional)
- * @returns Promise<boolean> - True if successful, false otherwise
- */
-export async function copyToClipboard(
-       text: string,
-       successMessage = 'Copied to clipboard',
-       errorMessage = 'Failed to copy to clipboard'
-): Promise<boolean> {
-       try {
-               // Try modern clipboard API first (secure contexts only)
-               if (navigator.clipboard && navigator.clipboard.writeText) {
-                       await navigator.clipboard.writeText(text);
-                       toast.success(successMessage);
-                       return true;
-               }
-
-               // Fallback for non-secure contexts
-               const textArea = document.createElement('textarea');
-               textArea.value = text;
-               textArea.style.position = 'fixed';
-               textArea.style.left = '-999999px';
-               textArea.style.top = '-999999px';
-               document.body.appendChild(textArea);
-               textArea.focus();
-               textArea.select();
-
-               const successful = document.execCommand('copy');
-               document.body.removeChild(textArea);
-
-               if (successful) {
-                       toast.success(successMessage);
-                       return true;
-               } else {
-                       throw new Error('execCommand failed');
-               }
-       } catch (error) {
-               console.error('Failed to copy to clipboard:', error);
-               toast.error(errorMessage);
-               return false;
-       }
-}
-
-/**
- * Copy code with HTML entity decoding and toast notification
- * @param rawCode - Raw code string that may contain HTML entities
- * @param successMessage - Custom success message (optional)
- * @param errorMessage - Custom error message (optional)
- * @returns Promise<boolean> - True if successful, false otherwise
- */
-export async function copyCodeToClipboard(
-       rawCode: string,
-       successMessage = 'Code copied to clipboard',
-       errorMessage = 'Failed to copy code'
-): Promise<boolean> {
-       // Decode HTML entities
-       const decodedCode = rawCode
-               .replace(/&amp;/g, '&')
-               .replace(/&lt;/g, '<')
-               .replace(/&gt;/g, '>')
-               .replace(/&quot;/g, '"')
-               .replace(/&#39;/g, "'");
-
-       return copyToClipboard(decodedCode, successMessage, errorMessage);
-}
index 2485e34de33c44e4f9ff63838e13733bf55c2e8a..ab600619912d659c2296f32ec715260bb21ed7c9 100644 (file)
@@ -40,7 +40,15 @@ export { setConfigValue, getConfigValue, configToParameterRecord } from './confi
 export { createMessageCountMap, getMessageCount } from './conversation-utils';
 
 // Clipboard utilities
-export { copyToClipboard, copyCodeToClipboard } from './copy';
+export {
+       copyToClipboard,
+       copyCodeToClipboard,
+       formatMessageForClipboard,
+       parseClipboardContent,
+       hasClipboardAttachments,
+       type ClipboardTextAttachment,
+       type ParsedClipboardContent
+} from './clipboard';
 
 // File preview utilities
 export { getFileTypeLabel } from './file-preview';
diff --git a/tools/server/webui/src/lib/utils/latex-protection.test.ts b/tools/server/webui/src/lib/utils/latex-protection.test.ts
deleted file mode 100644 (file)
index 40fe1b0..0000000
+++ /dev/null
@@ -1,376 +0,0 @@
-/* eslint-disable no-irregular-whitespace */
-import { describe, it, expect, test } from 'vitest';
-import { maskInlineLaTeX, preprocessLaTeX } from './latex-protection';
-
-describe('maskInlineLaTeX', () => {
-       it('should protect LaTeX $x + y$ but not money $3.99', () => {
-               const latexExpressions: string[] = [];
-               const input = 'I have $10, $3.99 and $x + y$ and $100x$. The amount is $2,000.';
-               const output = maskInlineLaTeX(input, latexExpressions);
-
-               expect(output).toBe('I have $10, $3.99 and <<LATEX_0>> and <<LATEX_1>>. The amount is $2,000.');
-               expect(latexExpressions).toEqual(['$x + y$', '$100x$']);
-       });
-
-       it('should ignore money like $5 and $12.99', () => {
-               const latexExpressions: string[] = [];
-               const input = 'Prices are $12.99 and $5. Tax?';
-               const output = maskInlineLaTeX(input, latexExpressions);
-
-               expect(output).toBe('Prices are $12.99 and $5. Tax?');
-               expect(latexExpressions).toEqual([]);
-       });
-
-       it('should protect inline math $a^2 + b^2$ even after text', () => {
-               const latexExpressions: string[] = [];
-               const input = 'Pythagorean: $a^2 + b^2 = c^2$.';
-               const output = maskInlineLaTeX(input, latexExpressions);
-
-               expect(output).toBe('Pythagorean: <<LATEX_0>>.');
-               expect(latexExpressions).toEqual(['$a^2 + b^2 = c^2$']);
-       });
-
-       it('should not protect math that has letter after closing $ (e.g. units)', () => {
-               const latexExpressions: string[] = [];
-               const input = 'The cost is $99 and change.';
-               const output = maskInlineLaTeX(input, latexExpressions);
-
-               expect(output).toBe('The cost is $99 and change.');
-               expect(latexExpressions).toEqual([]);
-       });
-
-       it('should allow $x$ followed by punctuation', () => {
-               const latexExpressions: string[] = [];
-               const input = 'We know $x$, right?';
-               const output = maskInlineLaTeX(input, latexExpressions);
-
-               expect(output).toBe('We know <<LATEX_0>>, right?');
-               expect(latexExpressions).toEqual(['$x$']);
-       });
-
-       it('should work across multiple lines', () => {
-               const latexExpressions: string[] = [];
-               const input = `Emma buys cupcakes for $3 each.\nHow much is $x + y$?`;
-               const output = maskInlineLaTeX(input, latexExpressions);
-
-               expect(output).toBe(`Emma buys cupcakes for $3 each.\nHow much is <<LATEX_0>>?`);
-               expect(latexExpressions).toEqual(['$x + y$']);
-       });
-
-       it('should not protect $100 but protect $matrix$', () => {
-               const latexExpressions: string[] = [];
-               const input = '$100 and $\\mathrm{GL}_2(\\mathbb{F}_7)$ are different.';
-               const output = maskInlineLaTeX(input, latexExpressions);
-
-               expect(output).toBe('$100 and <<LATEX_0>> are different.');
-               expect(latexExpressions).toEqual(['$\\mathrm{GL}_2(\\mathbb{F}_7)$']);
-       });
-
-       it('should skip if $ is followed by digit and alphanumeric after close (money)', () => {
-               const latexExpressions: string[] = [];
-               const input = 'I paid $5 quickly.';
-               const output = maskInlineLaTeX(input, latexExpressions);
-
-               expect(output).toBe('I paid $5 quickly.');
-               expect(latexExpressions).toEqual([]);
-       });
-
-       it('should protect LaTeX even with special chars inside', () => {
-               const latexExpressions: string[] = [];
-               const input = 'Consider $\\alpha_1 + \\beta_2$ now.';
-               const output = maskInlineLaTeX(input, latexExpressions);
-
-               expect(output).toBe('Consider <<LATEX_0>> now.');
-               expect(latexExpressions).toEqual(['$\\alpha_1 + \\beta_2$']);
-       });
-
-       it('short text', () => {
-               const latexExpressions: string[] = ['$0$'];
-               const input = '$a$\n$a$ and $b$';
-               const output = maskInlineLaTeX(input, latexExpressions);
-
-               expect(output).toBe('<<LATEX_1>>\n<<LATEX_2>> and <<LATEX_3>>');
-               expect(latexExpressions).toEqual(['$0$', '$a$', '$a$', '$b$']);
-       });
-
-       it('empty text', () => {
-               const latexExpressions: string[] = [];
-               const input = '$\n$$\n';
-               const output = maskInlineLaTeX(input, latexExpressions);
-
-               expect(output).toBe('$\n$$\n');
-               expect(latexExpressions).toEqual([]);
-       });
-
-       it('LaTeX-spacer preceded by backslash', () => {
-               const latexExpressions: string[] = [];
-               const input = `\\[
-\\boxed{
-\\begin{aligned}
-N_{\\text{att}}^{\\text{(MHA)}} &=
-h \\bigl[\\, d_{\\text{model}}\\;d_{k} + d_{\\text{model}}\\;d_{v}\\, \\bigr]   && (\\text{Q,K,V の重み})\\\\
-&\\quad+ h(d_{k}+d_{k}+d_{v})                                          && (\\text{バイアス Q,K,V)}\\\\[4pt]
-&\\quad+ (h d_{v})\\, d_{\\text{model}}                                 && (\\text{出力射影 }W^{O})\\\\
-&\\quad+ d_{\\text{model}}                                            && (\\text{バイアス }b^{O})
-\\end{aligned}}
-\\]`;
-               const output = maskInlineLaTeX(input, latexExpressions);
-
-               expect(output).toBe(input);
-               expect(latexExpressions).toEqual([]);
-       });
-});
-
-describe('preprocessLaTeX', () => {
-       test('converts inline \\( ... \\) to $...$', () => {
-               const input =
-                       '\\( \\mathrm{GL}_2(\\mathbb{F}_7) \\): Group of invertible matrices with entries in \\(\\mathbb{F}_7\\).';
-               const output = preprocessLaTeX(input);
-               expect(output).toBe(
-                       '$ \\mathrm{GL}_2(\\mathbb{F}_7) $: Group of invertible matrices with entries in $\\mathbb{F}_7$.'
-               );
-       });
-
-       test("don't inline \\\\( ... \\) to $...$", () => {
-               const input =
-                       'Chapter 20 of The TeXbook, in source "Definitions\\\\(also called Macros)", containst the formula \\((x_1,\\ldots,x_n)\\).';
-               const output = preprocessLaTeX(input);
-               expect(output).toBe(
-                       'Chapter 20 of The TeXbook, in source "Definitions\\\\(also called Macros)", containst the formula $(x_1,\\ldots,x_n)$.'
-               );
-       });
-
-       test('preserves display math \\[ ... \\] and protects adjacent text', () => {
-               const input = `Some kernel of \\(\\mathrm{SL}_2(\\mathbb{F}_7)\\):
-  \\[
-  \\left\\{ \\begin{pmatrix} 1 & 0 \\\\ 0 & 1 \\end{pmatrix}, \\begin{pmatrix} -1 & 0 \\\\ 0 & -1 \\end{pmatrix} \\right\\} = \\{\\pm I\\}
-  \\]`;
-               const output = preprocessLaTeX(input);
-
-               expect(output).toBe(`Some kernel of $\\mathrm{SL}_2(\\mathbb{F}_7)$:
-  $$
-  \\left\\{ \\begin{pmatrix} 1 & 0 \\\\ 0 & 1 \\end{pmatrix}, \\begin{pmatrix} -1 & 0 \\\\ 0 & -1 \\end{pmatrix} \\right\\} = \\{\\pm I\\}
-  $$`);
-       });
-
-       test('handles standalone display math equation', () => {
-               const input = `Algebra:
-\\[
-x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}
-\\]`;
-               const output = preprocessLaTeX(input);
-
-               expect(output).toBe(`Algebra:
-$$
-x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}
-$$`);
-       });
-
-       test('does not interpret currency values as LaTeX', () => {
-               const input = 'I have $10, $3.99 and $x + y$ and $100x$. The amount is $2,000.';
-               const output = preprocessLaTeX(input);
-
-               expect(output).toBe('I have \\$10, \\$3.99 and $x + y$ and $100x$. The amount is \\$2,000.');
-       });
-
-       test('ignores dollar signs followed by digits (money), but keeps valid math $x + y$', () => {
-               const input = 'I have $10, $3.99 and $x + y$ and $100x$. The amount is $2,000.';
-               const output = preprocessLaTeX(input);
-
-               expect(output).toBe('I have \\$10, \\$3.99 and $x + y$ and $100x$. The amount is \\$2,000.');
-       });
-
-       test('handles real-world word problems with amounts and no math delimiters', () => {
-               const input =
-                       'Emma buys 2 cupcakes for $3 each and 1 cookie for $1.50. How much money does she spend in total?';
-               const output = preprocessLaTeX(input);
-
-               expect(output).toBe(
-                       'Emma buys 2 cupcakes for \\$3 each and 1 cookie for \\$1.50. How much money does she spend in total?'
-               );
-       });
-
-       test('handles decimal amounts in word problem correctly', () => {
-               const input =
-                       'Maria has $20. She buys a notebook for $4.75 and a pack of pencils for $3.25. How much change does she receive?';
-               const output = preprocessLaTeX(input);
-
-               expect(output).toBe(
-                       'Maria has \\$20. She buys a notebook for \\$4.75 and a pack of pencils for \\$3.25. How much change does she receive?'
-               );
-       });
-
-       test('preserves display math with surrounding non-ASCII text', () => {
-               const input = `1 kg の質量は
-  \\[
-  E = (1\\ \\text{kg}) \\times (3.0 \\times 10^8\\ \\text{m/s})^2 \\approx 9.0 \\times 10^{16}\\ \\text{J}
-  \\]
-  というエネルギーに相当します。これは約 21 百万トンの TNT が爆発したときのエネルギーに匹敵します。`;
-               const output = preprocessLaTeX(input);
-
-               expect(output).toBe(
-                       `1 kg の質量は
-  $$
-  E = (1\\ \\text{kg}) \\times (3.0 \\times 10^8\\ \\text{m/s})^2 \\approx 9.0 \\times 10^{16}\\ \\text{J}
-  $$
-  というエネルギーに相当します。これは約 21 百万トンの TNT が爆発したときのエネルギーに匹敵します。`
-               );
-       });
-
-       test('LaTeX-spacer preceded by backslash', () => {
-               const input = `\\[
-\\boxed{
-\\begin{aligned}
-N_{\\text{att}}^{\\text{(MHA)}} &=
-h \\bigl[\\, d_{\\text{model}}\\;d_{k} + d_{\\text{model}}\\;d_{v}\\, \\bigr]   && (\\text{Q,K,V の重み})\\\\
-&\\quad+ h(d_{k}+d_{k}+d_{v})                                          && (\\text{バイアス Q,K,V)}\\\\[4pt]
-&\\quad+ (h d_{v})\\, d_{\\text{model}}                                 && (\\text{出力射影 }W^{O})\\\\
-&\\quad+ d_{\\text{model}}                                            && (\\text{バイアス }b^{O})
-\\end{aligned}}
-\\]`;
-               const output = preprocessLaTeX(input);
-               expect(output).toBe(
-                       `$$
-\\boxed{
-\\begin{aligned}
-N_{\\text{att}}^{\\text{(MHA)}} &=
-h \\bigl[\\, d_{\\text{model}}\\;d_{k} + d_{\\text{model}}\\;d_{v}\\, \\bigr]   && (\\text{Q,K,V の重み})\\\\
-&\\quad+ h(d_{k}+d_{k}+d_{v})                                          && (\\text{バイアス Q,K,V)}\\\\[4pt]
-&\\quad+ (h d_{v})\\, d_{\\text{model}}                                 && (\\text{出力射影 }W^{O})\\\\
-&\\quad+ d_{\\text{model}}                                            && (\\text{バイアス }b^{O})
-\\end{aligned}}
-$$`
-               );
-       });
-
-       test('converts \\[ ... \\] even when preceded by text without space', () => {
-               const input = 'Some line ...\nAlgebra: \\[x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}\\]';
-               const output = preprocessLaTeX(input);
-
-               expect(output).toBe(
-                       'Some line ...\nAlgebra: \n$$x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}$$\n'
-               );
-       });
-
-       test('converts \\[ ... \\] in table-cells', () => {
-               const input = `| ID | Expression |\n| #1 | \\[
-                       x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}
-\\] |`;
-               const output = preprocessLaTeX(input);
-
-               expect(output).toBe(
-                       '| ID | Expression |\n| #1 | $x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}$ |'
-               );
-       });
-
-       test('escapes isolated $ before digits ($5 → \\$5), but not valid math', () => {
-               const input = 'This costs $5 and this is math $x^2$. $100 is money.';
-               const output = preprocessLaTeX(input);
-
-               expect(output).toBe('This costs \\$5 and this is math $x^2$. \\$100 is money.');
-               // Note: Since $x^2$ is detected as valid LaTeX, it's preserved.
-               // $5 becomes \$5 only *after* real math is masked — but here it's correct because the masking logic avoids treating $5 as math.
-       });
-
-       test('display with LaTeX-line-breaks', () => {
-               const input = String.raw`- Algebraic topology, Homotopy Groups of $\mathbb{S}^3$:
-$$\pi_n(\mathbb{S}^3) = \begin{cases}
-\mathbb{Z} & n = 3 \\
-0 & n > 3, n \neq 4 \\
-\mathbb{Z}_2 & n = 4 \\
-\end{cases}$$`;
-               const output = preprocessLaTeX(input);
-               // If the formula contains '\\' the $$-delimiters should be in their own line.
-               expect(output).toBe(`- Algebraic topology, Homotopy Groups of $\\mathbb{S}^3$:
-$$\n\\pi_n(\\mathbb{S}^3) = \\begin{cases}
-\\mathbb{Z} & n = 3 \\\\
-0 & n > 3, n \\neq 4 \\\\
-\\mathbb{Z}_2 & n = 4 \\\\
-\\end{cases}\n$$`);
-       });
-
-       test('handles mhchem notation safely if present', () => {
-               const input = 'Chemical reaction: \\( \\ce{H2O} \\) and $\\ce{CO2}$';
-               const output = preprocessLaTeX(input);
-
-               expect(output).toBe('Chemical reaction: $ \\ce{H2O} $ and $\\ce{CO2}$');
-       });
-
-       test('preserves code blocks', () => {
-               const input = 'Inline code: `sum $total` and block:\n```\ndollar $amount\n```\nEnd.';
-               const output = preprocessLaTeX(input);
-
-               expect(output).toBe(input); // Code blocks prevent misinterpretation
-       });
-
-       test('preserves backslash parentheses in code blocks (GitHub issue)', () => {
-               const input = '```python\nfoo = "\\(bar\\)"\n```';
-               const output = preprocessLaTeX(input);
-
-               expect(output).toBe(input); // Code blocks should not have LaTeX conversion applied
-       });
-
-       test('preserves backslash brackets in code blocks', () => {
-               const input = '```python\nfoo = "\\[bar\\]"\n```';
-               const output = preprocessLaTeX(input);
-
-               expect(output).toBe(input); // Code blocks should not have LaTeX conversion applied
-       });
-
-       test('preserves backslash parentheses in inline code', () => {
-               const input = 'Use `foo = "\\(bar\\)"` in your code.';
-               const output = preprocessLaTeX(input);
-
-               expect(output).toBe(input);
-       });
-
-       test('escape backslash in mchem ce', () => {
-               const input = 'mchem ce:\n$\\ce{2H2(g) + O2(g) -> 2H2O(l)}$';
-               const output = preprocessLaTeX(input);
-
-               // mhchem-escape would insert a backslash here.
-               expect(output).toBe('mchem ce:\n$\\ce{2H2(g) + O2(g) -> 2H2O(l)}$');
-       });
-
-       test('escape backslash in mchem pu', () => {
-               const input = 'mchem pu:\n$\\pu{-572 kJ mol^{-1}}$';
-               const output = preprocessLaTeX(input);
-
-               // mhchem-escape would insert a backslash here.
-               expect(output).toBe('mchem pu:\n$\\pu{-572 kJ mol^{-1}}$');
-       });
-
-       test('LaTeX in blockquotes with display math', () => {
-               const input =
-                       '> **Definition (limit):**  \n>  \\[\n>  \\lim_{x\\to a} f(x) = L\n>  \\]\n>  means that as \\(x\\) gets close to \\(a\\).';
-               const output = preprocessLaTeX(input);
-
-               // Blockquote markers should be preserved, LaTeX should be converted
-               expect(output).toContain('> **Definition (limit):**');
-               expect(output).toContain('$$');
-               expect(output).toContain('$x$');
-               expect(output).not.toContain('\\[');
-               expect(output).not.toContain('\\]');
-               expect(output).not.toContain('\\(');
-               expect(output).not.toContain('\\)');
-       });
-
-       test('LaTeX in blockquotes with inline math', () => {
-               const input =
-                       "> The derivative \\(f'(x)\\) at point \\(x=a\\) measures slope.\n> Formula: \\(f'(a)=\\lim_{h\\to 0}\\frac{f(a+h)-f(a)}{h}\\)";
-               const output = preprocessLaTeX(input);
-
-               // Blockquote markers should be preserved, inline LaTeX converted to $...$
-               expect(output).toContain("> The derivative $f'(x)$ at point $x=a$ measures slope.");
-               expect(output).toContain("> Formula: $f'(a)=\\lim_{h\\to 0}\\frac{f(a+h)-f(a)}{h}$");
-       });
-
-       test('Mixed content with blockquotes and regular text', () => {
-               const input =
-                       'Regular text with \\(x^2\\).\n\n> Quote with \\(y^2\\).\n\nMore text with \\(z^2\\).';
-               const output = preprocessLaTeX(input);
-
-               // All LaTeX should be converted, blockquote markers preserved
-               expect(output).toBe('Regular text with $x^2$.\n\n> Quote with $y^2$.\n\nMore text with $z^2$.');
-       });
-});
diff --git a/tools/server/webui/src/lib/utils/model-names.test.ts b/tools/server/webui/src/lib/utils/model-names.test.ts
deleted file mode 100644 (file)
index ca85df3..0000000
+++ /dev/null
@@ -1,51 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { isValidModelName, normalizeModelName } from './model-names';
-
-describe('normalizeModelName', () => {
-       it('preserves Hugging Face org/model format (single slash)', () => {
-               // Single slash is treated as Hugging Face format and preserved
-               expect(normalizeModelName('meta-llama/Llama-3.1-8B')).toBe('meta-llama/Llama-3.1-8B');
-               expect(normalizeModelName('models/model-name-1')).toBe('models/model-name-1');
-       });
-
-       it('extracts filename from multi-segment paths', () => {
-               // Multiple slashes -> extract just the filename
-               expect(normalizeModelName('path/to/model/model-name-2')).toBe('model-name-2');
-               expect(normalizeModelName('/absolute/path/to/model')).toBe('model');
-       });
-
-       it('extracts filename from backslash paths', () => {
-               expect(normalizeModelName('C\\Models\\model-name-1')).toBe('model-name-1');
-               expect(normalizeModelName('path\\to\\model\\model-name-2')).toBe('model-name-2');
-       });
-
-       it('handles mixed path separators', () => {
-               expect(normalizeModelName('path/to\\model/model-name-2')).toBe('model-name-2');
-       });
-
-       it('returns simple names as-is', () => {
-               expect(normalizeModelName('simple-model')).toBe('simple-model');
-               expect(normalizeModelName('model-name-2')).toBe('model-name-2');
-       });
-
-       it('trims whitespace', () => {
-               expect(normalizeModelName('  model-name  ')).toBe('model-name');
-       });
-
-       it('returns empty string for empty input', () => {
-               expect(normalizeModelName('')).toBe('');
-               expect(normalizeModelName('   ')).toBe('');
-       });
-});
-
-describe('isValidModelName', () => {
-       it('returns true for valid names', () => {
-               expect(isValidModelName('model')).toBe(true);
-               expect(isValidModelName('path/to/model.bin')).toBe(true);
-       });
-
-       it('returns false for empty values', () => {
-               expect(isValidModelName('')).toBe(false);
-               expect(isValidModelName('   ')).toBe(false);
-       });
-});
diff --git a/tools/server/webui/tests/server/demo.spec.ts b/tools/server/webui/tests/server/demo.spec.ts
deleted file mode 100644 (file)
index e07cbbd..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-import { describe, it, expect } from 'vitest';
-
-describe('sum test', () => {
-       it('adds 1 + 2 to equal 3', () => {
-               expect(1 + 2).toBe(3);
-       });
-});
diff --git a/tools/server/webui/tests/unit/clipboard.test.ts b/tools/server/webui/tests/unit/clipboard.test.ts
new file mode 100644 (file)
index 0000000..d8ea489
--- /dev/null
@@ -0,0 +1,423 @@
+import { describe, it, expect } from 'vitest';
+import { AttachmentType } from '$lib/enums';
+import {
+       formatMessageForClipboard,
+       parseClipboardContent,
+       hasClipboardAttachments
+} from '$lib/utils/clipboard';
+
+describe('formatMessageForClipboard', () => {
+       it('returns plain content when no extras', () => {
+               const result = formatMessageForClipboard('Hello world', undefined);
+               expect(result).toBe('Hello world');
+       });
+
+       it('returns plain content when extras is empty array', () => {
+               const result = formatMessageForClipboard('Hello world', []);
+               expect(result).toBe('Hello world');
+       });
+
+       it('handles empty string content', () => {
+               const result = formatMessageForClipboard('', undefined);
+               expect(result).toBe('');
+       });
+
+       it('returns plain content when extras has only non-text attachments', () => {
+               const extras = [
+                       {
+                               type: AttachmentType.IMAGE as const,
+                               name: 'image.png',
+                               base64Url: 'data:image/png;base64,...'
+                       }
+               ];
+               const result = formatMessageForClipboard('Hello world', extras);
+               expect(result).toBe('Hello world');
+       });
+
+       it('filters non-text attachments and keeps only text ones', () => {
+               const extras = [
+                       {
+                               type: AttachmentType.IMAGE as const,
+                               name: 'image.png',
+                               base64Url: 'data:image/png;base64,...'
+                       },
+                       {
+                               type: AttachmentType.TEXT as const,
+                               name: 'file.txt',
+                               content: 'Text content'
+                       },
+                       {
+                               type: AttachmentType.PDF as const,
+                               name: 'doc.pdf',
+                               base64Data: 'data:application/pdf;base64,...',
+                               content: 'PDF content',
+                               processedAsImages: false
+                       }
+               ];
+               const result = formatMessageForClipboard('Hello', extras);
+
+               expect(result).toContain('"file.txt"');
+               expect(result).not.toContain('image.png');
+               expect(result).not.toContain('doc.pdf');
+       });
+
+       it('formats message with text attachments', () => {
+               const extras = [
+                       {
+                               type: AttachmentType.TEXT as const,
+                               name: 'file1.txt',
+                               content: 'File 1 content'
+                       },
+                       {
+                               type: AttachmentType.TEXT as const,
+                               name: 'file2.txt',
+                               content: 'File 2 content'
+                       }
+               ];
+               const result = formatMessageForClipboard('Hello world', extras);
+
+               expect(result).toContain('"Hello world"');
+               expect(result).toContain('"type": "TEXT"');
+               expect(result).toContain('"name": "file1.txt"');
+               expect(result).toContain('"content": "File 1 content"');
+               expect(result).toContain('"name": "file2.txt"');
+       });
+
+       it('handles content with quotes and special characters', () => {
+               const content = 'Hello "world" with\nnewline';
+               const extras = [
+                       {
+                               type: AttachmentType.TEXT as const,
+                               name: 'test.txt',
+                               content: 'Test content'
+                       }
+               ];
+               const result = formatMessageForClipboard(content, extras);
+
+               // Should be valid JSON
+               expect(result.startsWith('"')).toBe(true);
+               // The content should be properly escaped
+               const parsed = JSON.parse(result.split('\n')[0]);
+               expect(parsed).toBe(content);
+       });
+
+       it('converts legacy context type to TEXT type', () => {
+               const extras = [
+                       {
+                               type: AttachmentType.LEGACY_CONTEXT as const,
+                               name: 'legacy.txt',
+                               content: 'Legacy content'
+                       }
+               ];
+               const result = formatMessageForClipboard('Hello', extras);
+
+               expect(result).toContain('"type": "TEXT"');
+               expect(result).not.toContain('"context"');
+       });
+
+       it('handles attachment content with special characters', () => {
+               const extras = [
+                       {
+                               type: AttachmentType.TEXT as const,
+                               name: 'code.js',
+                               content: 'const x = "hello\\nworld";\nconst y = `template ${var}`;'
+                       }
+               ];
+               const formatted = formatMessageForClipboard('Check this code', extras);
+               const parsed = parseClipboardContent(formatted);
+
+               expect(parsed.textAttachments[0].content).toBe(
+                       'const x = "hello\\nworld";\nconst y = `template ${var}`;'
+               );
+       });
+
+       it('handles unicode characters in content and attachments', () => {
+               const extras = [
+                       {
+                               type: AttachmentType.TEXT as const,
+                               name: 'unicode.txt',
+                               content: '日本語テスト 🎉 émojis'
+                       }
+               ];
+               const formatted = formatMessageForClipboard('Привет мир 👋', extras);
+               const parsed = parseClipboardContent(formatted);
+
+               expect(parsed.message).toBe('Привет мир 👋');
+               expect(parsed.textAttachments[0].content).toBe('日本語テスト 🎉 émojis');
+       });
+
+       it('formats as plain text when asPlainText is true', () => {
+               const extras = [
+                       {
+                               type: AttachmentType.TEXT as const,
+                               name: 'file1.txt',
+                               content: 'File 1 content'
+                       },
+                       {
+                               type: AttachmentType.TEXT as const,
+                               name: 'file2.txt',
+                               content: 'File 2 content'
+                       }
+               ];
+               const result = formatMessageForClipboard('Hello world', extras, true);
+
+               expect(result).toBe('Hello world\n\nFile 1 content\n\nFile 2 content');
+       });
+
+       it('returns plain content when asPlainText is true but no attachments', () => {
+               const result = formatMessageForClipboard('Hello world', [], true);
+               expect(result).toBe('Hello world');
+       });
+
+       it('plain text mode does not use JSON format', () => {
+               const extras = [
+                       {
+                               type: AttachmentType.TEXT as const,
+                               name: 'test.txt',
+                               content: 'Test content'
+                       }
+               ];
+               const result = formatMessageForClipboard('Hello', extras, true);
+
+               expect(result).not.toContain('"type"');
+               expect(result).not.toContain('[');
+               expect(result).toBe('Hello\n\nTest content');
+       });
+});
+
+describe('parseClipboardContent', () => {
+       it('returns plain text as message when not in special format', () => {
+               const result = parseClipboardContent('Hello world');
+
+               expect(result.message).toBe('Hello world');
+               expect(result.textAttachments).toHaveLength(0);
+       });
+
+       it('handles empty string input', () => {
+               const result = parseClipboardContent('');
+
+               expect(result.message).toBe('');
+               expect(result.textAttachments).toHaveLength(0);
+       });
+
+       it('handles whitespace-only input', () => {
+               const result = parseClipboardContent('   \n\t  ');
+
+               expect(result.message).toBe('   \n\t  ');
+               expect(result.textAttachments).toHaveLength(0);
+       });
+
+       it('returns plain text as message when starts with quote but invalid format', () => {
+               const result = parseClipboardContent('"Unclosed quote');
+
+               expect(result.message).toBe('"Unclosed quote');
+               expect(result.textAttachments).toHaveLength(0);
+       });
+
+       it('returns original text when JSON array is malformed', () => {
+               const input = '"Hello"\n[invalid json';
+
+               const result = parseClipboardContent(input);
+
+               expect(result.message).toBe('"Hello"\n[invalid json');
+               expect(result.textAttachments).toHaveLength(0);
+       });
+
+       it('parses message with text attachments', () => {
+               const input = `"Hello world"
+[
+  {"type":"TEXT","name":"file1.txt","content":"File 1 content"},
+  {"type":"TEXT","name":"file2.txt","content":"File 2 content"}
+]`;
+
+               const result = parseClipboardContent(input);
+
+               expect(result.message).toBe('Hello world');
+               expect(result.textAttachments).toHaveLength(2);
+               expect(result.textAttachments[0].name).toBe('file1.txt');
+               expect(result.textAttachments[0].content).toBe('File 1 content');
+               expect(result.textAttachments[1].name).toBe('file2.txt');
+               expect(result.textAttachments[1].content).toBe('File 2 content');
+       });
+
+       it('handles escaped quotes in message', () => {
+               const input = `"Hello \\"world\\" with quotes"
+[
+  {"type":"TEXT","name":"file.txt","content":"test"}
+]`;
+
+               const result = parseClipboardContent(input);
+
+               expect(result.message).toBe('Hello "world" with quotes');
+               expect(result.textAttachments).toHaveLength(1);
+       });
+
+       it('handles newlines in message', () => {
+               const input = `"Hello\\nworld"
+[
+  {"type":"TEXT","name":"file.txt","content":"test"}
+]`;
+
+               const result = parseClipboardContent(input);
+
+               expect(result.message).toBe('Hello\nworld');
+               expect(result.textAttachments).toHaveLength(1);
+       });
+
+       it('returns message only when no array follows', () => {
+               const input = '"Just a quoted string"';
+
+               const result = parseClipboardContent(input);
+
+               expect(result.message).toBe('Just a quoted string');
+               expect(result.textAttachments).toHaveLength(0);
+       });
+
+       it('filters out invalid attachment objects', () => {
+               const input = `"Hello"
+[
+  {"type":"TEXT","name":"valid.txt","content":"valid"},
+  {"type":"INVALID","name":"invalid.txt","content":"invalid"},
+  {"name":"missing-type.txt","content":"missing"},
+  {"type":"TEXT","content":"missing name"}
+]`;
+
+               const result = parseClipboardContent(input);
+
+               expect(result.message).toBe('Hello');
+               expect(result.textAttachments).toHaveLength(1);
+               expect(result.textAttachments[0].name).toBe('valid.txt');
+       });
+
+       it('handles empty attachments array', () => {
+               const input = '"Hello"\n[]';
+
+               const result = parseClipboardContent(input);
+
+               expect(result.message).toBe('Hello');
+               expect(result.textAttachments).toHaveLength(0);
+       });
+
+       it('roundtrips correctly with formatMessageForClipboard', () => {
+               const originalContent = 'Hello "world" with\nspecial characters';
+               const originalExtras = [
+                       {
+                               type: AttachmentType.TEXT as const,
+                               name: 'file1.txt',
+                               content: 'Content with\nnewlines and "quotes"'
+                       },
+                       {
+                               type: AttachmentType.TEXT as const,
+                               name: 'file2.txt',
+                               content: 'Another file'
+                       }
+               ];
+
+               const formatted = formatMessageForClipboard(originalContent, originalExtras);
+               const parsed = parseClipboardContent(formatted);
+
+               expect(parsed.message).toBe(originalContent);
+               expect(parsed.textAttachments).toHaveLength(2);
+               expect(parsed.textAttachments[0].name).toBe('file1.txt');
+               expect(parsed.textAttachments[0].content).toBe('Content with\nnewlines and "quotes"');
+               expect(parsed.textAttachments[1].name).toBe('file2.txt');
+               expect(parsed.textAttachments[1].content).toBe('Another file');
+       });
+});
+
+describe('hasClipboardAttachments', () => {
+       it('returns false for plain text', () => {
+               expect(hasClipboardAttachments('Hello world')).toBe(false);
+       });
+
+       it('returns false for empty string', () => {
+               expect(hasClipboardAttachments('')).toBe(false);
+       });
+
+       it('returns false for quoted string without attachments', () => {
+               expect(hasClipboardAttachments('"Hello world"')).toBe(false);
+       });
+
+       it('returns true for valid format with attachments', () => {
+               const input = `"Hello"
+[{"type":"TEXT","name":"file.txt","content":"test"}]`;
+
+               expect(hasClipboardAttachments(input)).toBe(true);
+       });
+
+       it('returns false for format with empty attachments array', () => {
+               const input = '"Hello"\n[]';
+
+               expect(hasClipboardAttachments(input)).toBe(false);
+       });
+
+       it('returns false for malformed JSON', () => {
+               expect(hasClipboardAttachments('"Hello"\n[broken')).toBe(false);
+       });
+});
+
+describe('roundtrip edge cases', () => {
+       it('preserves empty message with attachments', () => {
+               const extras = [
+                       {
+                               type: AttachmentType.TEXT as const,
+                               name: 'file.txt',
+                               content: 'Content only'
+                       }
+               ];
+               const formatted = formatMessageForClipboard('', extras);
+               const parsed = parseClipboardContent(formatted);
+
+               expect(parsed.message).toBe('');
+               expect(parsed.textAttachments).toHaveLength(1);
+               expect(parsed.textAttachments[0].content).toBe('Content only');
+       });
+
+       it('preserves attachment with empty content', () => {
+               const extras = [
+                       {
+                               type: AttachmentType.TEXT as const,
+                               name: 'empty.txt',
+                               content: ''
+                       }
+               ];
+               const formatted = formatMessageForClipboard('Message', extras);
+               const parsed = parseClipboardContent(formatted);
+
+               expect(parsed.message).toBe('Message');
+               expect(parsed.textAttachments).toHaveLength(1);
+               expect(parsed.textAttachments[0].content).toBe('');
+       });
+
+       it('preserves multiple backslashes', () => {
+               const content = 'Path: C:\\\\Users\\\\test\\\\file.txt';
+               const extras = [
+                       {
+                               type: AttachmentType.TEXT as const,
+                               name: 'path.txt',
+                               content: 'D:\\\\Data\\\\file'
+                       }
+               ];
+               const formatted = formatMessageForClipboard(content, extras);
+               const parsed = parseClipboardContent(formatted);
+
+               expect(parsed.message).toBe(content);
+               expect(parsed.textAttachments[0].content).toBe('D:\\\\Data\\\\file');
+       });
+
+       it('preserves tabs and various whitespace', () => {
+               const content = 'Line1\t\tTabbed\n  Spaced\r\nCRLF';
+               const extras = [
+                       {
+                               type: AttachmentType.TEXT as const,
+                               name: 'whitespace.txt',
+                               content: '\t\t\n\n   '
+                       }
+               ];
+               const formatted = formatMessageForClipboard(content, extras);
+               const parsed = parseClipboardContent(formatted);
+
+               expect(parsed.message).toBe(content);
+               expect(parsed.textAttachments[0].content).toBe('\t\t\n\n   ');
+       });
+});
diff --git a/tools/server/webui/tests/unit/latex-protection.test.ts b/tools/server/webui/tests/unit/latex-protection.test.ts
new file mode 100644 (file)
index 0000000..84328db
--- /dev/null
@@ -0,0 +1,376 @@
+/* eslint-disable no-irregular-whitespace */
+import { describe, it, expect, test } from 'vitest';
+import { maskInlineLaTeX, preprocessLaTeX } from '$lib/utils/latex-protection';
+
+describe('maskInlineLaTeX', () => {
+       it('should protect LaTeX $x + y$ but not money $3.99', () => {
+               const latexExpressions: string[] = [];
+               const input = 'I have $10, $3.99 and $x + y$ and $100x$. The amount is $2,000.';
+               const output = maskInlineLaTeX(input, latexExpressions);
+
+               expect(output).toBe('I have $10, $3.99 and <<LATEX_0>> and <<LATEX_1>>. The amount is $2,000.');
+               expect(latexExpressions).toEqual(['$x + y$', '$100x$']);
+       });
+
+       it('should ignore money like $5 and $12.99', () => {
+               const latexExpressions: string[] = [];
+               const input = 'Prices are $12.99 and $5. Tax?';
+               const output = maskInlineLaTeX(input, latexExpressions);
+
+               expect(output).toBe('Prices are $12.99 and $5. Tax?');
+               expect(latexExpressions).toEqual([]);
+       });
+
+       it('should protect inline math $a^2 + b^2$ even after text', () => {
+               const latexExpressions: string[] = [];
+               const input = 'Pythagorean: $a^2 + b^2 = c^2$.';
+               const output = maskInlineLaTeX(input, latexExpressions);
+
+               expect(output).toBe('Pythagorean: <<LATEX_0>>.');
+               expect(latexExpressions).toEqual(['$a^2 + b^2 = c^2$']);
+       });
+
+       it('should not protect math that has letter after closing $ (e.g. units)', () => {
+               const latexExpressions: string[] = [];
+               const input = 'The cost is $99 and change.';
+               const output = maskInlineLaTeX(input, latexExpressions);
+
+               expect(output).toBe('The cost is $99 and change.');
+               expect(latexExpressions).toEqual([]);
+       });
+
+       it('should allow $x$ followed by punctuation', () => {
+               const latexExpressions: string[] = [];
+               const input = 'We know $x$, right?';
+               const output = maskInlineLaTeX(input, latexExpressions);
+
+               expect(output).toBe('We know <<LATEX_0>>, right?');
+               expect(latexExpressions).toEqual(['$x$']);
+       });
+
+       it('should work across multiple lines', () => {
+               const latexExpressions: string[] = [];
+               const input = `Emma buys cupcakes for $3 each.\nHow much is $x + y$?`;
+               const output = maskInlineLaTeX(input, latexExpressions);
+
+               expect(output).toBe(`Emma buys cupcakes for $3 each.\nHow much is <<LATEX_0>>?`);
+               expect(latexExpressions).toEqual(['$x + y$']);
+       });
+
+       it('should not protect $100 but protect $matrix$', () => {
+               const latexExpressions: string[] = [];
+               const input = '$100 and $\\mathrm{GL}_2(\\mathbb{F}_7)$ are different.';
+               const output = maskInlineLaTeX(input, latexExpressions);
+
+               expect(output).toBe('$100 and <<LATEX_0>> are different.');
+               expect(latexExpressions).toEqual(['$\\mathrm{GL}_2(\\mathbb{F}_7)$']);
+       });
+
+       it('should skip if $ is followed by digit and alphanumeric after close (money)', () => {
+               const latexExpressions: string[] = [];
+               const input = 'I paid $5 quickly.';
+               const output = maskInlineLaTeX(input, latexExpressions);
+
+               expect(output).toBe('I paid $5 quickly.');
+               expect(latexExpressions).toEqual([]);
+       });
+
+       it('should protect LaTeX even with special chars inside', () => {
+               const latexExpressions: string[] = [];
+               const input = 'Consider $\\alpha_1 + \\beta_2$ now.';
+               const output = maskInlineLaTeX(input, latexExpressions);
+
+               expect(output).toBe('Consider <<LATEX_0>> now.');
+               expect(latexExpressions).toEqual(['$\\alpha_1 + \\beta_2$']);
+       });
+
+       it('short text', () => {
+               const latexExpressions: string[] = ['$0$'];
+               const input = '$a$\n$a$ and $b$';
+               const output = maskInlineLaTeX(input, latexExpressions);
+
+               expect(output).toBe('<<LATEX_1>>\n<<LATEX_2>> and <<LATEX_3>>');
+               expect(latexExpressions).toEqual(['$0$', '$a$', '$a$', '$b$']);
+       });
+
+       it('empty text', () => {
+               const latexExpressions: string[] = [];
+               const input = '$\n$$\n';
+               const output = maskInlineLaTeX(input, latexExpressions);
+
+               expect(output).toBe('$\n$$\n');
+               expect(latexExpressions).toEqual([]);
+       });
+
+       it('LaTeX-spacer preceded by backslash', () => {
+               const latexExpressions: string[] = [];
+               const input = `\\[
+\\boxed{
+\\begin{aligned}
+N_{\\text{att}}^{\\text{(MHA)}} &=
+h \\bigl[\\, d_{\\text{model}}\\;d_{k} + d_{\\text{model}}\\;d_{v}\\, \\bigr]   && (\\text{Q,K,V の重み})\\\\
+&\\quad+ h(d_{k}+d_{k}+d_{v})                                          && (\\text{バイアス Q,K,V)}\\\\[4pt]
+&\\quad+ (h d_{v})\\, d_{\\text{model}}                                 && (\\text{出力射影 }W^{O})\\\\
+&\\quad+ d_{\\text{model}}                                            && (\\text{バイアス }b^{O})
+\\end{aligned}}
+\\]`;
+               const output = maskInlineLaTeX(input, latexExpressions);
+
+               expect(output).toBe(input);
+               expect(latexExpressions).toEqual([]);
+       });
+});
+
+describe('preprocessLaTeX', () => {
+       test('converts inline \\( ... \\) to $...$', () => {
+               const input =
+                       '\\( \\mathrm{GL}_2(\\mathbb{F}_7) \\): Group of invertible matrices with entries in \\(\\mathbb{F}_7\\).';
+               const output = preprocessLaTeX(input);
+               expect(output).toBe(
+                       '$ \\mathrm{GL}_2(\\mathbb{F}_7) $: Group of invertible matrices with entries in $\\mathbb{F}_7$.'
+               );
+       });
+
+       test("don't inline \\\\( ... \\) to $...$", () => {
+               const input =
+                       'Chapter 20 of The TeXbook, in source "Definitions\\\\(also called Macros)", containst the formula \\((x_1,\\ldots,x_n)\\).';
+               const output = preprocessLaTeX(input);
+               expect(output).toBe(
+                       'Chapter 20 of The TeXbook, in source "Definitions\\\\(also called Macros)", containst the formula $(x_1,\\ldots,x_n)$.'
+               );
+       });
+
+       test('preserves display math \\[ ... \\] and protects adjacent text', () => {
+               const input = `Some kernel of \\(\\mathrm{SL}_2(\\mathbb{F}_7)\\):
+  \\[
+  \\left\\{ \\begin{pmatrix} 1 & 0 \\\\ 0 & 1 \\end{pmatrix}, \\begin{pmatrix} -1 & 0 \\\\ 0 & -1 \\end{pmatrix} \\right\\} = \\{\\pm I\\}
+  \\]`;
+               const output = preprocessLaTeX(input);
+
+               expect(output).toBe(`Some kernel of $\\mathrm{SL}_2(\\mathbb{F}_7)$:
+  $$
+  \\left\\{ \\begin{pmatrix} 1 & 0 \\\\ 0 & 1 \\end{pmatrix}, \\begin{pmatrix} -1 & 0 \\\\ 0 & -1 \\end{pmatrix} \\right\\} = \\{\\pm I\\}
+  $$`);
+       });
+
+       test('handles standalone display math equation', () => {
+               const input = `Algebra:
+\\[
+x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}
+\\]`;
+               const output = preprocessLaTeX(input);
+
+               expect(output).toBe(`Algebra:
+$$
+x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}
+$$`);
+       });
+
+       test('does not interpret currency values as LaTeX', () => {
+               const input = 'I have $10, $3.99 and $x + y$ and $100x$. The amount is $2,000.';
+               const output = preprocessLaTeX(input);
+
+               expect(output).toBe('I have \\$10, \\$3.99 and $x + y$ and $100x$. The amount is \\$2,000.');
+       });
+
+       test('ignores dollar signs followed by digits (money), but keeps valid math $x + y$', () => {
+               const input = 'I have $10, $3.99 and $x + y$ and $100x$. The amount is $2,000.';
+               const output = preprocessLaTeX(input);
+
+               expect(output).toBe('I have \\$10, \\$3.99 and $x + y$ and $100x$. The amount is \\$2,000.');
+       });
+
+       test('handles real-world word problems with amounts and no math delimiters', () => {
+               const input =
+                       'Emma buys 2 cupcakes for $3 each and 1 cookie for $1.50. How much money does she spend in total?';
+               const output = preprocessLaTeX(input);
+
+               expect(output).toBe(
+                       'Emma buys 2 cupcakes for \\$3 each and 1 cookie for \\$1.50. How much money does she spend in total?'
+               );
+       });
+
+       test('handles decimal amounts in word problem correctly', () => {
+               const input =
+                       'Maria has $20. She buys a notebook for $4.75 and a pack of pencils for $3.25. How much change does she receive?';
+               const output = preprocessLaTeX(input);
+
+               expect(output).toBe(
+                       'Maria has \\$20. She buys a notebook for \\$4.75 and a pack of pencils for \\$3.25. How much change does she receive?'
+               );
+       });
+
+       test('preserves display math with surrounding non-ASCII text', () => {
+               const input = `1 kg の質量は
+  \\[
+  E = (1\\ \\text{kg}) \\times (3.0 \\times 10^8\\ \\text{m/s})^2 \\approx 9.0 \\times 10^{16}\\ \\text{J}
+  \\]
+  というエネルギーに相当します。これは約 21 百万トンの TNT が爆発したときのエネルギーに匹敵します。`;
+               const output = preprocessLaTeX(input);
+
+               expect(output).toBe(
+                       `1 kg の質量は
+  $$
+  E = (1\\ \\text{kg}) \\times (3.0 \\times 10^8\\ \\text{m/s})^2 \\approx 9.0 \\times 10^{16}\\ \\text{J}
+  $$
+  というエネルギーに相当します。これは約 21 百万トンの TNT が爆発したときのエネルギーに匹敵します。`
+               );
+       });
+
+       test('LaTeX-spacer preceded by backslash', () => {
+               const input = `\\[
+\\boxed{
+\\begin{aligned}
+N_{\\text{att}}^{\\text{(MHA)}} &=
+h \\bigl[\\, d_{\\text{model}}\\;d_{k} + d_{\\text{model}}\\;d_{v}\\, \\bigr]   && (\\text{Q,K,V の重み})\\\\
+&\\quad+ h(d_{k}+d_{k}+d_{v})                                          && (\\text{バイアス Q,K,V)}\\\\[4pt]
+&\\quad+ (h d_{v})\\, d_{\\text{model}}                                 && (\\text{出力射影 }W^{O})\\\\
+&\\quad+ d_{\\text{model}}                                            && (\\text{バイアス }b^{O})
+\\end{aligned}}
+\\]`;
+               const output = preprocessLaTeX(input);
+               expect(output).toBe(
+                       `$$
+\\boxed{
+\\begin{aligned}
+N_{\\text{att}}^{\\text{(MHA)}} &=
+h \\bigl[\\, d_{\\text{model}}\\;d_{k} + d_{\\text{model}}\\;d_{v}\\, \\bigr]   && (\\text{Q,K,V の重み})\\\\
+&\\quad+ h(d_{k}+d_{k}+d_{v})                                          && (\\text{バイアス Q,K,V)}\\\\[4pt]
+&\\quad+ (h d_{v})\\, d_{\\text{model}}                                 && (\\text{出力射影 }W^{O})\\\\
+&\\quad+ d_{\\text{model}}                                            && (\\text{バイアス }b^{O})
+\\end{aligned}}
+$$`
+               );
+       });
+
+       test('converts \\[ ... \\] even when preceded by text without space', () => {
+               const input = 'Some line ...\nAlgebra: \\[x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}\\]';
+               const output = preprocessLaTeX(input);
+
+               expect(output).toBe(
+                       'Some line ...\nAlgebra: \n$$x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}$$\n'
+               );
+       });
+
+       test('converts \\[ ... \\] in table-cells', () => {
+               const input = `| ID | Expression |\n| #1 | \\[
+                       x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}
+\\] |`;
+               const output = preprocessLaTeX(input);
+
+               expect(output).toBe(
+                       '| ID | Expression |\n| #1 | $x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}$ |'
+               );
+       });
+
+       test('escapes isolated $ before digits ($5 → \\$5), but not valid math', () => {
+               const input = 'This costs $5 and this is math $x^2$. $100 is money.';
+               const output = preprocessLaTeX(input);
+
+               expect(output).toBe('This costs \\$5 and this is math $x^2$. \\$100 is money.');
+               // Note: Since $x^2$ is detected as valid LaTeX, it's preserved.
+               // $5 becomes \$5 only *after* real math is masked — but here it's correct because the masking logic avoids treating $5 as math.
+       });
+
+       test('display with LaTeX-line-breaks', () => {
+               const input = String.raw`- Algebraic topology, Homotopy Groups of $\mathbb{S}^3$:
+$$\pi_n(\mathbb{S}^3) = \begin{cases}
+\mathbb{Z} & n = 3 \\
+0 & n > 3, n \neq 4 \\
+\mathbb{Z}_2 & n = 4 \\
+\end{cases}$$`;
+               const output = preprocessLaTeX(input);
+               // If the formula contains '\\' the $$-delimiters should be in their own line.
+               expect(output).toBe(`- Algebraic topology, Homotopy Groups of $\\mathbb{S}^3$:
+$$\n\\pi_n(\\mathbb{S}^3) = \\begin{cases}
+\\mathbb{Z} & n = 3 \\\\
+0 & n > 3, n \\neq 4 \\\\
+\\mathbb{Z}_2 & n = 4 \\\\
+\\end{cases}\n$$`);
+       });
+
+       test('handles mhchem notation safely if present', () => {
+               const input = 'Chemical reaction: \\( \\ce{H2O} \\) and $\\ce{CO2}$';
+               const output = preprocessLaTeX(input);
+
+               expect(output).toBe('Chemical reaction: $ \\ce{H2O} $ and $\\ce{CO2}$');
+       });
+
+       test('preserves code blocks', () => {
+               const input = 'Inline code: `sum $total` and block:\n```\ndollar $amount\n```\nEnd.';
+               const output = preprocessLaTeX(input);
+
+               expect(output).toBe(input); // Code blocks prevent misinterpretation
+       });
+
+       test('preserves backslash parentheses in code blocks (GitHub issue)', () => {
+               const input = '```python\nfoo = "\\(bar\\)"\n```';
+               const output = preprocessLaTeX(input);
+
+               expect(output).toBe(input); // Code blocks should not have LaTeX conversion applied
+       });
+
+       test('preserves backslash brackets in code blocks', () => {
+               const input = '```python\nfoo = "\\[bar\\]"\n```';
+               const output = preprocessLaTeX(input);
+
+               expect(output).toBe(input); // Code blocks should not have LaTeX conversion applied
+       });
+
+       test('preserves backslash parentheses in inline code', () => {
+               const input = 'Use `foo = "\\(bar\\)"` in your code.';
+               const output = preprocessLaTeX(input);
+
+               expect(output).toBe(input);
+       });
+
+       test('escape backslash in mchem ce', () => {
+               const input = 'mchem ce:\n$\\ce{2H2(g) + O2(g) -> 2H2O(l)}$';
+               const output = preprocessLaTeX(input);
+
+               // mhchem-escape would insert a backslash here.
+               expect(output).toBe('mchem ce:\n$\\ce{2H2(g) + O2(g) -> 2H2O(l)}$');
+       });
+
+       test('escape backslash in mchem pu', () => {
+               const input = 'mchem pu:\n$\\pu{-572 kJ mol^{-1}}$';
+               const output = preprocessLaTeX(input);
+
+               // mhchem-escape would insert a backslash here.
+               expect(output).toBe('mchem pu:\n$\\pu{-572 kJ mol^{-1}}$');
+       });
+
+       test('LaTeX in blockquotes with display math', () => {
+               const input =
+                       '> **Definition (limit):**  \n>  \\[\n>  \\lim_{x\\to a} f(x) = L\n>  \\]\n>  means that as \\(x\\) gets close to \\(a\\).';
+               const output = preprocessLaTeX(input);
+
+               // Blockquote markers should be preserved, LaTeX should be converted
+               expect(output).toContain('> **Definition (limit):**');
+               expect(output).toContain('$$');
+               expect(output).toContain('$x$');
+               expect(output).not.toContain('\\[');
+               expect(output).not.toContain('\\]');
+               expect(output).not.toContain('\\(');
+               expect(output).not.toContain('\\)');
+       });
+
+       test('LaTeX in blockquotes with inline math', () => {
+               const input =
+                       "> The derivative \\(f'(x)\\) at point \\(x=a\\) measures slope.\n> Formula: \\(f'(a)=\\lim_{h\\to 0}\\frac{f(a+h)-f(a)}{h}\\)";
+               const output = preprocessLaTeX(input);
+
+               // Blockquote markers should be preserved, inline LaTeX converted to $...$
+               expect(output).toContain("> The derivative $f'(x)$ at point $x=a$ measures slope.");
+               expect(output).toContain("> Formula: $f'(a)=\\lim_{h\\to 0}\\frac{f(a+h)-f(a)}{h}$");
+       });
+
+       test('Mixed content with blockquotes and regular text', () => {
+               const input =
+                       'Regular text with \\(x^2\\).\n\n> Quote with \\(y^2\\).\n\nMore text with \\(z^2\\).';
+               const output = preprocessLaTeX(input);
+
+               // All LaTeX should be converted, blockquote markers preserved
+               expect(output).toBe('Regular text with $x^2$.\n\n> Quote with $y^2$.\n\nMore text with $z^2$.');
+       });
+});
diff --git a/tools/server/webui/tests/unit/model-names.test.ts b/tools/server/webui/tests/unit/model-names.test.ts
new file mode 100644 (file)
index 0000000..40c5a0e
--- /dev/null
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest';
+import { isValidModelName, normalizeModelName } from '$lib/utils/model-names';
+
+describe('normalizeModelName', () => {
+       it('preserves Hugging Face org/model format (single slash)', () => {
+               // Single slash is treated as Hugging Face format and preserved
+               expect(normalizeModelName('meta-llama/Llama-3.1-8B')).toBe('meta-llama/Llama-3.1-8B');
+               expect(normalizeModelName('models/model-name-1')).toBe('models/model-name-1');
+       });
+
+       it('extracts filename from multi-segment paths', () => {
+               // Multiple slashes -> extract just the filename
+               expect(normalizeModelName('path/to/model/model-name-2')).toBe('model-name-2');
+               expect(normalizeModelName('/absolute/path/to/model')).toBe('model');
+       });
+
+       it('extracts filename from backslash paths', () => {
+               expect(normalizeModelName('C\\Models\\model-name-1')).toBe('model-name-1');
+               expect(normalizeModelName('path\\to\\model\\model-name-2')).toBe('model-name-2');
+       });
+
+       it('handles mixed path separators', () => {
+               expect(normalizeModelName('path/to\\model/model-name-2')).toBe('model-name-2');
+       });
+
+       it('returns simple names as-is', () => {
+               expect(normalizeModelName('simple-model')).toBe('simple-model');
+               expect(normalizeModelName('model-name-2')).toBe('model-name-2');
+       });
+
+       it('trims whitespace', () => {
+               expect(normalizeModelName('  model-name  ')).toBe('model-name');
+       });
+
+       it('returns empty string for empty input', () => {
+               expect(normalizeModelName('')).toBe('');
+               expect(normalizeModelName('   ')).toBe('');
+       });
+});
+
+describe('isValidModelName', () => {
+       it('returns true for valid names', () => {
+               expect(isValidModelName('model')).toBe(true);
+               expect(isValidModelName('path/to/model.bin')).toBe(true);
+       });
+
+       it('returns false for empty values', () => {
+               expect(isValidModelName('')).toBe(false);
+               expect(isValidModelName('   ')).toBe(false);
+       });
+});
index b41d3511b426adf66c3fd0e00fddc1baaf10e8e1..5183c09fcac76f8564ae0a4ca62df42f318d48b3 100644 (file)
@@ -125,9 +125,9 @@ export default defineConfig({
                        {
                                extends: './vite.config.ts',
                                test: {
-                                       name: 'server',
+                                       name: 'unit',
                                        environment: 'node',
-                                       include: ['tests/server/**/*.{test,spec}.{js,ts}']
+                                       include: ['tests/unit/**/*.{test,spec}.{js,ts}']
                                }
                        },
                        {