From b333b06772c89d96aacb5490d6a219fba7c09cc6 Mon Sep 17 00:00:00 2001 From: Mitja Felicijan Date: Thu, 12 Feb 2026 20:57:17 +0100 Subject: Engage! --- .../tests/client/components/TestWrapper.svelte | 17 + .../server/webui/tests/client/page.svelte.test.ts | 11 + .../tools/server/webui/tests/e2e/demo.test.ts | 6 + .../webui/tests/stories/ChatForm.stories.svelte | 161 ++++++++ .../webui/tests/stories/ChatMessage.stories.svelte | 207 ++++++++++ .../tests/stories/ChatSettings.stories.svelte | 19 + .../webui/tests/stories/ChatSidebar.stories.svelte | 97 +++++ .../server/webui/tests/stories/Introduction.mdx | 44 +++ .../tests/stories/MarkdownContent.stories.svelte | 130 +++++++ .../webui/tests/stories/fixtures/ai-tutorial.ts | 164 ++++++++ .../webui/tests/stories/fixtures/api-docs.ts | 160 ++++++++ .../webui/tests/stories/fixtures/assets/1.jpg | Bin 0 -> 44891 bytes .../fixtures/assets/beautiful-flowers-lotus.webp | Bin 0 -> 817630 bytes .../tests/stories/fixtures/assets/example.pdf | Bin 0 -> 351048 bytes .../tests/stories/fixtures/assets/hf-logo.svg | 8 + .../webui/tests/stories/fixtures/blog-post.ts | 125 ++++++ .../webui/tests/stories/fixtures/data-analysis.ts | 124 ++++++ .../server/webui/tests/stories/fixtures/empty.ts | 2 + .../webui/tests/stories/fixtures/math-formulas.ts | 221 +++++++++++ .../server/webui/tests/stories/fixtures/readme.ts | 136 +++++++ .../tests/stories/fixtures/storybook-mocks.ts | 81 ++++ .../server/webui/tests/unit/clipboard.test.ts | 423 +++++++++++++++++++++ .../webui/tests/unit/latex-protection.test.ts | 376 ++++++++++++++++++ .../server/webui/tests/unit/model-names.test.ts | 51 +++ 24 files changed, 2563 insertions(+) create mode 100644 llama.cpp/tools/server/webui/tests/client/components/TestWrapper.svelte create mode 100644 llama.cpp/tools/server/webui/tests/client/page.svelte.test.ts create mode 100644 llama.cpp/tools/server/webui/tests/e2e/demo.test.ts create mode 100644 llama.cpp/tools/server/webui/tests/stories/ChatForm.stories.svelte create mode 100644 llama.cpp/tools/server/webui/tests/stories/ChatMessage.stories.svelte create mode 100644 llama.cpp/tools/server/webui/tests/stories/ChatSettings.stories.svelte create mode 100644 llama.cpp/tools/server/webui/tests/stories/ChatSidebar.stories.svelte create mode 100644 llama.cpp/tools/server/webui/tests/stories/Introduction.mdx create mode 100644 llama.cpp/tools/server/webui/tests/stories/MarkdownContent.stories.svelte create mode 100644 llama.cpp/tools/server/webui/tests/stories/fixtures/ai-tutorial.ts create mode 100644 llama.cpp/tools/server/webui/tests/stories/fixtures/api-docs.ts create mode 100644 llama.cpp/tools/server/webui/tests/stories/fixtures/assets/1.jpg create mode 100644 llama.cpp/tools/server/webui/tests/stories/fixtures/assets/beautiful-flowers-lotus.webp create mode 100644 llama.cpp/tools/server/webui/tests/stories/fixtures/assets/example.pdf create mode 100644 llama.cpp/tools/server/webui/tests/stories/fixtures/assets/hf-logo.svg create mode 100644 llama.cpp/tools/server/webui/tests/stories/fixtures/blog-post.ts create mode 100644 llama.cpp/tools/server/webui/tests/stories/fixtures/data-analysis.ts create mode 100644 llama.cpp/tools/server/webui/tests/stories/fixtures/empty.ts create mode 100644 llama.cpp/tools/server/webui/tests/stories/fixtures/math-formulas.ts create mode 100644 llama.cpp/tools/server/webui/tests/stories/fixtures/readme.ts create mode 100644 llama.cpp/tools/server/webui/tests/stories/fixtures/storybook-mocks.ts create mode 100644 llama.cpp/tools/server/webui/tests/unit/clipboard.test.ts create mode 100644 llama.cpp/tools/server/webui/tests/unit/latex-protection.test.ts create mode 100644 llama.cpp/tools/server/webui/tests/unit/model-names.test.ts (limited to 'llama.cpp/tools/server/webui/tests') diff --git a/llama.cpp/tools/server/webui/tests/client/components/TestWrapper.svelte b/llama.cpp/tools/server/webui/tests/client/components/TestWrapper.svelte new file mode 100644 index 0000000..4bbb8e8 --- /dev/null +++ b/llama.cpp/tools/server/webui/tests/client/components/TestWrapper.svelte @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/llama.cpp/tools/server/webui/tests/client/page.svelte.test.ts b/llama.cpp/tools/server/webui/tests/client/page.svelte.test.ts new file mode 100644 index 0000000..6849beb --- /dev/null +++ b/llama.cpp/tools/server/webui/tests/client/page.svelte.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import TestWrapper from './components/TestWrapper.svelte'; + +describe('/+page.svelte', () => { + it('should render page without throwing', async () => { + // Basic smoke test - page should render without throwing errors + // API calls will fail in test environment but component should still mount + expect(() => render(TestWrapper)).not.toThrow(); + }); +}); diff --git a/llama.cpp/tools/server/webui/tests/e2e/demo.test.ts b/llama.cpp/tools/server/webui/tests/e2e/demo.test.ts new file mode 100644 index 0000000..9985ce1 --- /dev/null +++ b/llama.cpp/tools/server/webui/tests/e2e/demo.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from '@playwright/test'; + +test('home page has expected h1', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('h1')).toBeVisible(); +}); diff --git a/llama.cpp/tools/server/webui/tests/stories/ChatForm.stories.svelte b/llama.cpp/tools/server/webui/tests/stories/ChatForm.stories.svelte new file mode 100644 index 0000000..18319e8 --- /dev/null +++ b/llama.cpp/tools/server/webui/tests/stories/ChatForm.stories.svelte @@ -0,0 +1,161 @@ + + + { + mockServerProps(mockConfigs.noModalities); + + const textarea = await canvas.findByRole('textbox'); + const submitButton = await canvas.findByRole('button', { name: 'Send' }); + + // Expect the input to be focused after the component is mounted + await expect(textarea).toHaveFocus(); + + // Expect the submit button to be disabled + await expect(submitButton).toBeDisabled(); + + const text = 'What is the meaning of life?'; + + await userEvent.clear(textarea); + await userEvent.type(textarea, text); + + await expect(textarea).toHaveValue(text); + + const fileInput = document.querySelector('input[type="file"]'); + await expect(fileInput).not.toHaveAttribute('accept'); + + // Open file attachments dropdown + const fileUploadButton = canvas.getByText('Attach files'); + await userEvent.click(fileUploadButton); + + // Check dropdown menu items are disabled (no modalities) + const imagesButton = document.querySelector('.images-button'); + const audioButton = document.querySelector('.audio-button'); + + await expect(imagesButton).toHaveAttribute('data-disabled'); + await expect(audioButton).toHaveAttribute('data-disabled'); + + // Close dropdown by pressing Escape + await userEvent.keyboard('{Escape}'); + }} +/> + + + + { + mockServerProps(mockConfigs.visionOnly); + + // Open file attachments dropdown and verify it works + const fileUploadButton = canvas.getByText('Attach files'); + await userEvent.click(fileUploadButton); + + // Verify dropdown menu items exist + const imagesButton = document.querySelector('.images-button'); + const audioButton = document.querySelector('.audio-button'); + + await expect(imagesButton).toBeInTheDocument(); + await expect(audioButton).toBeInTheDocument(); + + // Close dropdown by pressing Escape + await userEvent.keyboard('{Escape}'); + + console.log('โœ… Vision modality: Dropdown menu verified'); + }} +/> + + { + mockServerProps(mockConfigs.audioOnly); + + // Open file attachments dropdown and verify it works + const fileUploadButton = canvas.getByText('Attach files'); + await userEvent.click(fileUploadButton); + + // Verify dropdown menu items exist + const imagesButton = document.querySelector('.images-button'); + const audioButton = document.querySelector('.audio-button'); + + await expect(imagesButton).toBeInTheDocument(); + await expect(audioButton).toBeInTheDocument(); + + // Close dropdown by pressing Escape + await userEvent.keyboard('{Escape}'); + + console.log('โœ… Audio modality: Dropdown menu verified'); + }} +/> + + { + mockServerProps(mockConfigs.bothModalities); + + const jpgAttachment = canvas.getByAltText('1.jpg'); + const svgAttachment = canvas.getByAltText('hf-logo.svg'); + const pdfFileExtension = canvas.getByText('PDF'); + const pdfAttachment = canvas.getByText('example.pdf'); + const pdfSize = canvas.getByText('342.82 KB'); + + await expect(jpgAttachment).toBeInTheDocument(); + await expect(jpgAttachment).toHaveAttribute('src', jpgAsset); + + await expect(svgAttachment).toBeInTheDocument(); + await expect(svgAttachment).toHaveAttribute('src', svgAsset); + + await expect(pdfFileExtension).toBeInTheDocument(); + await expect(pdfAttachment).toBeInTheDocument(); + await expect(pdfSize).toBeInTheDocument(); + }} +/> diff --git a/llama.cpp/tools/server/webui/tests/stories/ChatMessage.stories.svelte b/llama.cpp/tools/server/webui/tests/stories/ChatMessage.stories.svelte new file mode 100644 index 0000000..5f4de7d --- /dev/null +++ b/llama.cpp/tools/server/webui/tests/stories/ChatMessage.stories.svelte @@ -0,0 +1,207 @@ + + + { + const { settingsStore } = await import('$lib/stores/settings.svelte'); + settingsStore.updateConfig('disableReasoningFormat', false); + }} +/> + + { + const { settingsStore } = await import('$lib/stores/settings.svelte'); + settingsStore.updateConfig('disableReasoningFormat', false); + }} +/> + + { + const { settingsStore } = await import('$lib/stores/settings.svelte'); + settingsStore.updateConfig('disableReasoningFormat', false); + }} +/> + + { + const { settingsStore } = await import('$lib/stores/settings.svelte'); + settingsStore.updateConfig('disableReasoningFormat', true); + }} +/> + + { + const { settingsStore } = await import('$lib/stores/settings.svelte'); + settingsStore.updateConfig('disableReasoningFormat', false); + // Phase 1: Stream reasoning content in chunks + let reasoningText = + 'I need to think about this carefully. Let me break down the problem:\n\n1. The user is asking for help with something complex\n2. I should provide a thorough and helpful response\n3. I need to consider multiple approaches\n4. The best solution would be to explain step by step\n\nThis approach will ensure clarity and understanding.'; + + let reasoningChunk = 'I'; + let i = 0; + while (i < reasoningText.length) { + const chunkSize = Math.floor(Math.random() * 5) + 3; // Random 3-7 characters + const chunk = reasoningText.slice(i, i + chunkSize); + reasoningChunk += chunk; + + // Update the reactive state directly + streamingMessage.thinking = reasoningChunk; + + i += chunkSize; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + const regularText = + "Based on my analysis, here's the solution:\n\n**Step 1:** First, we need to understand the requirements clearly.\n\n**Step 2:** Then we can implement the solution systematically.\n\n**Step 3:** Finally, we test and validate the results.\n\nThis approach ensures we cover all aspects of the problem effectively."; + + let contentChunk = ''; + i = 0; + + while (i < regularText.length) { + const chunkSize = Math.floor(Math.random() * 5) + 3; // Random 3-7 characters + const chunk = regularText.slice(i, i + chunkSize); + contentChunk += chunk; + + // Update the reactive state directly + streamingMessage.content = contentChunk; + + i += chunkSize; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + streamingMessage.timestamp = Date.now(); + }} +> +
+ +
+
+ + { + const { settingsStore } = await import('$lib/stores/settings.svelte'); + settingsStore.updateConfig('disableReasoningFormat', false); + // Import the chat store to simulate loading state + const { chatStore } = await import('$lib/stores/chat.svelte'); + + // Set loading state to true to trigger the processing UI + chatStore.isLoading = true; + + // Simulate the processing state hook behavior + // This will show the "Generating..." text and parameter details + await new Promise((resolve) => setTimeout(resolve, 100)); + }} +/> diff --git a/llama.cpp/tools/server/webui/tests/stories/ChatSettings.stories.svelte b/llama.cpp/tools/server/webui/tests/stories/ChatSettings.stories.svelte new file mode 100644 index 0000000..4d8dbe5 --- /dev/null +++ b/llama.cpp/tools/server/webui/tests/stories/ChatSettings.stories.svelte @@ -0,0 +1,19 @@ + + + diff --git a/llama.cpp/tools/server/webui/tests/stories/ChatSidebar.stories.svelte b/llama.cpp/tools/server/webui/tests/stories/ChatSidebar.stories.svelte new file mode 100644 index 0000000..42cea87 --- /dev/null +++ b/llama.cpp/tools/server/webui/tests/stories/ChatSidebar.stories.svelte @@ -0,0 +1,97 @@ + + + { + const { conversationsStore } = await import('$lib/stores/conversations.svelte'); + + waitFor(() => setTimeout(() => { + conversationsStore.conversations = mockConversations; + }, 0)); + }} +> +
+ +
+
+ + { + const { conversationsStore } = await import('$lib/stores/conversations.svelte'); + + waitFor(() => setTimeout(() => { + conversationsStore.conversations = mockConversations; + }, 0)); + + const searchTrigger = screen.getByText('Search conversations'); + userEvent.click(searchTrigger); + }} +> +
+ +
+
+ + { + // Mock empty conversations store + const { conversationsStore } = await import('$lib/stores/conversations.svelte'); + conversationsStore.conversations = []; + }} +> +
+ +
+
diff --git a/llama.cpp/tools/server/webui/tests/stories/Introduction.mdx b/llama.cpp/tools/server/webui/tests/stories/Introduction.mdx new file mode 100644 index 0000000..19d0b28 --- /dev/null +++ b/llama.cpp/tools/server/webui/tests/stories/Introduction.mdx @@ -0,0 +1,44 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# llama.cpp Web UI + +Welcome to the **llama.cpp Web UI** component library! This Storybook showcases the components used in the modern web interface for the llama.cpp server. + +## ๐Ÿš€ About This Project + +WebUI is a modern web interface for the llama.cpp server, built with SvelteKit and ShadCN UI. Features include: + +- **Real-time chat conversations** with AI assistants +- **Multi-conversation management** with persistent storage +- **Advanced parameter tuning** for model behavior +- **File upload support** for multimodal interactions +- **Responsive design** that works on desktop and mobile + +## ๐ŸŽจ Design System + +The UI is built using: + +- **SvelteKit** - Modern web framework with excellent performance +- **Tailwind CSS** - Utility-first CSS framework for rapid styling +- **ShadCN/UI** - High-quality, accessible component library +- **Lucide Icons** - Beautiful, consistent icon set + +## ๐Ÿ”ง Development + +This Storybook serves as both documentation and a development environment for the UI components. Each story demonstrates: + +- **Component variations** - Different states and configurations +- **Interactive examples** - Live components you can interact with +- **Usage patterns** - How components work together +- **Styling consistency** - Unified design language + +## ๐Ÿš€ Getting Started + +To explore the components: + +1. **Browse the sidebar** to see all available components +2. **Click on stories** to see different component states +3. **Use the controls panel** to interact with component props +4. **Check the docs tab** for detailed component information diff --git a/llama.cpp/tools/server/webui/tests/stories/MarkdownContent.stories.svelte b/llama.cpp/tools/server/webui/tests/stories/MarkdownContent.stories.svelte new file mode 100644 index 0000000..90aa90b --- /dev/null +++ b/llama.cpp/tools/server/webui/tests/stories/MarkdownContent.stories.svelte @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + { + // Wait for component to render + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Find all links in the rendered content + const links = canvasElement.querySelectorAll('a[href]'); + + // Test that we have the expected number of links + expect(links.length).toBeGreaterThan(0); + + // Test each link for proper attributes + links.forEach((link) => { + const href = link.getAttribute('href'); + + // Test that external links have proper security attributes + if (href && (href.startsWith('http://') || href.startsWith('https://'))) { + expect(link.getAttribute('target')).toBe('_blank'); + expect(link.getAttribute('rel')).toBe('noopener noreferrer'); + } + }); + + // Test specific links exist + const hugginFaceLink = Array.from(links).find( + (link) => link.getAttribute('href') === 'https://huggingface.co' + ); + expect(hugginFaceLink).toBeTruthy(); + expect(hugginFaceLink?.textContent).toBe('Hugging Face Homepage'); + + const githubLink = Array.from(links).find( + (link) => link.getAttribute('href') === 'https://github.com/ggml-org/llama.cpp' + ); + expect(githubLink).toBeTruthy(); + expect(githubLink?.textContent).toBe('GitHub Repository'); + + const openaiLink = Array.from(links).find( + (link) => link.getAttribute('href') === 'https://openai.com' + ); + expect(openaiLink).toBeTruthy(); + expect(openaiLink?.textContent).toBe('OpenAI Website'); + + const googleLink = Array.from(links).find( + (link) => link.getAttribute('href') === 'https://www.google.com' + ); + expect(googleLink).toBeTruthy(); + expect(googleLink?.textContent).toBe('Google Search'); + + // Test inline links (auto-linked URLs) + const exampleLink = Array.from(links).find( + (link) => link.getAttribute('href') === 'https://example.com' + ); + expect(exampleLink).toBeTruthy(); + + const pythonDocsLink = Array.from(links).find( + (link) => link.getAttribute('href') === 'https://docs.python.org' + ); + expect(pythonDocsLink).toBeTruthy(); + + console.log(`โœ… URL Links test passed - Found ${links.length} links with proper attributes`); + }} +/> diff --git a/llama.cpp/tools/server/webui/tests/stories/fixtures/ai-tutorial.ts b/llama.cpp/tools/server/webui/tests/stories/fixtures/ai-tutorial.ts new file mode 100644 index 0000000..b3b1c24 --- /dev/null +++ b/llama.cpp/tools/server/webui/tests/stories/fixtures/ai-tutorial.ts @@ -0,0 +1,164 @@ +// AI Assistant Tutorial Response +export const AI_TUTORIAL_MD = String.raw` +# Building a Modern Chat Application with SvelteKit + +I'll help you create a **production-ready chat application** using SvelteKit, TypeScript, and WebSockets. This implementation includes real-time messaging, user authentication, and message persistence. + +## ๐Ÿš€ Quick Start + +First, let's set up the project: + +${'```'}bash +npm create svelte@latest chat-app +cd chat-app +npm install +npm install socket.io socket.io-client +npm install @prisma/client prisma +npm run dev +${'```'} + +## ๐Ÿ“ Project Structure + +${'```'} +chat-app/ +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ routes/ +โ”‚ โ”‚ โ”œโ”€โ”€ +layout.svelte +โ”‚ โ”‚ โ”œโ”€โ”€ +page.svelte +โ”‚ โ”‚ โ””โ”€โ”€ api/ +โ”‚ โ”‚ โ””โ”€โ”€ socket/+server.ts +โ”‚ โ”œโ”€โ”€ lib/ +โ”‚ โ”‚ โ”œโ”€โ”€ components/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ ChatMessage.svelte +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ ChatInput.svelte +โ”‚ โ”‚ โ””โ”€โ”€ stores/ +โ”‚ โ”‚ โ””โ”€โ”€ chat.ts +โ”‚ โ””โ”€โ”€ app.html +โ”œโ”€โ”€ prisma/ +โ”‚ โ””โ”€โ”€ schema.prisma +โ””โ”€โ”€ package.json +${'```'} + +## ๐Ÿ’ป Implementation + +### WebSocket Server + +${'```'}typescript +// src/lib/server/socket.ts +import { Server } from 'socket.io'; +import type { ViteDevServer } from 'vite'; + +export function initializeSocketIO(server: ViteDevServer) { + const io = new Server(server.httpServer || server, { + cors: { + origin: process.env.ORIGIN || 'http://localhost:5173', + credentials: true + } + }); + + io.on('connection', (socket) => { + console.log('User connected:', socket.id); + + socket.on('message', async (data) => { + // Broadcast to all clients + io.emit('new-message', { + id: crypto.randomUUID(), + userId: socket.id, + content: data.content, + timestamp: new Date().toISOString() + }); + }); + + socket.on('disconnect', () => { + console.log('User disconnected:', socket.id); + }); + }); + + return io; +} +${'```'} + +### Client Store + +${'```'}typescript +// src/lib/stores/chat.ts +import { writable } from 'svelte/store'; +import io from 'socket.io-client'; + +export interface Message { + id: string; + userId: string; + content: string; + timestamp: string; +} + +function createChatStore() { + const { subscribe, update } = writable([]); + let socket: ReturnType; + + return { + subscribe, + connect: () => { + socket = io('http://localhost:5173'); + + socket.on('new-message', (message: Message) => { + update(messages => [...messages, message]); + }); + }, + sendMessage: (content: string) => { + if (socket && content.trim()) { + socket.emit('message', { content }); + } + } + }; +} + +export const chatStore = createChatStore(); +${'```'} + +## ๐ŸŽฏ Key Features + +โœ… **Real-time messaging** with WebSockets +โœ… **Message persistence** using Prisma + PostgreSQL +โœ… **Type-safe** with TypeScript +โœ… **Responsive UI** for all devices +โœ… **Auto-reconnection** on connection loss + +## ๐Ÿ“Š Performance Metrics + +| Metric | Value | +|--------|-------| +| **Message Latency** | < 50ms | +| **Concurrent Users** | 10,000+ | +| **Messages/Second** | 5,000+ | +| **Uptime** | 99.9% | + +## ๐Ÿ”ง Configuration + +### Environment Variables + +${'```'}env +DATABASE_URL="postgresql://user:password@localhost:5432/chat" +JWT_SECRET="your-secret-key" +REDIS_URL="redis://localhost:6379" +${'```'} + +## ๐Ÿšข Deployment + +Deploy to production using Docker: + +${'```'}dockerfile +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --only=production +COPY . . +RUN npm run build +EXPOSE 3000 +CMD ["node", "build"] +${'```'} + +--- + +*Need help? Check the [documentation](https://kit.svelte.dev) or [open an issue](https://github.com/sveltejs/kit/issues)* +`; diff --git a/llama.cpp/tools/server/webui/tests/stories/fixtures/api-docs.ts b/llama.cpp/tools/server/webui/tests/stories/fixtures/api-docs.ts new file mode 100644 index 0000000..7b49995 --- /dev/null +++ b/llama.cpp/tools/server/webui/tests/stories/fixtures/api-docs.ts @@ -0,0 +1,160 @@ +// API Documentation +export const API_DOCS_MD = String.raw` +# REST API Documentation + +## ๐Ÿ” Authentication + +All API requests require authentication using **Bearer tokens**. Include your API key in the Authorization header: + +${'```'}http +GET /api/v1/users +Host: api.example.com +Authorization: Bearer YOUR_API_KEY +Content-Type: application/json +${'```'} + +## ๐Ÿ“ Endpoints + +### Users API + +#### **GET** /api/v1/users + +Retrieve a paginated list of users. + +**Query Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| page | integer | 1 | Page number | +| limit | integer | 20 | Items per page | +| sort | string | "created_at" | Sort field | +| order | string | "desc" | Sort order | + +**Response:** 200 OK + +${'```'}json +{ + "data": [ + { + "id": "usr_1234567890", + "email": "user@example.com", + "name": "John Doe", + "role": "admin", + "created_at": "2024-01-15T10:30:00Z" + } + ], + "pagination": { + "page": 1, + "limit": 20, + "total": 156, + "pages": 8 + } +} +${'```'} + +#### **POST** /api/v1/users + +Create a new user account. + +**Request Body:** + +${'```'}json +{ + "email": "newuser@example.com", + "password": "SecurePassword123!", + "name": "Jane Smith", + "role": "user" +} +${'```'} + +**Response:** 201 Created + +${'```'}json +{ + "id": "usr_9876543210", + "email": "newuser@example.com", + "name": "Jane Smith", + "role": "user", + "created_at": "2024-01-21T09:15:00Z" +} +${'```'} + +### Error Responses + +The API returns errors in a consistent format: + +${'```'}json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "Invalid request parameters", + "details": [ + { + "field": "email", + "message": "Email format is invalid" + } + ] + } +} +${'```'} + +### Rate Limiting + +| Tier | Requests/Hour | Burst | +|------|--------------|-------| +| **Free** | 1,000 | 100 | +| **Pro** | 10,000 | 500 | +| **Enterprise** | Unlimited | - | + +**Headers:** +- X-RateLimit-Limit +- X-RateLimit-Remaining +- X-RateLimit-Reset + +### Webhooks + +Configure webhooks to receive real-time events: + +${'```'}javascript +// Webhook payload +{ + "event": "user.created", + "timestamp": "2024-01-21T09:15:00Z", + "data": { + "id": "usr_9876543210", + "email": "newuser@example.com" + }, + "signature": "sha256=abcd1234..." +} +${'```'} + +### SDK Examples + +**JavaScript/TypeScript:** + +${'```'}typescript +import { ApiClient } from '@example/api-sdk'; + +const client = new ApiClient({ + apiKey: process.env.API_KEY +}); + +const users = await client.users.list({ + page: 1, + limit: 20 +}); +${'```'} + +**Python:** + +${'```'}python +from example_api import Client + +client = Client(api_key=os.environ['API_KEY']) +users = client.users.list(page=1, limit=20) +${'```'} + +--- + +๐Ÿ“š [Full API Reference](https://api.example.com/docs) | ๐Ÿ’ฌ [Support](https://support.example.com) +`; diff --git a/llama.cpp/tools/server/webui/tests/stories/fixtures/assets/1.jpg b/llama.cpp/tools/server/webui/tests/stories/fixtures/assets/1.jpg new file mode 100644 index 0000000..8348e38 Binary files /dev/null and b/llama.cpp/tools/server/webui/tests/stories/fixtures/assets/1.jpg differ diff --git a/llama.cpp/tools/server/webui/tests/stories/fixtures/assets/beautiful-flowers-lotus.webp b/llama.cpp/tools/server/webui/tests/stories/fixtures/assets/beautiful-flowers-lotus.webp new file mode 100644 index 0000000..6efcffc Binary files /dev/null and b/llama.cpp/tools/server/webui/tests/stories/fixtures/assets/beautiful-flowers-lotus.webp differ diff --git a/llama.cpp/tools/server/webui/tests/stories/fixtures/assets/example.pdf b/llama.cpp/tools/server/webui/tests/stories/fixtures/assets/example.pdf new file mode 100644 index 0000000..915d301 Binary files /dev/null and b/llama.cpp/tools/server/webui/tests/stories/fixtures/assets/example.pdf differ diff --git a/llama.cpp/tools/server/webui/tests/stories/fixtures/assets/hf-logo.svg b/llama.cpp/tools/server/webui/tests/stories/fixtures/assets/hf-logo.svg new file mode 100644 index 0000000..d55ea22 --- /dev/null +++ b/llama.cpp/tools/server/webui/tests/stories/fixtures/assets/hf-logo.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/llama.cpp/tools/server/webui/tests/stories/fixtures/blog-post.ts b/llama.cpp/tools/server/webui/tests/stories/fixtures/blog-post.ts new file mode 100644 index 0000000..3eb2ed7 --- /dev/null +++ b/llama.cpp/tools/server/webui/tests/stories/fixtures/blog-post.ts @@ -0,0 +1,125 @@ +// Blog Post Content +export const BLOG_POST_MD = String.raw` +# Understanding Rust's Ownership System + +*Published on March 15, 2024 โ€ข 8 min read* + +Rust's ownership system is one of its most distinctive features, enabling memory safety without garbage collection. In this post, we'll explore how ownership works and why it's revolutionary for systems programming. + +## What is Ownership? + +Ownership is a set of rules that governs how Rust manages memory. These rules are checked at compile time, ensuring memory safety without runtime overhead. + +### The Three Rules of Ownership + +1. **Each value has a single owner** +2. **There can only be one owner at a time** +3. **When the owner goes out of scope, the value is dropped** + +## Memory Management Without GC + +Traditional approaches to memory management: + +- **Manual management** (C/C++): Error-prone, leads to bugs +- **Garbage collection** (Java, Python): Runtime overhead +- **Ownership** (Rust): Compile-time safety, zero runtime cost + +## Basic Examples + +### Variable Scope + +${'```'}rust +fn main() { + let s = String::from("hello"); // s comes into scope + + // s is valid here + println!("{}", s); + +} // s goes out of scope and is dropped +${'```'} + +### Move Semantics + +${'```'}rust +fn main() { + let s1 = String::from("hello"); + let s2 = s1; // s1 is moved to s2 + + // println!("{}", s1); // โŒ ERROR: s1 is no longer valid + println!("{}", s2); // โœ… OK: s2 owns the string +} +${'```'} + +## Borrowing and References + +Instead of transferring ownership, you can **borrow** values: + +### Immutable References + +${'```'}rust +fn calculate_length(s: &String) -> usize { + s.len() // s is a reference, doesn't own the String +} + +fn main() { + let s1 = String::from("hello"); + let len = calculate_length(&s1); // Borrow s1 + println!("Length of '{}' is {}", s1, len); // s1 still valid +} +${'```'} + +### Mutable References + +${'```'}rust +fn main() { + let mut s = String::from("hello"); + + let r1 = &mut s; + r1.push_str(", world"); + println!("{}", r1); + + // let r2 = &mut s; // โŒ ERROR: cannot borrow twice +} +${'```'} + +## Common Pitfalls + +### Dangling References + +${'```'}rust +fn dangle() -> &String { // โŒ ERROR: missing lifetime specifier + let s = String::from("hello"); + &s // s will be dropped, leaving a dangling reference +} +${'```'} + +### โœ… Solution + +${'```'}rust +fn no_dangle() -> String { + let s = String::from("hello"); + s // Ownership is moved out +} +${'```'} + +## Benefits + +- โœ… **No null pointer dereferences** +- โœ… **No data races** +- โœ… **No use-after-free** +- โœ… **No memory leaks** + +## Conclusion + +Rust's ownership system eliminates entire classes of bugs at compile time. While it has a learning curve, the benefits in safety and performance are worth it. + +## Further Reading + +- [The Rust Book - Ownership](https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html) +- [Rust by Example - Ownership](https://doc.rust-lang.org/rust-by-example/scope/move.html) +- [Rustlings Exercises](https://github.com/rust-lang/rustlings) + +--- + +*Questions? Reach out on [Twitter](https://twitter.com/rustlang) or join the [Rust Discord](https://discord.gg/rust-lang)* +`; diff --git a/llama.cpp/tools/server/webui/tests/stories/fixtures/data-analysis.ts b/llama.cpp/tools/server/webui/tests/stories/fixtures/data-analysis.ts new file mode 100644 index 0000000..6fec32d --- /dev/null +++ b/llama.cpp/tools/server/webui/tests/stories/fixtures/data-analysis.ts @@ -0,0 +1,124 @@ +// Data Analysis Report +export const DATA_ANALYSIS_MD = String.raw` +# Q4 2024 Business Analytics Report + +*Executive Summary โ€ข Generated on January 15, 2025* + +## ๐Ÿ“Š Key Performance Indicators + +${'```'} +Daily Active Users (DAU): 1.2M (+65% YoY) +Monthly Active Users (MAU): 4.5M (+48% YoY) +User Retention (Day 30): 68% (+12pp YoY) +Average Session Duration: 24min (+35% YoY) +${'```'} + +## ๐ŸŽฏ Product Performance + +### Feature Adoption Rates + +1. **AI Assistant**: 78% of users (โ†‘ from 45%) +2. **Collaboration Tools**: 62% of users (โ†‘ from 38%) +3. **Analytics Dashboard**: 54% of users (โ†‘ from 31%) +4. **Mobile App**: 41% of users (โ†‘ from 22%) + +### Customer Satisfaction + +| Metric | Q4 2024 | Q3 2024 | Change | +|--------|---------|---------|--------| +| **NPS Score** | 72 | 68 | +4 | +| **CSAT** | 4.6/5 | 4.4/5 | +0.2 | +| **Support Tickets** | 2,340 | 2,890 | -19% | +| **Resolution Time** | 4.2h | 5.1h | -18% | + +## ๐Ÿ’ฐ Revenue Metrics + +### Monthly Recurring Revenue (MRR) + +- **Current MRR**: $2.8M (+42% YoY) +- **New MRR**: $340K +- **Expansion MRR**: $180K +- **Churned MRR**: $95K +- **Net New MRR**: $425K + +### Customer Acquisition + +${'```'} +Cost per Acquisition (CAC): $127 (-23% YoY) +Customer Lifetime Value: $1,840 (+31% YoY) +LTV:CAC Ratio: 14.5:1 +Payback Period: 3.2 months +${'```'} + +## ๐ŸŒ Geographic Performance + +### Revenue by Region + +1. **North America**: 45% ($1.26M) +2. **Europe**: 32% ($896K) +3. **Asia-Pacific**: 18% ($504K) +4. **Other**: 5% ($140K) + +### Growth Opportunities + +- **APAC**: 89% YoY growth potential +- **Latin America**: Emerging market entry +- **Middle East**: Enterprise expansion + +## ๐Ÿ“ฑ Channel Performance + +### Traffic Sources + +| Channel | Sessions | Conversion | Revenue | +|---------|----------|------------|---------| +| **Organic Search** | 45% | 3.2% | $1.1M | +| **Direct** | 28% | 4.1% | $850K | +| **Social Media** | 15% | 2.8% | $420K | +| **Paid Ads** | 12% | 5.5% | $430K | + +### Marketing ROI + +- **Content Marketing**: 340% ROI +- **Email Campaigns**: 280% ROI +- **Social Media**: 190% ROI +- **Paid Search**: 220% ROI + +## ๐Ÿ” User Behavior Analysis + +### Session Patterns + +- **Peak Hours**: 9-11 AM, 2-4 PM EST +- **Mobile Usage**: 67% of sessions +- **Average Pages/Session**: 4.8 +- **Bounce Rate**: 23% (โ†“ from 31%) + +### Feature Usage Heatmap + +Most used features in order: +1. Dashboard (89% of users) +2. Search (76% of users) +3. Reports (64% of users) +4. Settings (45% of users) +5. Integrations (32% of users) + +## ๐Ÿ’ก Recommendations + +1. **Invest** in AI capabilities (+$2M budget) +2. **Expand** sales team in APAC region +3. **Improve** onboarding to reduce churn +4. **Launch** enterprise security features + +## Appendix + +### Methodology + +Data collected from: +- Internal analytics (Amplitude) +- Customer surveys (n=2,450) +- Financial systems (NetSuite) +- Market research (Gartner) + +--- + +*Report prepared by Data Analytics Team โ€ข [View Interactive Dashboard](https://analytics.example.com)* +`; diff --git a/llama.cpp/tools/server/webui/tests/stories/fixtures/empty.ts b/llama.cpp/tools/server/webui/tests/stories/fixtures/empty.ts new file mode 100644 index 0000000..05286e7 --- /dev/null +++ b/llama.cpp/tools/server/webui/tests/stories/fixtures/empty.ts @@ -0,0 +1,2 @@ +// Empty state +export const EMPTY_MD = ''; diff --git a/llama.cpp/tools/server/webui/tests/stories/fixtures/math-formulas.ts b/llama.cpp/tools/server/webui/tests/stories/fixtures/math-formulas.ts new file mode 100644 index 0000000..1355256 --- /dev/null +++ b/llama.cpp/tools/server/webui/tests/stories/fixtures/math-formulas.ts @@ -0,0 +1,221 @@ +/* eslint-disable no-irregular-whitespace */ +// Math Formulas Content +export const MATH_FORMULAS_MD = String.raw` +# Mathematical Formulas and Expressions + +This document demonstrates various mathematical notation and formulas that can be rendered using LaTeX syntax in markdown. + +## Basic Arithmetic + +### Addition and Summation +$$\sum_{i=1}^{n} i = \frac{n(n+1)}{2}$$ + +## Algebra + +### Quadratic Formula +The solutions to $ax^2 + bx + c = 0$ are: +$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$ + +### Binomial Theorem +$$(x + y)^n = \sum_{k=0}^{n} \binom{n}{k} x^{n-k} y^k$$ + +## Calculus + +### Derivatives +The derivative of $f(x) = x^n$ is: +$$f'(x) = nx^{n-1}$$ + +### Integration +$$\int_a^b f(x) \, dx = F(b) - F(a)$$ + +### Fundamental Theorem of Calculus +$$\frac{d}{dx} \int_a^x f(t) \, dt = f(x)$$ + +## Linear Algebra + +### Matrix Multiplication +If $A$ is an $m \times n$ matrix and $B$ is an $n \times p$ matrix, then: +$$C_{ij} = \sum_{k=1}^{n} A_{ik} B_{kj}$$ + +### Eigenvalues and Eigenvectors +For a square matrix $A$, if $Av = \lambda v$ for some non-zero vector $v$, then: +- $\lambda$ is an eigenvalue +- $v$ is an eigenvector + +## Statistics and Probability + +### Normal Distribution +The probability density function is: +$$f(x) = \frac{1}{\sigma\sqrt{2\pi}} e^{-\frac{1}{2}\left(\frac{x-\mu}{\sigma}\right)^2}$$ + +### Bayes' Theorem +$$P(A|B) = \frac{P(B|A) \cdot P(A)}{P(B)}$$ + +### Central Limit Theorem +For large $n$, the sample mean $\bar{X}$ is approximately: +$$\bar{X} \sim N\left(\mu, \frac{\sigma^2}{n}\right)$$ + +## Trigonometry + +### Pythagorean Identity +$$\sin^2\theta + \cos^2\theta = 1$$ + +### Euler's Formula +$$e^{i\theta} = \cos\theta + i\sin\theta$$ + +### Taylor Series for Sine +$$\sin x = \sum_{n=0}^{\infty} \frac{(-1)^n}{(2n+1)!} x^{2n+1} = x - \frac{x^3}{3!} + \frac{x^5}{5!} - \frac{x^7}{7!} + \cdots$$ + +## Complex Analysis + +### Complex Numbers +A complex number can be written as: +$$z = a + bi = r e^{i\theta}$$ + +where $r = |z| = \sqrt{a^2 + b^2}$ and $\theta = \arg(z)$ + +### Cauchy-Riemann Equations +For a function $f(z) = u(x,y) + iv(x,y)$ to be analytic: +$$\frac{\partial u}{\partial x} = \frac{\partial v}{\partial y}, \quad \frac{\partial u}{\partial y} = -\frac{\partial v}{\partial x}$$ + +## Differential Equations + +### First-order Linear ODE +$$\frac{dy}{dx} + P(x)y = Q(x)$$ + +Solution: $y = e^{-\int P(x)dx}\left[\int Q(x)e^{\int P(x)dx}dx + C\right]$ + +### Heat Equation +$$\frac{\partial u}{\partial t} = \alpha \frac{\partial^2 u}{\partial x^2}$$ + +## Number Theory + +### Prime Number Theorem +$$\pi(x) \sim \frac{x}{\ln x}$$ + +where $\pi(x)$ is the number of primes less than or equal to $x$. + +### Fermat's Last Theorem +For $n > 2$, there are no positive integers $a$, $b$, and $c$ such that: +$$a^n + b^n = c^n$$ + +## Set Theory + +### De Morgan's Laws +$$\overline{A \cup B} = \overline{A} \cap \overline{B}$$ +$$\overline{A \cap B} = \overline{A} \cup \overline{B}$$ + +## Advanced Topics + +### Riemann Zeta Function +$$\zeta(s) = \sum_{n=1}^{\infty} \frac{1}{n^s} = \prod_{p \text{ prime}} \frac{1}{1-p^{-s}}$$ + +### Maxwell's Equations +$$\nabla \cdot \mathbf{E} = \frac{\rho}{\epsilon_0}$$ +$$\nabla \cdot \mathbf{B} = 0$$ +$$\nabla \times \mathbf{E} = -\frac{\partial \mathbf{B}}{\partial t}$$ +$$\nabla \times \mathbf{B} = \mu_0\mathbf{J} + \mu_0\epsilon_0\frac{\partial \mathbf{E}}{\partial t}$$ + +### Schrรถdinger Equation +$$i\hbar\frac{\partial}{\partial t}\Psi(\mathbf{r},t) = \hat{H}\Psi(\mathbf{r},t)$$ + +## Inline Math Examples + +Here are some inline mathematical expressions: + +- The golden ratio: $\phi = \frac{1 + \sqrt{5}}{2} \approx 1.618$ +- Euler's number: $e = \lim_{n \to \infty} \left(1 + \frac{1}{n}\right)^n$ +- Pi: $\pi = 4 \sum_{n=0}^{\infty} \frac{(-1)^n}{2n+1}$ +- Square root of 2: $\sqrt{2} = 1.41421356...$ + +## Fractions and Radicals + +Complex fraction: $\frac{\frac{a}{b} + \frac{c}{d}}{\frac{e}{f} - \frac{g}{h}}$ + +Nested radicals: $\sqrt{2 + \sqrt{3 + \sqrt{4 + \sqrt{5}}}}$ + +## Summations and Products + +### Geometric Series +$$\sum_{n=0}^{\infty} ar^n = \frac{a}{1-r} \quad \text{for } |r| < 1$$ + +### Product Notation +$$n! = \prod_{k=1}^{n} k$$ + +### Double Summation +$$\sum_{i=1}^{m} \sum_{j=1}^{n} a_{ij}$$ + +## Limits + +$$\lim_{x \to 0} \frac{\sin x}{x} = 1$$ + +$$\lim_{n \to \infty} \left(1 + \frac{x}{n}\right)^n = e^x$$ + +## Further Bracket Styles and Amounts + +- \( \mathrm{GL}_2(\mathbb{F}_7) \): Group of invertible matrices with entries in \(\mathbb{F}_7\). +- 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\} + \] +- Algebra: +\[ +x = \frac{-b \pm \sqrt{\,b^{2}-4ac\,}}{2a} +\] +- $100 and $12.99 are amounts, not LaTeX. +- I have $10, $3.99 and $x + y$ and $100x$. The amount is $2,000. +- Emma buys 2 cupcakes for $3 each and 1 cookie for $1.50. How much money does she spend in total? +- Maria has $20. She buys a notebook for $4.75 and a pack of pencils for $3.25. How much change does she receive? +- 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 ใŒ็ˆ†็™บใ—ใŸใจใใฎใ‚จใƒใƒซใ‚ฎใƒผใซๅŒนๆ•ตใ—ใพใ™ใ€‚ +- Algebra: \[ +x = \frac{-b \pm \sqrt{\,b^{2}-4ac\,}}{2a} +\] +- 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}$$ +- Spacer preceded by backslash: +\[ +\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}} +\] + +## Formulas in a Table + +| Area | Expression | Comment | +|------|------------|---------| +| **Algebra** | \[ +x = \frac{-b \pm \sqrt{\,b^{2}-4ac\,}}{2a} +\] | Quadratic formula | +| | \[ +(a+b)^{n} = \sum_{k=0}^{n}\binom{n}{k}\,a^{\,n-k}\,b^{\,k} +\] | Binomial theorem | +| | \(\displaystyle \prod_{k=1}^{n}k = n! \) | Factorial definition | +| **Geometry** | \( \mathbf{a}\cdot \mathbf{b} = \|\mathbf{a}\|\,\|\mathbf{b}\|\,\cos\theta \) | Dot product & angle | + +## No math (but chemical) + +Balanced chemical reaction with states: + +\[ +\ce{2H2(g) + O2(g) -> 2H2O(l)} +\] + +The standard enthalpy change for the reaction is: $\Delta H^\circ = \pu{-572 kJ mol^{-1}}$. + +--- + +*This document showcases various mathematical notation and formulas that can be rendered in markdown using LaTeX syntax.* +`; diff --git a/llama.cpp/tools/server/webui/tests/stories/fixtures/readme.ts b/llama.cpp/tools/server/webui/tests/stories/fixtures/readme.ts new file mode 100644 index 0000000..e8b573d --- /dev/null +++ b/llama.cpp/tools/server/webui/tests/stories/fixtures/readme.ts @@ -0,0 +1,136 @@ +// README Content +export const README_MD = String.raw` +# ๐Ÿš€ Awesome Web Framework + +[![npm version](https://img.shields.io/npm/v/awesome-framework.svg)](https://www.npmjs.com/package/awesome-framework) +[![Build Status](https://github.com/awesome/framework/workflows/CI/badge.svg)](https://github.com/awesome/framework/actions) +[![Coverage](https://codecov.io/gh/awesome/framework/branch/main/graph/badge.svg)](https://codecov.io/gh/awesome/framework) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +> A modern, fast, and flexible web framework for building scalable applications + +## โœจ Features + +- ๐ŸŽฏ **Type-Safe** - Full TypeScript support out of the box +- โšก **Lightning Fast** - Built on Vite for instant HMR +- ๐Ÿ“ฆ **Zero Config** - Works out of the box for most use cases +- ๐ŸŽจ **Flexible** - Unopinionated with sensible defaults +- ๐Ÿ”ง **Extensible** - Plugin system for custom functionality +- ๐Ÿ“ฑ **Responsive** - Mobile-first approach +- ๐ŸŒ **i18n Ready** - Built-in internationalization +- ๐Ÿ”’ **Secure** - Security best practices by default + +## ๐Ÿ“ฆ Installation + +${'```'}bash +npm install awesome-framework +# or +yarn add awesome-framework +# or +pnpm add awesome-framework +${'```'} + +## ๐Ÿš€ Quick Start + +### Create a new project + +${'```'}bash +npx create-awesome-app my-app +cd my-app +npm run dev +${'```'} + +### Basic Example + +${'```'}javascript +import { createApp } from 'awesome-framework'; + +const app = createApp({ + port: 3000, + middleware: ['cors', 'helmet', 'compression'] +}); + +app.get('/', (req, res) => { + res.json({ message: 'Hello World!' }); +}); + +app.listen(() => { + console.log('Server running on http://localhost:3000'); +}); +${'```'} + +## ๐Ÿ“– Documentation + +### Core Concepts + +- [Getting Started](https://docs.awesome.dev/getting-started) +- [Configuration](https://docs.awesome.dev/configuration) +- [Routing](https://docs.awesome.dev/routing) +- [Middleware](https://docs.awesome.dev/middleware) +- [Database](https://docs.awesome.dev/database) +- [Authentication](https://docs.awesome.dev/authentication) + +### Advanced Topics + +- [Performance Optimization](https://docs.awesome.dev/performance) +- [Deployment](https://docs.awesome.dev/deployment) +- [Testing](https://docs.awesome.dev/testing) +- [Security](https://docs.awesome.dev/security) + +## ๐Ÿ› ๏ธ Development + +### Prerequisites + +- Node.js >= 18 +- pnpm >= 8 + +### Setup + +${'```'}bash +git clone https://github.com/awesome/framework.git +cd framework +pnpm install +pnpm dev +${'```'} + +### Testing + +${'```'}bash +pnpm test # Run unit tests +pnpm test:e2e # Run end-to-end tests +pnpm test:watch # Run tests in watch mode +${'```'} + +## ๐Ÿค Contributing + +We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. + +### Contributors + + + + + +## ๐Ÿ“Š Benchmarks + +| Framework | Requests/sec | Latency (ms) | Memory (MB) | +|-----------|-------------|--------------|-------------| +| **Awesome** | **45,230** | **2.1** | **42** | +| Express | 28,450 | 3.5 | 68 | +| Fastify | 41,200 | 2.3 | 48 | +| Koa | 32,100 | 3.1 | 52 | + +*Benchmarks performed on MacBook Pro M2, Node.js 20.x* + +## ๐Ÿ“ License + +MIT ยฉ [Awesome Team](https://github.com/awesome) + +## ๐Ÿ™ Acknowledgments + +Special thanks to all our sponsors and contributors who make this project possible. + +--- + +**[Website](https://awesome.dev)** โ€ข **[Documentation](https://docs.awesome.dev)** โ€ข **[Discord](https://discord.gg/awesome)** โ€ข **[Twitter](https://twitter.com/awesomeframework)** +`; diff --git a/llama.cpp/tools/server/webui/tests/stories/fixtures/storybook-mocks.ts b/llama.cpp/tools/server/webui/tests/stories/fixtures/storybook-mocks.ts new file mode 100644 index 0000000..c40a746 --- /dev/null +++ b/llama.cpp/tools/server/webui/tests/stories/fixtures/storybook-mocks.ts @@ -0,0 +1,81 @@ +import { serverStore } from '$lib/stores/server.svelte'; +import { modelsStore } from '$lib/stores/models.svelte'; + +/** + * Mock server properties for Storybook testing + * This utility allows setting mock server configurations without polluting production code + */ +export function mockServerProps(props: Partial): void { + // Reset any pointer-events from previous tests (dropdown cleanup) + const body = document.querySelector('body'); + if (body) body.style.pointerEvents = ''; + + // Directly set the props for testing purposes + (serverStore as unknown as { props: ApiLlamaCppServerProps }).props = { + model_path: props.model_path || 'test-model', + modalities: { + vision: props.modalities?.vision ?? false, + audio: props.modalities?.audio ?? false + }, + ...props + } as ApiLlamaCppServerProps; + + // Set router mode role so activeModelId can be set + (serverStore as unknown as { props: ApiLlamaCppServerProps }).props.role = 'ROUTER'; + + // Also mock modelsStore methods for modality checking + const vision = props.modalities?.vision ?? false; + const audio = props.modalities?.audio ?? false; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (modelsStore as any).modelSupportsVision = () => vision; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (modelsStore as any).modelSupportsAudio = () => audio; + + // Mock models list with a test model so activeModelId can be resolved + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (modelsStore as any).models = [ + { + id: 'test-model', + name: 'Test Model', + model: 'test-model' + } + ]; + + // Mock selectedModelId + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (modelsStore as any).selectedModelId = 'test-model'; +} + +/** + * Reset server store to clean state for testing + */ +export function resetServerStore(): void { + (serverStore as unknown as { props: ApiLlamaCppServerProps }).props = { + model_path: '', + modalities: { + vision: false, + audio: false + } + } as ApiLlamaCppServerProps; + (serverStore as unknown as { error: string }).error = ''; + (serverStore as unknown as { loading: boolean }).loading = false; +} + +/** + * Common mock configurations for Storybook stories + */ +export const mockConfigs = { + visionOnly: { + modalities: { vision: true, audio: false } + }, + audioOnly: { + modalities: { vision: false, audio: true } + }, + bothModalities: { + modalities: { vision: true, audio: true } + }, + noModalities: { + modalities: { vision: false, audio: false } + } +} as const; diff --git a/llama.cpp/tools/server/webui/tests/unit/clipboard.test.ts b/llama.cpp/tools/server/webui/tests/unit/clipboard.test.ts new file mode 100644 index 0000000..d8ea489 --- /dev/null +++ b/llama.cpp/tools/server/webui/tests/unit/clipboard.test.ts @@ -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/llama.cpp/tools/server/webui/tests/unit/latex-protection.test.ts b/llama.cpp/tools/server/webui/tests/unit/latex-protection.test.ts new file mode 100644 index 0000000..84328db --- /dev/null +++ b/llama.cpp/tools/server/webui/tests/unit/latex-protection.test.ts @@ -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 <> and <>. 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: <>.'); + 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 <>, 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 <>?`); + 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 <> 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 <> 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('<>\n<> and <>'); + 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/llama.cpp/tools/server/webui/tests/unit/model-names.test.ts b/llama.cpp/tools/server/webui/tests/unit/model-names.test.ts new file mode 100644 index 0000000..40c5a0e --- /dev/null +++ b/llama.cpp/tools/server/webui/tests/unit/model-names.test.ts @@ -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); + }); +}); -- cgit v1.2.3