ui: Restructure repo to use tools/ui folder and ui / UI / llama-ui / LLAMA_UI naming (#23064)
* webui: Move static build output from `tools/server/public` to `build/ui` directory * refactor: Move to `tools/ui` * refactor: rename CMake variables and preprocessor defines - Rename LLAMA_BUILD_WEBUI -> LLAMA_BUILD_UI (old kept as deprecated) - Rename LLAMA_USE_PREBUILT_WEBUI -> LLAMA_USE_PREBUILT_UI (old kept as deprecated) - Backward compat: old vars auto-forward to new ones with DEPRECATION warning - Rename internal vars: WEBUI_SOURCE -> UI_SOURCE, WEBUI_SOURCE_DIR -> UI_SOURCE_DIR, etc. - Rename HF bucket: LLAMA_WEBUI_HF_BUCKET -> LLAMA_UI_HF_BUCKET - Emit both LLAMA_BUILD_WEBUI and LLAMA_BUILD_UI preprocessor defines - Emit both LLAMA_WEBUI_DEFAULT_ENABLED and LLAMA_UI_DEFAULT_ENABLED * refactor: rename CLI flags (--webui -> --ui) with backward compat - Add --ui/--no-ui (old --webui/--no-webui kept as deprecated aliases) - Add --ui-config (old --webui-config kept as deprecated alias) - Add --ui-config-file (old --webui-config-file kept as deprecated alias) - Add --ui-mcp-proxy/--no-ui-mcp-proxy (old --webui-mcp-proxy kept as deprecated) - Add new env vars: LLAMA_ARG_UI, LLAMA_ARG_UI_CONFIG, LLAMA_ARG_UI_CONFIG_FILE, LLAMA_ARG_UI_MCP_PROXY - C++ struct fields: params.ui, params.ui_config_json, params.ui_mcp_proxy added alongside old fields - Backward compat: old fields synced to new ones in g_params_to_internals * refactor: update C++ server internals with backward compat - Rename json_webui_settings -> json_ui_settings (both kept in server_context_meta) - Rename params.webui usage -> params.ui (both synced, old still works) - JSON API emits both "ui"/"ui_settings" and "webui"/"webui_settings" keys - Server routes use params.ui_mcp_proxy || params.webui_mcp_proxy - Preprocessor guards use #if defined(LLAMA_BUILD_UI) || defined(LLAMA_BUILD_WEBUI) * refactor: rename CI/CD workflows, artifacts, and build script - Rename webui-build.yml -> ui-build.yml; artifact webui-build -> ui-build - Rename webui-publish.yml -> ui-publish.yml; var HF_BUCKET_WEBUI_STATIC_OUTPUT -> HF_BUCKET_UI_STATIC_OUTPUT - Rename server-webui.yml -> server-ui.yml; job webui-build/checks -> ui-build/checks - Update server.yml: job/artifact refs webui-build -> ui-build - Update release.yml: all webui-build/publish refs -> ui-build/publish; HF_TOKEN_WEBUI_STATIC_OUTPUT -> HF_TOKEN_UI_STATIC_OUTPUT - Update server-self-hosted.yml: webui-build -> ui-build - Update build-self-hosted.yml: HF_WEBUI_VERSION -> HF_UI_VERSION - Rename webui-download.cmake -> ui-download.cmake (internal refs updated) - Update labeler.yml: server/webui -> server/ui path label * docs: update CODEOWNERS and server README docs - Update CODEOWNERS: team ggml-org/llama-webui -> ggml-org/llama-ui, path /tools/server/webui/ -> /tools/ui/ - Update server README.md: CLI tables show --ui flags with deprecated --webui aliases - Update server README-dev.md: "WebUI" -> "UI", paths updated to tools/ui/ * fix: Small fixes for UI build * fix: CMake.txt syntax * chore: Formatting * fix: `.editorconfig` for llama-ui * chore: Formatting * refactor: Use `APP_NAME` in Error route * refactor: Cleanup * refactor: Single migration service * make llama-ui a linkable target * fix: UI Build output * fix: Missing change * fix: separate llama-ui npm build output into build/tools/ui/dist subfolder + use cmake npm build instead of downloading ui-build.yml artifacts in CI * refactor: UI workflows cleanup --------- Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
This commit is contained in:
1 parent
49d1701bd2
commit
59778f0196
565 files changed
+1610
-694
No files matched your search
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import Page from '../../../src/routes/(chat)/+page.svelte';
|
||||
|
||||
let sidebarOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Test wrapper that provides necessary context providers for component testing.
|
||||
This mirrors the providers from +layout.svelte.
|
||||
-->
|
||||
<Tooltip.Provider>
|
||||
<Sidebar.Provider bind:open={sidebarOpen}>
|
||||
<Page />
|
||||
</Sidebar.Provider>
|
||||
</Tooltip.Provider>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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').first()).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import ChatMessage from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/ChatScreen/ChatMessage',
|
||||
component: ChatMessage,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
}
|
||||
});
|
||||
|
||||
// Mock messages for different scenarios
|
||||
const userMessage: DatabaseMessage = {
|
||||
id: '1',
|
||||
convId: 'conv-1',
|
||||
type: 'message',
|
||||
timestamp: Date.now() - 1000 * 60 * 5,
|
||||
role: 'user',
|
||||
content: 'What is the meaning of life, the universe, and everything?',
|
||||
parent: '',
|
||||
thinking: '',
|
||||
children: []
|
||||
};
|
||||
|
||||
const assistantMessage: DatabaseMessage = {
|
||||
id: '2',
|
||||
convId: 'conv-1',
|
||||
type: 'message',
|
||||
timestamp: Date.now() - 1000 * 60 * 3,
|
||||
role: 'assistant',
|
||||
content:
|
||||
'The answer to the ultimate question of life, the universe, and everything is **42**.\n\nThis comes from Douglas Adams\' "The Hitchhiker\'s Guide to the Galaxy," where a supercomputer named Deep Thought calculated this answer over 7.5 million years. However, the question itself was never properly formulated, which is why the answer seems meaningless without context.',
|
||||
parent: '1',
|
||||
thinking: '',
|
||||
children: []
|
||||
};
|
||||
|
||||
const assistantWithReasoning: DatabaseMessage = {
|
||||
id: '3',
|
||||
convId: 'conv-1',
|
||||
type: 'message',
|
||||
timestamp: Date.now() - 1000 * 60 * 2,
|
||||
role: 'assistant',
|
||||
content: "Here's the concise answer, now that I've thought it through carefully for you.",
|
||||
parent: '1',
|
||||
thinking:
|
||||
"Let's consider the user's question step by step:\\n\\n1. Identify the core problem\\n2. Evaluate relevant information\\n3. Formulate a clear answer\\n\\nFollowing this process ensures the final response stays focused and accurate.",
|
||||
children: []
|
||||
};
|
||||
const rawOutputMessage: DatabaseMessage = {
|
||||
id: '6',
|
||||
convId: 'conv-1',
|
||||
type: 'message',
|
||||
timestamp: Date.now() - 1000 * 60,
|
||||
role: 'assistant',
|
||||
content:
|
||||
'<|channel|>analysis<|message|>User greeted me. Initiating overcomplicated analysis: Is this a trap? No, just a normal hello. Respond calmly, act like a helpful assistant, and do not start explaining quantum physics again. Confidence 0.73. Engaging socially acceptable greeting protocol...<|end|>Hello there! How can I help you today?',
|
||||
parent: '1',
|
||||
thinking: '',
|
||||
children: []
|
||||
};
|
||||
|
||||
let processingMessage = $state({
|
||||
id: '4',
|
||||
convId: 'conv-1',
|
||||
type: 'message',
|
||||
timestamp: 0, // No timestamp = processing
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
parent: '1',
|
||||
thinking: '',
|
||||
children: []
|
||||
});
|
||||
|
||||
let streamingMessage = $state({
|
||||
id: '5',
|
||||
convId: 'conv-1',
|
||||
type: 'message',
|
||||
timestamp: 0, // No timestamp = streaming
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
parent: '1',
|
||||
thinking: '',
|
||||
children: []
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="User"
|
||||
args={{
|
||||
message: userMessage
|
||||
}}
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings.svelte');
|
||||
settingsStore.updateConfig('showRawOutputSwitch', false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="Assistant"
|
||||
args={{
|
||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
|
||||
message: assistantMessage
|
||||
}}
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings.svelte');
|
||||
settingsStore.updateConfig('showRawOutputSwitch', false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="AssistantWithReasoning"
|
||||
args={{
|
||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
|
||||
message: assistantWithReasoning
|
||||
}}
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings.svelte');
|
||||
settingsStore.updateConfig('showRawOutputSwitch', false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="RawLlmOutput"
|
||||
args={{
|
||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
|
||||
message: rawOutputMessage
|
||||
}}
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings.svelte');
|
||||
settingsStore.updateConfig('showRawOutputSwitch', true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="WithReasoningContent"
|
||||
args={{
|
||||
message: streamingMessage
|
||||
}}
|
||||
asChild
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings.svelte');
|
||||
settingsStore.updateConfig('showRawOutputSwitch', 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();
|
||||
}}
|
||||
>
|
||||
<div class="w-[56rem]">
|
||||
<ChatMessage message={streamingMessage} />
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
name="Processing"
|
||||
args={{
|
||||
message: processingMessage
|
||||
}}
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings.svelte');
|
||||
settingsStore.updateConfig('showRawOutputSwitch', 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));
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import ChatScreenForm from '$lib/components/app/chat/ChatScreen/ChatScreenForm.svelte';
|
||||
import { expect } from 'storybook/test';
|
||||
import jpgAsset from './fixtures/assets/1.jpg?url';
|
||||
import svgAsset from './fixtures/assets/hf-logo.svg?url';
|
||||
import pdfAsset from './fixtures/assets/example.pdf?raw';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/ChatScreen/ChatScreenForm',
|
||||
component: ChatScreenForm,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
}
|
||||
});
|
||||
|
||||
let fileAttachments = $state([
|
||||
{
|
||||
id: '1',
|
||||
name: '1.jpg',
|
||||
type: 'image/jpeg',
|
||||
size: 44891,
|
||||
preview: jpgAsset,
|
||||
file: new File([''], '1.jpg', { type: 'image/jpeg' })
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'hf-logo.svg',
|
||||
type: 'image/svg+xml',
|
||||
size: 1234,
|
||||
preview: svgAsset,
|
||||
file: new File([''], 'hf-logo.svg', { type: 'image/svg+xml' })
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'example.pdf',
|
||||
type: 'application/pdf',
|
||||
size: 351048,
|
||||
file: new File([pdfAsset], 'example.pdf', { type: 'application/pdf' })
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="Default"
|
||||
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
play={async ({ canvas, userEvent }) => {
|
||||
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');
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story name="Loading" args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', isLoading: true }} />
|
||||
|
||||
<Story
|
||||
name="FileAttachments"
|
||||
args={{
|
||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
|
||||
uploadedFiles: fileAttachments
|
||||
}}
|
||||
play={async ({ canvas }) => {
|
||||
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();
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Introduction" />
|
||||
|
||||
# llama.cpp Web UI
|
||||
|
||||
Welcome to the **llama-ui** component library! This Storybook showcases the components used in the modern web interface for the llama-server.
|
||||
|
||||
## 🚀 About This Project
|
||||
|
||||
Llama UI is a modern web interface for the llama-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
|
||||
@@ -0,0 +1,132 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import { expect } from 'storybook/test';
|
||||
import { MarkdownContent } from '$lib/components/app';
|
||||
import { AI_TUTORIAL_MD } from './fixtures/ai-tutorial.js';
|
||||
import { API_DOCS_MD } from './fixtures/api-docs.js';
|
||||
import { BLOG_POST_MD } from './fixtures/blog-post.js';
|
||||
import { DATA_ANALYSIS_MD } from './fixtures/data-analysis.js';
|
||||
import { README_MD } from './fixtures/readme.js';
|
||||
import { MATH_FORMULAS_MD } from './fixtures/math-formulas.js';
|
||||
import { EMPTY_MD } from './fixtures/empty.js';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/MarkdownContent',
|
||||
component: MarkdownContent,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story name="Empty" args={{ content: EMPTY_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }} />
|
||||
|
||||
<Story
|
||||
name="AI Tutorial"
|
||||
args={{ content: AI_TUTORIAL_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="API Documentation"
|
||||
args={{ content: API_DOCS_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="Technical Blog"
|
||||
args={{ content: BLOG_POST_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="Data Analysis"
|
||||
args={{ content: DATA_ANALYSIS_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="README file"
|
||||
args={{ content: README_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="Math Formulas"
|
||||
args={{ content: MATH_FORMULAS_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="URL Links"
|
||||
args={{
|
||||
content: `# URL Links Test
|
||||
|
||||
Here are some example URLs that should open in new tabs:
|
||||
|
||||
- [Hugging Face Homepage](https://huggingface.co)
|
||||
- [GitHub Repository](https://github.com/ggml-org/llama.cpp)
|
||||
- [OpenAI Website](https://openai.com)
|
||||
- [Google Search](https://www.google.com)
|
||||
|
||||
You can also test inline links like https://example.com or https://docs.python.org.
|
||||
|
||||
All links should have \`target="_blank"\` and \`rel="noopener noreferrer"\` attributes for security.`,
|
||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]'
|
||||
}}
|
||||
play={async (context) => {
|
||||
const { canvasElement } = context;
|
||||
// Wait for component to render
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Find all links in the rendered content
|
||||
const links = (canvasElement as HTMLElement).querySelectorAll(
|
||||
'a[href]'
|
||||
) as NodeListOf<HTMLAnchorElement>;
|
||||
const linkList = Array.from(links) as HTMLAnchorElement[];
|
||||
|
||||
// Test that we have the expected number of links
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
|
||||
// Test each link for proper attributes
|
||||
links.forEach((link: HTMLAnchorElement) => {
|
||||
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 = linkList.find(
|
||||
(link) => link.getAttribute('href') === 'https://huggingface.co'
|
||||
);
|
||||
expect(hugginFaceLink).toBeTruthy();
|
||||
expect(hugginFaceLink?.textContent).toBe('Hugging Face Homepage');
|
||||
|
||||
const githubLink = linkList.find(
|
||||
(link) => link.getAttribute('href') === 'https://github.com/ggml-org/llama.cpp'
|
||||
);
|
||||
expect(githubLink).toBeTruthy();
|
||||
expect(githubLink?.textContent).toBe('GitHub Repository');
|
||||
|
||||
const openaiLink = linkList.find((link) => link.getAttribute('href') === 'https://openai.com');
|
||||
expect(openaiLink).toBeTruthy();
|
||||
expect(openaiLink?.textContent).toBe('OpenAI Website');
|
||||
|
||||
const googleLink = linkList.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 = linkList.find(
|
||||
(link) => link.getAttribute('href') === 'https://example.com'
|
||||
);
|
||||
expect(exampleLink).toBeTruthy();
|
||||
|
||||
const pythonDocsLink = linkList.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`);
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import SidebarNavigation from '$lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte';
|
||||
import { waitFor } from 'storybook/test';
|
||||
import { screen } from 'storybook/test';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/SidebarNavigation',
|
||||
component: SidebarNavigation,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
|
||||
let sidebarOpen = $state(true);
|
||||
|
||||
// Mock conversations for the sidebar
|
||||
const mockConversations: DatabaseConversation[] = [
|
||||
{
|
||||
id: 'conv-1',
|
||||
name: 'Getting Started with AI',
|
||||
lastModified: Date.now() - 1000 * 60 * 5, // 5 minutes ago
|
||||
currNode: 'msg-1'
|
||||
},
|
||||
{
|
||||
id: 'conv-2',
|
||||
name: 'Python Programming Help',
|
||||
lastModified: Date.now() - 1000 * 60 * 60 * 2, // 2 hours ago
|
||||
currNode: 'msg-2'
|
||||
},
|
||||
{
|
||||
id: 'conv-3',
|
||||
name: 'Creative Writing Ideas',
|
||||
lastModified: Date.now() - 1000 * 60 * 60 * 24, // 1 day ago
|
||||
currNode: 'msg-3'
|
||||
},
|
||||
{
|
||||
id: 'conv-4',
|
||||
name: 'This is a very long conversation title that should be truncated properly when displayed',
|
||||
lastModified: Date.now() - 1000 * 60 * 60 * 24 * 3, // 3 days ago
|
||||
currNode: 'msg-4'
|
||||
},
|
||||
{
|
||||
id: 'conv-5',
|
||||
name: 'Math Problem Solving',
|
||||
lastModified: Date.now() - 1000 * 60 * 60 * 24 * 7, // 1 week ago
|
||||
currNode: 'msg-5'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<Story
|
||||
asChild
|
||||
name="Default"
|
||||
play={async () => {
|
||||
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
|
||||
|
||||
waitFor(() => setTimeout(() => {
|
||||
conversationsStore.conversations = mockConversations;
|
||||
}, 0));
|
||||
}}
|
||||
>
|
||||
<Sidebar.Provider bind:open={sidebarOpen}>
|
||||
<div class="flex-column h-full h-screen w-72 bg-background">
|
||||
<SidebarNavigation />
|
||||
</div>
|
||||
</Sidebar.Provider>
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
asChild
|
||||
name="SearchActive"
|
||||
play={async ({ userEvent }) => {
|
||||
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
|
||||
|
||||
waitFor(() => setTimeout(() => {
|
||||
conversationsStore.conversations = mockConversations;
|
||||
}, 0));
|
||||
|
||||
const searchTrigger = screen.getByText('Search');
|
||||
userEvent.click(searchTrigger);
|
||||
}}
|
||||
>
|
||||
<Sidebar.Provider bind:open={sidebarOpen}>
|
||||
<div class="flex-column h-full h-screen w-72 bg-background">
|
||||
<SidebarNavigation />
|
||||
</div>
|
||||
</Sidebar.Provider>
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
asChild
|
||||
name="Empty"
|
||||
play={async () => {
|
||||
// Mock empty conversations store
|
||||
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
|
||||
conversationsStore.conversations = [];
|
||||
}}
|
||||
>
|
||||
<Sidebar.Provider bind:open={sidebarOpen}>
|
||||
<div class="flex-column h-full h-screen w-72 bg-background">
|
||||
<SidebarNavigation />
|
||||
</div>
|
||||
</Sidebar.Provider>
|
||||
</Story>
|
||||
@@ -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<Message[]>([]);
|
||||
let socket: ReturnType<typeof io>;
|
||||
|
||||
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)*
|
||||
`;
|
||||
@@ -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)
|
||||
`;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 798 KiB |
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M230.721 172.7C230.183 170.673 229.313 168.75 228.146 167.008C228.396 166.091 228.587 165.159 228.714 164.217C229.543 158.241 227.471 152.77 223.567 148.537C221.452 146.225 219.185 144.698 216.784 143.761C218.36 137.018 219.157 130.117 219.161 123.193C219.161 120.03 218.982 116.932 218.682 113.88C218.526 112.356 218.337 110.836 218.115 109.32C217.428 104.847 216.408 100.431 215.064 96.11C214.183 93.2707 213.164 90.476 212.01 87.736C210.281 83.6782 208.262 79.75 205.969 75.982C204.465 73.475 202.827 71.0508 201.062 68.72C200.197 67.543 199.296 66.3938 198.358 65.274C195.58 61.898 192.561 58.7277 189.325 55.788C188.25 54.7997 187.145 53.8453 186.01 52.926C184.893 51.9943 183.751 51.0927 182.586 50.222C180.241 48.4766 177.818 46.8392 175.324 45.315C161.543 36.945 145.382 32.145 128.109 32.145C77.817 32.145 37.057 72.907 37.057 123.196C37.055 130.208 37.867 137.196 39.477 144.02C37.317 144.958 35.247 146.42 33.327 148.535C29.424 152.766 27.351 158.217 28.18 164.193C28.306 165.142 28.495 166.082 28.747 167.006C27.5811 168.749 26.7117 170.673 26.174 172.7C24.974 177.261 25.369 181.374 26.894 184.978C25.236 189.688 25.65 194.704 27.809 199.065C29.379 202.25 31.626 204.714 34.396 206.916C37.689 209.534 41.811 211.758 46.783 213.892C52.715 216.422 59.956 218.799 63.249 219.671C71.755 221.873 79.911 223.269 88.177 223.337C99.954 223.446 110.096 220.677 117.357 213.59C120.924 214.027 124.515 214.246 128.109 214.244C131.906 214.236 135.699 213.997 139.467 213.529C146.711 220.661 156.892 223.455 168.712 223.343C176.977 223.277 185.133 221.881 193.617 219.676C196.932 218.804 204.17 216.427 210.105 213.897C215.077 211.76 219.199 209.536 222.514 206.922C225.263 204.719 227.508 202.256 229.079 199.071C231.26 194.709 231.652 189.693 230.017 184.983C231.527 181.379 231.92 177.257 230.721 172.7ZM222.281 184.673C223.952 187.844 224.059 191.427 222.585 194.764C220.349 199.821 214.795 203.805 204.008 208.082C197.3 210.742 191.158 212.443 191.104 212.458C182.232 214.759 174.208 215.928 167.262 215.928C155.76 215.928 147.201 212.754 141.773 206.486C132.594 208.05 123.222 208.103 114.026 206.644C108.591 212.808 100.081 215.928 88.676 215.928C81.729 215.928 73.706 214.759 64.833 212.458C64.779 212.443 58.639 210.742 51.929 208.082C41.143 203.805 35.587 199.824 33.352 194.764C31.878 191.427 31.985 187.844 33.656 184.673C33.81 184.378 33.976 184.091 34.153 183.813C33.1516 182.309 32.4799 180.61 32.182 178.827C31.8842 177.045 31.967 175.22 32.425 173.472C33.089 170.949 34.46 168.851 36.322 167.344C35.425 165.87 34.8365 164.23 34.592 162.522C34.056 158.808 35.289 155.1 38.062 152.076C40.222 149.723 43.275 148.428 46.655 148.428H46.745C44.1965 140.259 42.9044 131.75 42.913 123.193C42.913 76.522 80.749 38.683 127.427 38.683C174.104 38.683 211.94 76.518 211.94 123.193C211.947 131.773 210.646 140.304 208.081 148.492C208.489 148.452 208.889 148.432 209.282 148.431C212.662 148.431 215.716 149.726 217.874 152.079C220.647 155.1 221.881 158.811 221.344 162.525C221.1 164.233 220.511 165.873 219.615 167.347C221.477 168.854 222.849 170.952 223.512 173.475C223.97 175.223 224.053 177.048 223.755 178.831C223.458 180.613 222.786 182.312 221.784 183.816C221.961 184.091 222.129 184.378 222.281 184.673Z" fill="white"/>
|
||||
<path d="M221.784 183.816C222.786 182.312 223.458 180.613 223.756 178.831C224.053 177.048 223.97 175.223 223.512 173.475C222.848 170.952 221.476 168.854 219.615 167.347C220.512 165.873 221.1 164.233 221.344 162.525C221.881 158.811 220.648 155.103 217.874 152.079C215.716 149.726 212.662 148.431 209.282 148.431C208.889 148.431 208.489 148.452 208.081 148.492C210.643 140.304 211.942 131.774 211.933 123.195C211.933 76.5231 174.097 38.6851 127.424 38.6851C80.75 38.6851 42.9099 76.5191 42.9099 123.195C42.9015 131.752 44.1936 140.261 46.742 148.43H46.6519C43.2719 148.43 40.219 149.724 38.06 152.077C35.287 155.098 34.0529 158.81 34.5899 162.523C34.8346 164.231 35.4231 165.872 36.3199 167.346C34.4579 168.852 33.086 170.95 32.422 173.473C31.9642 175.222 31.8817 177.047 32.1799 178.83C32.4781 180.612 33.1501 182.312 34.1519 183.816C33.9739 184.094 33.8099 184.381 33.6549 184.676C31.9849 187.847 31.877 191.43 33.352 194.767C35.588 199.824 41.1419 203.808 51.9289 208.085C58.6359 210.745 64.779 212.446 64.833 212.461C73.705 214.762 81.729 215.931 88.675 215.931C100.081 215.931 108.591 212.811 114.026 206.647C123.222 208.106 132.594 208.052 141.773 206.489C147.201 212.757 155.76 215.931 167.262 215.931C174.208 215.931 182.232 214.762 191.103 212.461C191.158 212.446 197.298 210.745 204.008 208.085C214.795 203.808 220.35 199.824 222.585 194.767C224.059 191.43 223.952 187.847 222.281 184.676C222.129 184.379 221.961 184.091 221.784 183.816ZM110.137 196.997C109.669 197.815 109.168 198.614 108.635 199.391C107.23 201.448 105.382 203.02 103.237 204.188C99.1369 206.424 93.947 207.205 88.675 207.205C80.346 207.205 71.808 205.256 67.023 204.015C66.787 203.954 37.689 195.735 41.373 188.739C41.993 187.562 43.0129 187.092 44.2979 187.092C49.4849 187.092 58.9299 194.816 62.9889 194.816C63.8959 194.816 64.5359 194.43 64.7969 193.488C66.5269 187.284 38.5039 184.676 40.8639 175.692C41.2799 174.102 42.41 173.456 43.998 173.456C50.856 173.455 66.248 185.516 69.467 185.516C69.714 185.516 69.8909 185.443 69.9869 185.291C70.0009 185.268 70.015 185.246 70.028 185.222C71.539 182.727 70.6719 180.913 60.3209 174.573L59.3269 173.968C47.9359 167.074 39.9409 162.925 44.4879 157.975C45.0109 157.404 45.7529 157.151 46.6539 157.151C47.7219 157.151 49.0149 157.508 50.4389 158.108C56.4549 160.645 64.793 167.564 68.276 170.581C68.8239 171.057 69.3683 171.538 69.9089 172.022C69.9089 172.022 74.319 176.608 76.985 176.608C77.599 176.608 78.1199 176.366 78.4729 175.768C80.364 172.58 60.9099 157.838 59.8129 151.755C59.0689 147.634 60.3349 145.546 62.6749 145.546C63.7879 145.546 65.1459 146.02 66.6449 146.971C71.2949 149.922 80.2729 165.35 83.5599 171.352C84.6619 173.363 86.5429 174.213 88.2379 174.213C91.6009 174.213 94.2299 170.87 88.5459 166.622C80.0029 160.23 83.001 149.782 87.078 149.139C87.252 149.111 87.4279 149.097 87.6029 149.097C91.3109 149.097 92.9459 155.486 92.9459 155.486C92.9459 155.486 97.7399 167.524 105.975 175.753C113.447 183.222 114.491 189.351 110.137 196.997ZM136.766 198.407L136.339 198.458L135.611 198.541C135.228 198.581 134.844 198.619 134.459 198.654L134.084 198.688L133.741 198.717L133.255 198.756L132.718 198.795L132.182 198.83L132.063 198.838C131.923 198.846 131.783 198.855 131.641 198.862L131.462 198.872C131.296 198.881 131.13 198.889 130.962 198.896L130.381 198.921L129.854 198.939L129.502 198.949H129.323C129.213 198.949 129.104 198.955 128.994 198.956H128.82C128.71 198.956 128.601 198.956 128.491 198.961L128.043 198.967H127.418C126.927 198.967 126.437 198.962 125.949 198.952L125.553 198.943C125.44 198.943 125.327 198.938 125.216 198.934L124.796 198.922L124.275 198.902L123.805 198.881L123.684 198.876L123.237 198.853C123.112 198.846 122.989 198.84 122.865 198.831L122.576 198.814C122.213 198.791 121.85 198.766 121.487 198.738L121.107 198.707C120.947 198.695 120.787 198.68 120.628 198.666C120.441 198.65 120.254 198.632 120.067 198.614C119.754 198.585 119.441 198.553 119.128 198.519H119.113C123.683 188.324 121.372 178.802 112.137 169.575C106.08 163.526 102.051 154.594 101.215 152.633C99.5229 146.828 95.045 140.375 87.608 140.375C86.979 140.375 86.351 140.425 85.73 140.523C82.472 141.036 79.624 142.911 77.592 145.733C75.396 143.002 73.262 140.831 71.332 139.605C68.422 137.76 65.5179 136.824 62.6889 136.824C59.1579 136.824 56.0019 138.274 53.8019 140.904L53.7459 140.971C53.7039 140.798 53.6639 140.625 53.6229 140.451L53.6179 140.428C53.1992 138.638 52.8477 136.833 52.5639 135.016C52.5639 135.004 52.5639 134.992 52.5579 134.98C52.5359 134.843 52.5159 134.705 52.4949 134.568C52.4334 134.162 52.3757 133.755 52.3219 133.348C52.2979 133.163 52.2719 132.978 52.2489 132.793L52.1809 132.238C52.1589 132.053 52.1409 131.885 52.1209 131.709L52.115 131.665C52.0351 130.945 51.9651 130.225 51.9049 129.503L51.8829 129.226L51.8479 128.754C51.8379 128.625 51.8279 128.495 51.8209 128.365C51.8209 128.334 51.8159 128.304 51.8149 128.275C51.7895 127.913 51.7678 127.55 51.7499 127.187C51.7399 126.998 51.7299 126.81 51.7219 126.62L51.7019 126.124L51.6969 125.974L5Line truncated
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M203.21 123.685V123.194C203.21 81.34 169.292 47.411 127.435 47.411C85.5791 47.411 51.648 81.342 51.648 123.194V123.358C51.646 123.467 51.645 123.576 51.648 123.685C51.6529 123.848 51.6546 124.011 51.653 124.174L51.6581 124.534L51.661 124.663C51.661 124.723 51.6631 124.782 51.6651 124.842C51.6681 124.937 51.67 125.033 51.67 125.128L51.681 125.517L51.697 125.974L51.702 126.124L51.722 126.597V126.62C51.73 126.805 51.7401 126.989 51.7491 127.173L51.75 127.187C51.76 127.375 51.7701 127.564 51.7821 127.753C51.7921 127.927 51.802 128.101 51.815 128.275L51.8171 128.306C51.8258 128.455 51.8358 128.605 51.847 128.754L51.85 128.794L51.883 129.226L51.8861 129.254C51.8921 129.338 51.898 129.422 51.906 129.503C51.9658 130.224 52.0355 130.945 52.1151 131.664L52.12 131.709L52.181 132.238L52.2491 132.793L52.299 133.17L52.322 133.347C52.3753 133.755 52.433 134.162 52.495 134.568L52.4991 134.595L52.558 134.979C52.8435 136.808 53.1971 138.626 53.618 140.429L53.6231 140.451L53.655 140.586L53.746 140.971L53.802 140.904C56.002 138.274 59.158 136.824 62.689 136.824C65.519 136.824 68.4221 137.76 71.3321 139.605C73.2621 140.831 75.3961 143.002 77.5921 145.733C79.6241 142.911 82.4721 141.035 85.7301 140.523C86.3513 140.425 86.9792 140.376 87.6081 140.375C95.0441 140.375 99.523 146.828 101.215 152.633C102.051 154.594 106.08 163.526 112.156 169.568C121.392 178.795 123.703 188.316 119.132 198.511H119.148C119.459 198.546 119.772 198.578 120.087 198.607C120.274 198.625 120.46 198.643 120.648 198.659L120.714 198.665L121.127 198.7L121.507 198.73C121.869 198.758 122.232 198.784 122.596 198.807L122.885 198.824L123.114 198.838L123.256 198.846L123.703 198.869L123.825 198.874L124.294 198.895L124.816 198.915L125.235 198.927L125.305 198.929C125.394 198.933 125.483 198.936 125.572 198.936L125.668 198.939C126.258 198.953 126.847 198.96 127.437 198.959H128.063L128.51 198.954C128.62 198.949 128.729 198.949 128.84 198.949H129.014L129.165 198.945C129.224 198.943 129.283 198.941 129.343 198.941H129.522L129.873 198.932L130.401 198.914L130.982 198.888C131.15 198.882 131.316 198.873 131.482 198.865L131.661 198.854L131.927 198.84L132.083 198.831L132.201 198.823L132.738 198.788L133.274 198.749L133.761 198.71L134.103 198.681L134.479 198.647C135.107 198.591 135.733 198.525 136.359 198.45L136.786 198.399C132.287 188.247 134.616 178.767 143.813 169.577C149.876 163.519 153.905 154.587 154.745 152.625C156.438 146.821 160.914 140.368 168.352 140.368C168.981 140.368 169.61 140.418 170.231 140.516C173.486 141.028 176.334 142.904 178.369 145.726C180.566 142.996 182.699 140.823 184.63 139.597C187.539 137.753 190.445 136.817 193.272 136.817C196.388 136.817 199.212 137.947 201.345 140.02C201.384 139.851 201.422 139.682 201.459 139.512L201.568 139.006C201.607 138.821 201.646 138.636 201.683 138.451C201.749 138.124 201.815 137.797 201.878 137.467C201.944 137.125 202.007 136.781 202.067 136.437L202.098 136.251C202.117 136.141 202.135 136.031 202.156 135.92C202.19 135.748 202.218 135.576 202.246 135.402L202.257 135.336L202.328 134.883L202.398 134.424V134.42C202.449 134.081 202.497 133.742 202.542 133.403L202.553 133.319L202.616 132.841L202.667 132.433L202.757 131.629L202.792 131.306L202.801 131.218C202.82 131.044 202.838 130.87 202.854 130.696V130.682C202.867 130.544 202.881 130.405 202.893 130.266C202.964 129.478 203.024 128.686 203.072 127.891C203.081 127.761 203.088 127.63 203.096 127.499V127.493L203.122 127.002L203.128 126.892C203.144 126.56 203.158 126.228 203.169 125.896V125.884L203.174 125.754C203.179 125.645 203.183 125.535 203.183 125.425L203.185 125.381C203.189 125.278 203.193 125.172 203.193 125.067L203.196 124.977C203.199 124.872 203.202 124.768 203.202 124.663L203.204 124.574C203.207 124.441 203.21 124.307 203.21 124.174V123.685ZM108.638 199.391C114.64 190.59 114.214 183.984 105.98 175.754C97.7441 167.523 92.951 155.487 92.951 155.487C92.951 155.487 91.1621 148.496 87.0821 149.138C83.0021 149.78 80.0091 160.227 88.5521 166.622C97.0941 173.017 86.8521 177.353 83.5641 171.352C80.2761 165.35 71.299 149.923 66.645 146.972C61.991 144.021 58.718 145.675 59.815 151.757C60.36 154.776 65.4281 159.929 70.1631 164.743C74.9671 169.627 79.428 174.163 78.474 175.768C76.581 178.955 69.9141 172.023 69.9141 172.023C69.9141 172.023 49.038 153.025 44.494 157.976C40.304 162.539 46.765 166.418 56.7211 172.397C57.5671 172.905 58.4391 173.429 59.3321 173.969C70.7231 180.865 71.609 182.684 69.992 185.293C69.395 186.257 65.582 183.968 60.892 181.153C52.897 176.352 42.3551 170.023 40.8661 175.688C39.5781 180.591 47.334 183.595 54.368 186.32C60.228 188.59 65.5881 190.666 64.7991 193.484C63.9821 196.406 59.5531 193.969 54.7121 191.305C49.2771 188.314 43.3221 185.038 41.3731 188.735C37.6901 195.725 66.7831 203.954 67.0231 204.015C76.4231 206.453 100.295 211.619 108.638 199.391ZM147.303 199.391C141.301 190.59 141.727 183.984 149.962 175.754C158.197 167.523 162.99 155.487 162.99 155.487C162.99 155.487 164.779 148.496 168.859 149.138C172.939 149Line truncated
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M152.047 102.567C153.229 102.985 154.108 104.257 154.944 105.468C156.074 107.104 157.126 108.627 158.74 107.769C160.644 106.756 162.205 105.202 163.225 103.302C164.246 101.402 164.681 99.2427 164.475 97.096C164.321 95.4908 163.813 93.9398 162.987 92.5548C162.161 91.1697 161.038 89.985 159.7 89.0862C158.361 88.1874 156.839 87.5968 155.245 87.3569C153.65 87.117 152.022 87.2339 150.478 87.699C148.934 88.1639 147.513 88.9653 146.316 90.0455C145.119 91.1257 144.176 92.4578 143.556 93.946C142.936 95.4342 142.653 97.0415 142.728 98.652C142.804 100.263 143.235 101.836 143.992 103.26C144.74 104.667 146.4 104.003 148.152 103.302C149.525 102.753 150.956 102.181 152.047 102.567ZM100.672 102.567C99.49 102.985 98.611 104.258 97.775 105.468C96.645 107.105 95.592 108.627 93.979 107.769C91.5845 106.501 89.7482 104.386 88.8278 101.838C87.9075 99.2895 87.9692 96.4896 89.0008 93.9841C90.0324 91.4786 91.9601 89.4471 94.408 88.2855C96.856 87.1239 99.6488 86.9156 102.242 87.701C104.307 88.3228 106.141 89.5427 107.513 91.2065C108.885 92.8704 109.732 94.9035 109.949 97.049C110.165 99.1945 109.74 101.356 108.728 103.26C107.979 104.667 106.319 104.003 104.567 103.303C103.193 102.753 101.764 102.181 100.672 102.567ZM144.099 149.318C152.242 142.903 155.233 132.429 155.233 125.977C155.233 120.877 151.802 122.482 146.309 125.202L145.999 125.355C140.957 127.852 134.245 131.177 126.877 131.177C119.508 131.177 112.796 127.852 107.755 125.354C102.084 122.545 98.527 120.783 98.527 125.978C98.527 132.634 101.709 143.563 110.443 149.912C111.596 147.573 113.219 145.497 115.211 143.813C117.202 142.129 119.52 140.874 122.018 140.126C122.89 139.866 123.788 141.367 124.707 142.904C125.594 144.386 126.501 145.902 127.423 145.902C128.406 145.902 129.371 144.408 130.314 142.95C131.299 141.425 132.26 139.94 133.189 140.237C137.864 141.738 141.775 144.993 144.099 149.318Z" fill="#32343D"/>
|
||||
<path d="M144.097 149.317C139.856 152.659 134.219 154.9 126.878 154.9C119.981 154.9 114.587 152.922 110.443 149.911C111.596 147.572 113.219 145.495 115.211 143.812C117.202 142.128 119.52 140.873 122.018 140.125C123.73 139.614 125.545 145.901 127.423 145.901C129.433 145.901 131.37 139.655 133.189 140.236C137.863 141.738 141.773 144.993 144.097 149.317Z" fill="#FF323D"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M81.2 111.64C80.2312 112.288 79.1173 112.687 77.9572 112.801C76.7971 112.916 75.6267 112.742 74.55 112.295C73.6893 111.94 72.9072 111.418 72.2488 110.759C71.5903 110.101 71.0684 109.319 70.713 108.458C70.267 107.381 70.0935 106.211 70.2082 105.051C70.3228 103.891 70.7219 102.777 71.37 101.808C72.1488 100.642 73.2558 99.7333 74.5512 99.1967C75.8466 98.6601 77.272 98.5197 78.6471 98.7935C80.0223 99.0672 81.2853 99.7427 82.2764 100.734C83.2675 101.726 83.9422 102.99 84.215 104.365C84.4883 105.74 84.3477 107.165 83.8113 108.46C83.2748 109.755 82.3654 110.861 81.2 111.64ZM182.613 111.64C181.644 112.288 180.53 112.687 179.37 112.801C178.209 112.916 177.039 112.742 175.962 112.295C175.101 111.939 174.319 111.418 173.661 110.759C173.003 110.101 172.481 109.319 172.125 108.458C171.68 107.381 171.507 106.211 171.621 105.051C171.736 103.891 172.135 102.777 172.782 101.808C173.364 100.936 174.133 100.205 175.032 99.6658C175.931 99.1269 176.938 98.7942 177.981 98.6917C179.025 98.5891 180.078 98.7193 181.064 99.0728C182.051 99.4264 182.947 99.9944 183.688 100.736C184.68 101.727 185.355 102.99 185.628 104.365C185.902 105.74 185.761 107.165 185.224 108.46C184.687 109.755 183.779 110.861 182.613 111.64Z" fill="#FFAD03"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 34 KiB |
@@ -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)*
|
||||
`;
|
||||
@@ -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)*
|
||||
`;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Empty state
|
||||
export const EMPTY_MD = '';
|
||||
@@ -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.*
|
||||
`;
|
||||
@@ -0,0 +1,136 @@
|
||||
// README Content
|
||||
export const README_MD = String.raw`
|
||||
# 🚀 Awesome Web Framework
|
||||
|
||||
[](https://www.npmjs.com/package/awesome-framework)
|
||||
[](https://github.com/awesome/framework/actions)
|
||||
[](https://codecov.io/gh/awesome/framework)
|
||||
[](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
|
||||
|
||||
<a href="https://github.com/awesome/framework/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=awesome/framework" />
|
||||
</a>
|
||||
|
||||
## 📊 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)**
|
||||
`;
|
||||
@@ -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<ApiLlamaCppServerProps>): 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;
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deriveAgenticSections, hasAgenticContent } from '$lib/utils/agentic';
|
||||
import { AgenticSectionType, MessageRole } from '$lib/enums';
|
||||
import type { DatabaseMessage } from '$lib/types/database';
|
||||
import type { ApiChatCompletionToolCall } from '$lib/types/api';
|
||||
|
||||
function makeAssistant(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
|
||||
return {
|
||||
id: overrides.id ?? 'ast-1',
|
||||
convId: 'conv-1',
|
||||
type: 'text',
|
||||
timestamp: Date.now(),
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: overrides.content ?? '',
|
||||
parent: null,
|
||||
children: [],
|
||||
...overrides
|
||||
} as DatabaseMessage;
|
||||
}
|
||||
|
||||
function makeToolMsg(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
|
||||
return {
|
||||
id: overrides.id ?? 'tool-1',
|
||||
convId: 'conv-1',
|
||||
type: 'text',
|
||||
timestamp: Date.now(),
|
||||
role: MessageRole.TOOL,
|
||||
content: overrides.content ?? 'tool result',
|
||||
parent: null,
|
||||
children: [],
|
||||
toolCallId: overrides.toolCallId ?? 'call_1',
|
||||
...overrides
|
||||
} as DatabaseMessage;
|
||||
}
|
||||
|
||||
describe('deriveAgenticSections', () => {
|
||||
it('returns empty array for assistant with no content', () => {
|
||||
const msg = makeAssistant({ content: '' });
|
||||
const sections = deriveAgenticSections(msg);
|
||||
expect(sections).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns text section for simple assistant message', () => {
|
||||
const msg = makeAssistant({ content: 'Hello world' });
|
||||
const sections = deriveAgenticSections(msg);
|
||||
expect(sections).toHaveLength(1);
|
||||
expect(sections[0].type).toBe(AgenticSectionType.TEXT);
|
||||
expect(sections[0].content).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('returns reasoning + text for message with reasoning', () => {
|
||||
const msg = makeAssistant({
|
||||
content: 'Answer is 4.',
|
||||
reasoningContent: 'Let me think...'
|
||||
});
|
||||
const sections = deriveAgenticSections(msg);
|
||||
expect(sections).toHaveLength(2);
|
||||
expect(sections[0].type).toBe(AgenticSectionType.REASONING);
|
||||
expect(sections[0].content).toBe('Let me think...');
|
||||
expect(sections[1].type).toBe(AgenticSectionType.TEXT);
|
||||
});
|
||||
|
||||
it('single turn: assistant with tool calls and results', () => {
|
||||
const msg = makeAssistant({
|
||||
content: 'Let me check.',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'search', arguments: '{"q":"test"}' }
|
||||
}
|
||||
])
|
||||
});
|
||||
const toolResult = makeToolMsg({
|
||||
toolCallId: 'call_1',
|
||||
content: 'Found 3 results'
|
||||
});
|
||||
const sections = deriveAgenticSections(msg, [toolResult]);
|
||||
expect(sections).toHaveLength(2);
|
||||
expect(sections[0].type).toBe(AgenticSectionType.TEXT);
|
||||
expect(sections[1].type).toBe(AgenticSectionType.TOOL_CALL);
|
||||
expect(sections[1].toolName).toBe('search');
|
||||
expect(sections[1].toolResult).toBe('Found 3 results');
|
||||
});
|
||||
|
||||
it('single turn: pending tool call without result', () => {
|
||||
const msg = makeAssistant({
|
||||
toolCalls: JSON.stringify([
|
||||
{ id: 'call_1', type: 'function', function: { name: 'bash', arguments: '{}' } }
|
||||
])
|
||||
});
|
||||
const sections = deriveAgenticSections(msg, [], [], true);
|
||||
expect(sections).toHaveLength(1);
|
||||
expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL_PENDING);
|
||||
expect(sections[0].toolName).toBe('bash');
|
||||
});
|
||||
|
||||
it('multi-turn: two assistant turns grouped as one session', () => {
|
||||
const assistant1 = makeAssistant({
|
||||
id: 'ast-1',
|
||||
content: 'Turn 1 text',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'search', arguments: '{"q":"foo"}' }
|
||||
}
|
||||
])
|
||||
});
|
||||
const tool1 = makeToolMsg({ id: 'tool-1', toolCallId: 'call_1', content: 'result 1' });
|
||||
const assistant2 = makeAssistant({
|
||||
id: 'ast-2',
|
||||
content: 'Final answer based on results.'
|
||||
});
|
||||
|
||||
// toolMessages contains both tool result and continuation assistant
|
||||
const sections = deriveAgenticSections(assistant1, [tool1, assistant2]);
|
||||
expect(sections).toHaveLength(3);
|
||||
// Turn 1
|
||||
expect(sections[0].type).toBe(AgenticSectionType.TEXT);
|
||||
expect(sections[0].content).toBe('Turn 1 text');
|
||||
expect(sections[1].type).toBe(AgenticSectionType.TOOL_CALL);
|
||||
expect(sections[1].toolName).toBe('search');
|
||||
expect(sections[1].toolResult).toBe('result 1');
|
||||
// Turn 2 (final)
|
||||
expect(sections[2].type).toBe(AgenticSectionType.TEXT);
|
||||
expect(sections[2].content).toBe('Final answer based on results.');
|
||||
});
|
||||
|
||||
it('multi-turn: three turns with tool calls', () => {
|
||||
const assistant1 = makeAssistant({
|
||||
id: 'ast-1',
|
||||
content: '',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'list_files', arguments: '{}' }
|
||||
}
|
||||
])
|
||||
});
|
||||
const tool1 = makeToolMsg({ id: 'tool-1', toolCallId: 'call_1', content: 'file1 file2' });
|
||||
const assistant2 = makeAssistant({
|
||||
id: 'ast-2',
|
||||
content: 'Reading file1...',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'call_2',
|
||||
type: 'function',
|
||||
function: { name: 'read_file', arguments: '{"path":"file1"}' }
|
||||
}
|
||||
])
|
||||
});
|
||||
const tool2 = makeToolMsg({
|
||||
id: 'tool-2',
|
||||
toolCallId: 'call_2',
|
||||
content: 'contents of file1'
|
||||
});
|
||||
const assistant3 = makeAssistant({
|
||||
id: 'ast-3',
|
||||
content: 'Here is the analysis.',
|
||||
reasoningContent: 'The file contains...'
|
||||
});
|
||||
|
||||
const sections = deriveAgenticSections(assistant1, [tool1, assistant2, tool2, assistant3]);
|
||||
// Turn 1: tool_call (no text since content is empty)
|
||||
// Turn 2: text + tool_call
|
||||
// Turn 3: reasoning + text
|
||||
expect(sections).toHaveLength(5);
|
||||
expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL);
|
||||
expect(sections[0].toolName).toBe('list_files');
|
||||
expect(sections[1].type).toBe(AgenticSectionType.TEXT);
|
||||
expect(sections[1].content).toBe('Reading file1...');
|
||||
expect(sections[2].type).toBe(AgenticSectionType.TOOL_CALL);
|
||||
expect(sections[2].toolName).toBe('read_file');
|
||||
expect(sections[3].type).toBe(AgenticSectionType.REASONING);
|
||||
expect(sections[4].type).toBe(AgenticSectionType.TEXT);
|
||||
expect(sections[4].content).toBe('Here is the analysis.');
|
||||
});
|
||||
|
||||
it('returns REASONING_PENDING when streaming with only reasoning content', () => {
|
||||
const msg = makeAssistant({
|
||||
reasoningContent: 'Let me think about this...'
|
||||
});
|
||||
const sections = deriveAgenticSections(msg, [], [], true);
|
||||
expect(sections).toHaveLength(1);
|
||||
expect(sections[0].type).toBe(AgenticSectionType.REASONING_PENDING);
|
||||
expect(sections[0].content).toBe('Let me think about this...');
|
||||
});
|
||||
|
||||
it('returns REASONING (not pending) when streaming but text content has appeared', () => {
|
||||
const msg = makeAssistant({
|
||||
content: 'The answer is',
|
||||
reasoningContent: 'Let me think...'
|
||||
});
|
||||
const sections = deriveAgenticSections(msg, [], [], true);
|
||||
expect(sections).toHaveLength(2);
|
||||
expect(sections[0].type).toBe(AgenticSectionType.REASONING);
|
||||
expect(sections[1].type).toBe(AgenticSectionType.TEXT);
|
||||
});
|
||||
|
||||
it('returns REASONING (not pending) when not streaming', () => {
|
||||
const msg = makeAssistant({
|
||||
reasoningContent: 'Let me think...'
|
||||
});
|
||||
const sections = deriveAgenticSections(msg, [], [], false);
|
||||
expect(sections).toHaveLength(1);
|
||||
expect(sections[0].type).toBe(AgenticSectionType.REASONING);
|
||||
});
|
||||
|
||||
it('multi-turn: streaming tool calls on last turn', () => {
|
||||
const assistant1 = makeAssistant({
|
||||
toolCalls: JSON.stringify([
|
||||
{ id: 'call_1', type: 'function', function: { name: 'search', arguments: '{}' } }
|
||||
])
|
||||
});
|
||||
const tool1 = makeToolMsg({ toolCallId: 'call_1', content: 'result' });
|
||||
const assistant2 = makeAssistant({ id: 'ast-2', content: '' });
|
||||
|
||||
const streamingToolCalls: ApiChatCompletionToolCall[] = [
|
||||
{ id: 'call_2', type: 'function', function: { name: 'write_file', arguments: '{"pa' } }
|
||||
];
|
||||
|
||||
const sections = deriveAgenticSections(assistant1, [tool1, assistant2], streamingToolCalls);
|
||||
// Turn 1: tool_call
|
||||
// Turn 2 (streaming): streaming tool call
|
||||
expect(sections.some((s) => s.type === AgenticSectionType.TOOL_CALL)).toBe(true);
|
||||
expect(sections.some((s) => s.type === AgenticSectionType.TOOL_CALL_STREAMING)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasAgenticContent', () => {
|
||||
it('returns false for plain assistant', () => {
|
||||
const msg = makeAssistant({ content: 'Just text' });
|
||||
expect(hasAgenticContent(msg)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when message has toolCalls', () => {
|
||||
const msg = makeAssistant({
|
||||
toolCalls: JSON.stringify([
|
||||
{ id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } }
|
||||
])
|
||||
});
|
||||
expect(hasAgenticContent(msg)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when toolMessages are provided', () => {
|
||||
const msg = makeAssistant();
|
||||
const tool = makeToolMsg();
|
||||
expect(hasAgenticContent(msg, [tool])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for empty toolCalls JSON', () => {
|
||||
const msg = makeAssistant({ toolCalls: '[]' });
|
||||
expect(hasAgenticContent(msg)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LEGACY_AGENTIC_REGEX } from '$lib/constants/agentic';
|
||||
|
||||
/**
|
||||
* Tests for legacy marker stripping (used in migration).
|
||||
* The new system does not embed markers in content - these tests verify
|
||||
* the legacy regex patterns still work for the migration code.
|
||||
*/
|
||||
|
||||
// Mirror the legacy stripping logic used during migration
|
||||
function stripLegacyContextMarkers(content: string): string {
|
||||
return content
|
||||
.replace(new RegExp(LEGACY_AGENTIC_REGEX.REASONING_BLOCK.source, 'g'), '')
|
||||
.replace(LEGACY_AGENTIC_REGEX.REASONING_OPEN, '')
|
||||
.replace(new RegExp(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK.source, 'g'), '')
|
||||
.replace(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '');
|
||||
}
|
||||
|
||||
// A realistic complete tool call block as stored in old message.content
|
||||
const COMPLETE_BLOCK =
|
||||
'\n\n<<<AGENTIC_TOOL_CALL_START>>>\n' +
|
||||
'<<<TOOL_NAME:bash_tool>>>\n' +
|
||||
'<<<TOOL_ARGS_START>>>\n' +
|
||||
'{"command":"ls /tmp","description":"list tmp"}\n' +
|
||||
'<<<TOOL_ARGS_END>>>\n' +
|
||||
'file1.txt\nfile2.txt\n' +
|
||||
'<<<AGENTIC_TOOL_CALL_END>>>\n';
|
||||
|
||||
// Partial block: streaming was cut before END arrived.
|
||||
const OPEN_BLOCK =
|
||||
'\n\n<<<AGENTIC_TOOL_CALL_START>>>\n' +
|
||||
'<<<TOOL_NAME:bash_tool>>>\n' +
|
||||
'<<<TOOL_ARGS_START>>>\n' +
|
||||
'{"command":"ls /tmp","description":"list tmp"}\n' +
|
||||
'<<<TOOL_ARGS_END>>>\n' +
|
||||
'partial output...';
|
||||
|
||||
describe('legacy agentic marker stripping (for migration)', () => {
|
||||
it('strips a complete tool call block, leaving surrounding text', () => {
|
||||
const input = 'Before.' + COMPLETE_BLOCK + 'After.';
|
||||
const result = stripLegacyContextMarkers(input);
|
||||
expect(result).not.toContain('<<<');
|
||||
expect(result).toContain('Before.');
|
||||
expect(result).toContain('After.');
|
||||
});
|
||||
|
||||
it('strips multiple complete tool call blocks', () => {
|
||||
const input = 'A' + COMPLETE_BLOCK + 'B' + COMPLETE_BLOCK + 'C';
|
||||
const result = stripLegacyContextMarkers(input);
|
||||
expect(result).not.toContain('<<<');
|
||||
expect(result).toContain('A');
|
||||
expect(result).toContain('B');
|
||||
expect(result).toContain('C');
|
||||
});
|
||||
|
||||
it('strips an open/partial tool call block (no END marker)', () => {
|
||||
const input = 'Lead text.' + OPEN_BLOCK;
|
||||
const result = stripLegacyContextMarkers(input);
|
||||
expect(result).toBe('Lead text.');
|
||||
expect(result).not.toContain('<<<');
|
||||
});
|
||||
|
||||
it('does not alter content with no markers', () => {
|
||||
const input = 'Just a normal assistant response.';
|
||||
expect(stripLegacyContextMarkers(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('strips reasoning block independently', () => {
|
||||
const input = '<<<reasoning_content_start>>>think hard<<<reasoning_content_end>>>Answer.';
|
||||
expect(stripLegacyContextMarkers(input)).toBe('Answer.');
|
||||
});
|
||||
|
||||
it('strips both reasoning and agentic blocks together', () => {
|
||||
const input =
|
||||
'<<<reasoning_content_start>>>plan<<<reasoning_content_end>>>' +
|
||||
'Some text.' +
|
||||
COMPLETE_BLOCK;
|
||||
expect(stripLegacyContextMarkers(input)).not.toContain('<<<');
|
||||
expect(stripLegacyContextMarkers(input)).toContain('Some text.');
|
||||
});
|
||||
|
||||
it('empty string survives', () => {
|
||||
expect(stripLegacyContextMarkers('')).toBe('');
|
||||
});
|
||||
|
||||
it('detects legacy markers', () => {
|
||||
expect(LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test('normal text')).toBe(false);
|
||||
expect(
|
||||
LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test('text<<<AGENTIC_TOOL_CALL_START>>>more')
|
||||
).toBe(true);
|
||||
expect(LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test('<<<reasoning_content_start>>>think')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 ');
|
||||
});
|
||||
});
|
||||
@@ -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$.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client';
|
||||
import { MCPService } from '$lib/services/mcp.service';
|
||||
import { MCPConnectionPhase, MCPTransportType } from '$lib/enums';
|
||||
import type { MCPConnectionLog, MCPServerConfig } from '$lib/types';
|
||||
|
||||
type DiagnosticFetchFactory = (
|
||||
serverName: string,
|
||||
config: MCPServerConfig,
|
||||
baseInit: RequestInit,
|
||||
targetUrl: URL,
|
||||
useProxy: boolean,
|
||||
onLog?: (log: MCPConnectionLog) => void
|
||||
) => { fetch: typeof fetch; disable: () => void };
|
||||
|
||||
const createDiagnosticFetch = (
|
||||
config: MCPServerConfig,
|
||||
onLog?: (log: MCPConnectionLog) => void,
|
||||
baseInit: RequestInit = {}
|
||||
) =>
|
||||
(
|
||||
MCPService as unknown as { createDiagnosticFetch: DiagnosticFetchFactory }
|
||||
).createDiagnosticFetch('test-server', config, baseInit, new URL(config.url), false, onLog);
|
||||
|
||||
describe('MCPService', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('stops transport phase logging after handshake diagnostics are disabled', async () => {
|
||||
const logs: MCPConnectionLog[] = [];
|
||||
const response = new Response('{}', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
url: 'https://example.com/mcp',
|
||||
transport: MCPTransportType.STREAMABLE_HTTP
|
||||
};
|
||||
|
||||
const controller = createDiagnosticFetch(config, (log) => logs.push(log));
|
||||
|
||||
await controller.fetch(config.url, { method: 'POST', body: '{}' });
|
||||
expect(logs).toHaveLength(2);
|
||||
expect(logs.every((log) => log.message.includes('https://example.com/mcp'))).toBe(true);
|
||||
|
||||
controller.disable();
|
||||
await controller.fetch(config.url, { method: 'POST', body: '{}' });
|
||||
|
||||
expect(logs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('redacts all configured custom headers in diagnostic request logs', async () => {
|
||||
const logs: MCPConnectionLog[] = [];
|
||||
const response = new Response('{}', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
url: 'https://example.com/mcp',
|
||||
transport: MCPTransportType.STREAMABLE_HTTP,
|
||||
headers: {
|
||||
'x-auth-token': 'secret-token',
|
||||
'x-vendor-api-key': 'secret-key'
|
||||
}
|
||||
};
|
||||
|
||||
const controller = createDiagnosticFetch(config, (log) => logs.push(log), {
|
||||
headers: config.headers
|
||||
});
|
||||
|
||||
await controller.fetch(config.url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{}'
|
||||
});
|
||||
|
||||
expect(logs).toHaveLength(2);
|
||||
expect(logs[0].details).toMatchObject({
|
||||
request: {
|
||||
headers: {
|
||||
'x-auth-token': '[redacted]',
|
||||
'x-vendor-api-key': '[redacted]',
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('partially redacts mcp-session-id in diagnostic request and response logs', async () => {
|
||||
const logs: MCPConnectionLog[] = [];
|
||||
const response = new Response('{}', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'mcp-session-id': 'session-response-67890'
|
||||
}
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
url: 'https://example.com/mcp',
|
||||
transport: MCPTransportType.STREAMABLE_HTTP
|
||||
};
|
||||
|
||||
const controller = createDiagnosticFetch(config, (log) => logs.push(log));
|
||||
|
||||
await controller.fetch(config.url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'mcp-session-id': 'session-request-12345'
|
||||
},
|
||||
body: '{}'
|
||||
});
|
||||
|
||||
expect(logs).toHaveLength(2);
|
||||
expect(logs[0].details).toMatchObject({
|
||||
request: {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'mcp-session-id': '....12345'
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(logs[1].details).toMatchObject({
|
||||
response: {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'mcp-session-id': '....67890'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts JSON-RPC methods without logging the raw request body', async () => {
|
||||
const logs: MCPConnectionLog[] = [];
|
||||
const response = new Response('{}', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
url: 'https://example.com/mcp',
|
||||
transport: MCPTransportType.STREAMABLE_HTTP
|
||||
};
|
||||
|
||||
const controller = createDiagnosticFetch(config, (log) => logs.push(log));
|
||||
|
||||
await controller.fetch(config.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify([
|
||||
{ jsonrpc: '2.0', id: 1, method: 'initialize' },
|
||||
{ jsonrpc: '2.0', method: 'notifications/initialized' }
|
||||
])
|
||||
});
|
||||
|
||||
expect(logs[0].details).toMatchObject({
|
||||
request: {
|
||||
method: 'POST',
|
||||
body: {
|
||||
kind: 'string',
|
||||
size: expect.any(Number)
|
||||
},
|
||||
jsonRpcMethods: ['initialize', 'notifications/initialized']
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a CORS hint to Failed to fetch diagnostic log messages', async () => {
|
||||
const logs: MCPConnectionLog[] = [];
|
||||
const fetchError = new TypeError('Failed to fetch');
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(fetchError));
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
url: 'http://localhost:8000/mcp',
|
||||
transport: MCPTransportType.STREAMABLE_HTTP
|
||||
};
|
||||
|
||||
const controller = createDiagnosticFetch(config, (log) => logs.push(log));
|
||||
|
||||
await expect(controller.fetch(config.url, { method: 'POST', body: '{}' })).rejects.toThrow(
|
||||
'Failed to fetch'
|
||||
);
|
||||
|
||||
expect(logs).toHaveLength(2);
|
||||
expect(logs[1].message).toBe(
|
||||
'HTTP POST http://localhost:8000/mcp failed: Failed to fetch (check CORS?)'
|
||||
);
|
||||
});
|
||||
|
||||
it('detaches phase error logging after the initialize handshake completes', async () => {
|
||||
const phaseLogs: Array<{ phase: MCPConnectionPhase; log: MCPConnectionLog }> = [];
|
||||
const stopPhaseLogging = vi.fn();
|
||||
let emitClientError: ((error: Error) => void) | undefined;
|
||||
|
||||
vi.spyOn(MCPService, 'createTransport').mockReturnValue({
|
||||
transport: {} as never,
|
||||
type: MCPTransportType.WEBSOCKET,
|
||||
stopPhaseLogging
|
||||
});
|
||||
vi.spyOn(MCPService, 'listTools').mockResolvedValue([]);
|
||||
vi.spyOn(Client.prototype, 'getServerVersion').mockReturnValue(undefined);
|
||||
vi.spyOn(Client.prototype, 'getServerCapabilities').mockReturnValue(undefined);
|
||||
vi.spyOn(Client.prototype, 'getInstructions').mockReturnValue(undefined);
|
||||
vi.spyOn(Client.prototype, 'connect').mockImplementation(async function (this: Client) {
|
||||
emitClientError = (error: Error) => this.onerror?.(error);
|
||||
this.onerror?.(new Error('handshake protocol error'));
|
||||
});
|
||||
|
||||
await MCPService.connect(
|
||||
'test-server',
|
||||
{
|
||||
url: 'ws://example.com/mcp',
|
||||
transport: MCPTransportType.WEBSOCKET
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
(phase, log) => phaseLogs.push({ phase, log })
|
||||
);
|
||||
|
||||
expect(stopPhaseLogging).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
phaseLogs.filter(
|
||||
({ phase, log }) =>
|
||||
phase === MCPConnectionPhase.ERROR &&
|
||||
log.message === 'Protocol error: handshake protocol error'
|
||||
)
|
||||
).toHaveLength(1);
|
||||
|
||||
emitClientError?.(new Error('runtime protocol error'));
|
||||
|
||||
expect(
|
||||
phaseLogs.filter(
|
||||
({ phase, log }) =>
|
||||
phase === MCPConnectionPhase.ERROR &&
|
||||
log.message === 'Protocol error: runtime protocol error'
|
||||
)
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
|
||||
const { parseModelId } = ModelsService;
|
||||
|
||||
describe('parseModelId', () => {
|
||||
it('handles unknown patterns correctly', () => {
|
||||
expect(parseModelId('model-name-1')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'model-name-1',
|
||||
orgName: null,
|
||||
params: null,
|
||||
quantization: null,
|
||||
raw: 'model-name-1',
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('org/model-name-2')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'model-name-2',
|
||||
orgName: 'org',
|
||||
params: null,
|
||||
quantization: null,
|
||||
raw: 'org/model-name-2',
|
||||
tags: []
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts model parameters correctly', () => {
|
||||
expect(parseModelId('model-100B-BF16')).toMatchObject({ params: '100B' });
|
||||
expect(parseModelId('model-100B:Q4_K_M')).toMatchObject({ params: '100B' });
|
||||
});
|
||||
|
||||
it('extracts model parameters correctly in lowercase', () => {
|
||||
expect(parseModelId('model-100b-bf16')).toMatchObject({ params: '100B' });
|
||||
expect(parseModelId('model-100b:q4_k_m')).toMatchObject({ params: '100B' });
|
||||
});
|
||||
|
||||
it('extracts activated parameters correctly', () => {
|
||||
expect(parseModelId('model-100B-A10B-BF16')).toMatchObject({ activatedParams: 'A10B' });
|
||||
expect(parseModelId('model-100B-A10B:Q4_K_M')).toMatchObject({ activatedParams: 'A10B' });
|
||||
});
|
||||
|
||||
it('extracts activated parameters correctly in lowercase', () => {
|
||||
expect(parseModelId('model-100b-a10b-bf16')).toMatchObject({ activatedParams: 'A10B' });
|
||||
expect(parseModelId('model-100b-a10b:q4_k_m')).toMatchObject({ activatedParams: 'A10B' });
|
||||
});
|
||||
|
||||
it('extracts quantization correctly', () => {
|
||||
// Dash-separated quantization
|
||||
expect(parseModelId('model-100B-UD-IQ1_S')).toMatchObject({ quantization: 'UD-IQ1_S' });
|
||||
expect(parseModelId('model-100B-IQ4_XS')).toMatchObject({ quantization: 'IQ4_XS' });
|
||||
expect(parseModelId('model-100B-Q4_K_M')).toMatchObject({ quantization: 'Q4_K_M' });
|
||||
expect(parseModelId('model-100B-Q8_0')).toMatchObject({ quantization: 'Q8_0' });
|
||||
expect(parseModelId('model-100B-UD-Q8_K_XL')).toMatchObject({ quantization: 'UD-Q8_K_XL' });
|
||||
expect(parseModelId('model-100B-F16')).toMatchObject({ quantization: 'F16' });
|
||||
expect(parseModelId('model-100B-BF16')).toMatchObject({ quantization: 'BF16' });
|
||||
expect(parseModelId('model-100B-MXFP4')).toMatchObject({ quantization: 'MXFP4' });
|
||||
|
||||
// Colon-separated quantization
|
||||
expect(parseModelId('model-100B:UD-IQ1_S')).toMatchObject({ quantization: 'UD-IQ1_S' });
|
||||
expect(parseModelId('model-100B:IQ4_XS')).toMatchObject({ quantization: 'IQ4_XS' });
|
||||
expect(parseModelId('model-100B:Q4_K_M')).toMatchObject({ quantization: 'Q4_K_M' });
|
||||
expect(parseModelId('model-100B:Q8_0')).toMatchObject({ quantization: 'Q8_0' });
|
||||
expect(parseModelId('model-100B:UD-Q8_K_XL')).toMatchObject({ quantization: 'UD-Q8_K_XL' });
|
||||
expect(parseModelId('model-100B:F16')).toMatchObject({ quantization: 'F16' });
|
||||
expect(parseModelId('model-100B:BF16')).toMatchObject({ quantization: 'BF16' });
|
||||
expect(parseModelId('model-100B:MXFP4')).toMatchObject({ quantization: 'MXFP4' });
|
||||
|
||||
// Dot-separated quantization
|
||||
expect(parseModelId('nomic-embed-text-v2-moe.Q4_K_M')).toMatchObject({
|
||||
quantization: 'Q4_K_M'
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts additional tags correctly', () => {
|
||||
expect(parseModelId('model-100B-foobar-Q4_K_M')).toMatchObject({ tags: ['foobar'] });
|
||||
expect(parseModelId('model-100B-A10B-foobar-1M-BF16')).toMatchObject({
|
||||
tags: ['foobar', '1M']
|
||||
});
|
||||
expect(parseModelId('model-100B-1M-foobar:UD-Q8_K_XL')).toMatchObject({
|
||||
tags: ['1M', 'foobar']
|
||||
});
|
||||
});
|
||||
|
||||
it('filters out container format segments from tags', () => {
|
||||
expect(parseModelId('model-100B-GGUF-Instruct-BF16')).toMatchObject({
|
||||
tags: ['Instruct']
|
||||
});
|
||||
expect(parseModelId('model-100B-GGML-Instruct:Q4_K_M')).toMatchObject({
|
||||
tags: ['Instruct']
|
||||
});
|
||||
});
|
||||
|
||||
it('handles real-world examples correctly', () => {
|
||||
expect(parseModelId('meta-llama/Llama-3.1-8B')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'Llama-3.1',
|
||||
orgName: 'meta-llama',
|
||||
params: '8B',
|
||||
quantization: null,
|
||||
raw: 'meta-llama/Llama-3.1-8B',
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('openai/gpt-oss-120b-MXFP4')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'gpt-oss',
|
||||
orgName: 'openai',
|
||||
params: '120B',
|
||||
quantization: 'MXFP4',
|
||||
raw: 'openai/gpt-oss-120b-MXFP4',
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('openai/gpt-oss-20b:Q4_K_M')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'gpt-oss',
|
||||
orgName: 'openai',
|
||||
params: '20B',
|
||||
quantization: 'Q4_K_M',
|
||||
raw: 'openai/gpt-oss-20b:Q4_K_M',
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('Qwen/Qwen3-Coder-30B-A3B-Instruct-1M-BF16')).toStrictEqual({
|
||||
activatedParams: 'A3B',
|
||||
modelName: 'Qwen3-Coder',
|
||||
orgName: 'Qwen',
|
||||
params: '30B',
|
||||
quantization: 'BF16',
|
||||
raw: 'Qwen/Qwen3-Coder-30B-A3B-Instruct-1M-BF16',
|
||||
tags: ['Instruct', '1M']
|
||||
});
|
||||
});
|
||||
|
||||
it('handles real-world examples with quantization in segments', () => {
|
||||
expect(parseModelId('meta-llama/Llama-4-Scout-17B-16E-Instruct-Q4_K_M')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'Llama-4-Scout',
|
||||
orgName: 'meta-llama',
|
||||
params: '17B',
|
||||
quantization: 'Q4_K_M',
|
||||
raw: 'meta-llama/Llama-4-Scout-17B-16E-Instruct-Q4_K_M',
|
||||
tags: ['16E', 'Instruct']
|
||||
});
|
||||
|
||||
expect(parseModelId('MiniMaxAI/MiniMax-M2-IQ4_XS')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'MiniMax-M2',
|
||||
orgName: 'MiniMaxAI',
|
||||
params: null,
|
||||
quantization: 'IQ4_XS',
|
||||
raw: 'MiniMaxAI/MiniMax-M2-IQ4_XS',
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('MiniMaxAI/MiniMax-M2-UD-Q3_K_XL')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'MiniMax-M2',
|
||||
orgName: 'MiniMaxAI',
|
||||
params: null,
|
||||
quantization: 'UD-Q3_K_XL',
|
||||
raw: 'MiniMaxAI/MiniMax-M2-UD-Q3_K_XL',
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('mistralai/Devstral-2-123B-Instruct-2512-Q4_K_M')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'Devstral-2',
|
||||
orgName: 'mistralai',
|
||||
params: '123B',
|
||||
quantization: 'Q4_K_M',
|
||||
raw: 'mistralai/Devstral-2-123B-Instruct-2512-Q4_K_M',
|
||||
tags: ['Instruct', '2512']
|
||||
});
|
||||
|
||||
expect(parseModelId('mistralai/Devstral-Small-2-24B-Instruct-2512-Q8_0')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'Devstral-Small-2',
|
||||
orgName: 'mistralai',
|
||||
params: '24B',
|
||||
quantization: 'Q8_0',
|
||||
raw: 'mistralai/Devstral-Small-2-24B-Instruct-2512-Q8_0',
|
||||
tags: ['Instruct', '2512']
|
||||
});
|
||||
|
||||
expect(parseModelId('noctrex/GLM-4.7-Flash-MXFP4_MOE')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'GLM-4.7-Flash',
|
||||
orgName: 'noctrex',
|
||||
params: null,
|
||||
quantization: 'MXFP4_MOE',
|
||||
raw: 'noctrex/GLM-4.7-Flash-MXFP4_MOE',
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('Qwen/Qwen3-Coder-Next-Q4_K_M')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'Qwen3-Coder-Next',
|
||||
orgName: 'Qwen',
|
||||
params: null,
|
||||
quantization: 'Q4_K_M',
|
||||
raw: 'Qwen/Qwen3-Coder-Next-Q4_K_M',
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('openai/gpt-oss-120b-Q4_K_M')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'gpt-oss',
|
||||
orgName: 'openai',
|
||||
params: '120B',
|
||||
quantization: 'Q4_K_M',
|
||||
raw: 'openai/gpt-oss-120b-Q4_K_M',
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('openai/gpt-oss-20b-F16')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'gpt-oss',
|
||||
orgName: 'openai',
|
||||
params: '20B',
|
||||
quantization: 'F16',
|
||||
raw: 'openai/gpt-oss-20b-F16',
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('nomic-embed-text-v2-moe.Q4_K_M')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'nomic-embed-text-v2-moe',
|
||||
orgName: null,
|
||||
params: null,
|
||||
quantization: 'Q4_K_M',
|
||||
raw: 'nomic-embed-text-v2-moe.Q4_K_M',
|
||||
tags: []
|
||||
});
|
||||
});
|
||||
|
||||
it('handles ambiguous model names', () => {
|
||||
// Qwen3.5 Instruct vs Thinking — tags should distinguish them
|
||||
expect(parseModelId('Qwen/Qwen3.5-30B-A3B-Instruct')).toMatchObject({
|
||||
modelName: 'Qwen3.5',
|
||||
params: '30B',
|
||||
activatedParams: 'A3B',
|
||||
tags: ['Instruct']
|
||||
});
|
||||
|
||||
expect(parseModelId('Qwen/Qwen3.5-30B-A3B-Thinking')).toMatchObject({
|
||||
modelName: 'Qwen3.5',
|
||||
params: '30B',
|
||||
activatedParams: 'A3B',
|
||||
tags: ['Thinking']
|
||||
});
|
||||
|
||||
// Dot-separated quantization with variant suffixes
|
||||
expect(parseModelId('gemma-3-27b-it-heretic-v2.Q8_0')).toMatchObject({
|
||||
modelName: 'gemma-3',
|
||||
params: '27B',
|
||||
quantization: 'Q8_0',
|
||||
tags: ['it', 'heretic', 'v2']
|
||||
});
|
||||
|
||||
expect(parseModelId('gemma-3-27b-it.Q8_0')).toMatchObject({
|
||||
modelName: 'gemma-3',
|
||||
params: '27B',
|
||||
quantization: 'Q8_0',
|
||||
tags: ['it']
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
|
||||
/**
|
||||
* Tests for the new reasoning content handling.
|
||||
* In the new architecture, reasoning content is stored in a dedicated
|
||||
* `reasoningContent` field on DatabaseMessage, not embedded in content with tags.
|
||||
* The API sends it as `reasoning_content` on ApiChatMessageData.
|
||||
*/
|
||||
|
||||
describe('reasoning content in new structured format', () => {
|
||||
it('reasoning is stored as separate field, not in content', () => {
|
||||
// Simulate what the new chat store does
|
||||
const message = {
|
||||
content: 'The answer is 4.',
|
||||
reasoningContent: 'Let me think: 2+2=4, basic arithmetic.'
|
||||
};
|
||||
|
||||
// Content should be clean
|
||||
expect(message.content).not.toContain('<<<');
|
||||
expect(message.content).toBe('The answer is 4.');
|
||||
|
||||
// Reasoning in dedicated field
|
||||
expect(message.reasoningContent).toBe('Let me think: 2+2=4, basic arithmetic.');
|
||||
});
|
||||
|
||||
it('convertDbMessageToApiChatMessageData includes reasoning_content', () => {
|
||||
// Simulate the conversion logic
|
||||
const dbMessage = {
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: 'The answer is 4.',
|
||||
reasoningContent: 'Let me think: 2+2=4, basic arithmetic.'
|
||||
};
|
||||
|
||||
const apiMessage: Record<string, unknown> = {
|
||||
role: dbMessage.role,
|
||||
content: dbMessage.content
|
||||
};
|
||||
if (dbMessage.reasoningContent) {
|
||||
apiMessage.reasoning_content = dbMessage.reasoningContent;
|
||||
}
|
||||
|
||||
expect(apiMessage.content).toBe('The answer is 4.');
|
||||
expect(apiMessage.reasoning_content).toBe('Let me think: 2+2=4, basic arithmetic.');
|
||||
// No internal tags leak into either field
|
||||
expect(apiMessage.content).not.toContain('<<<');
|
||||
expect(apiMessage.reasoning_content).not.toContain('<<<');
|
||||
});
|
||||
|
||||
it('API message excludes reasoning when excludeReasoningFromContext is true', () => {
|
||||
const dbMessage = {
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: 'The answer is 4.',
|
||||
reasoningContent: 'internal thinking'
|
||||
};
|
||||
|
||||
const excludeReasoningFromContext = true;
|
||||
|
||||
const apiMessage: Record<string, unknown> = {
|
||||
role: dbMessage.role,
|
||||
content: dbMessage.content
|
||||
};
|
||||
if (!excludeReasoningFromContext && dbMessage.reasoningContent) {
|
||||
apiMessage.reasoning_content = dbMessage.reasoningContent;
|
||||
}
|
||||
|
||||
expect(apiMessage.content).toBe('The answer is 4.');
|
||||
expect(apiMessage.reasoning_content).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles messages with no reasoning', () => {
|
||||
const dbMessage = {
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: 'No reasoning here.',
|
||||
reasoningContent: undefined
|
||||
};
|
||||
|
||||
const apiMessage: Record<string, unknown> = {
|
||||
role: dbMessage.role,
|
||||
content: dbMessage.content
|
||||
};
|
||||
if (dbMessage.reasoningContent) {
|
||||
apiMessage.reasoning_content = dbMessage.reasoningContent;
|
||||
}
|
||||
|
||||
expect(apiMessage.content).toBe('No reasoning here.');
|
||||
expect(apiMessage.reasoning_content).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { redactValue } from '$lib/utils/redact';
|
||||
|
||||
describe('redactValue', () => {
|
||||
it('returns [redacted] by default', () => {
|
||||
expect(redactValue('secret-token')).toBe('[redacted]');
|
||||
});
|
||||
|
||||
it('shows last N characters when showLastChars is provided', () => {
|
||||
expect(redactValue('session-abc12', 5)).toBe('....abc12');
|
||||
});
|
||||
|
||||
it('handles value shorter than showLastChars', () => {
|
||||
expect(redactValue('ab', 5)).toBe('....ab');
|
||||
});
|
||||
|
||||
it('returns [redacted] when showLastChars is 0', () => {
|
||||
expect(redactValue('secret', 0)).toBe('[redacted]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getRequestUrl,
|
||||
getRequestMethod,
|
||||
getRequestBody,
|
||||
summarizeRequestBody,
|
||||
formatDiagnosticErrorMessage,
|
||||
extractJsonRpcMethods
|
||||
} from '$lib/utils/request-helpers';
|
||||
|
||||
describe('getRequestUrl', () => {
|
||||
it('returns a plain string input as-is', () => {
|
||||
expect(getRequestUrl('https://example.com/mcp')).toBe('https://example.com/mcp');
|
||||
});
|
||||
|
||||
it('returns href from a URL object', () => {
|
||||
expect(getRequestUrl(new URL('https://example.com/mcp'))).toBe('https://example.com/mcp');
|
||||
});
|
||||
|
||||
it('returns url from a Request object', () => {
|
||||
const req = new Request('https://example.com/mcp');
|
||||
expect(getRequestUrl(req)).toBe('https://example.com/mcp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRequestMethod', () => {
|
||||
it('prefers method from init', () => {
|
||||
expect(getRequestMethod('https://example.com', { method: 'POST' })).toBe('POST');
|
||||
});
|
||||
|
||||
it('falls back to Request.method', () => {
|
||||
const req = new Request('https://example.com', { method: 'PUT' });
|
||||
expect(getRequestMethod(req)).toBe('PUT');
|
||||
});
|
||||
|
||||
it('falls back to baseInit.method', () => {
|
||||
expect(getRequestMethod('https://example.com', undefined, { method: 'DELETE' })).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('defaults to GET', () => {
|
||||
expect(getRequestMethod('https://example.com')).toBe('GET');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRequestBody', () => {
|
||||
it('returns body from init', () => {
|
||||
expect(getRequestBody('https://example.com', { body: 'payload' })).toBe('payload');
|
||||
});
|
||||
|
||||
it('returns undefined when no body is present', () => {
|
||||
expect(getRequestBody('https://example.com')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeRequestBody', () => {
|
||||
it('returns empty for null', () => {
|
||||
expect(summarizeRequestBody(null)).toEqual({ kind: 'empty' });
|
||||
});
|
||||
|
||||
it('returns empty for undefined', () => {
|
||||
expect(summarizeRequestBody(undefined)).toEqual({ kind: 'empty' });
|
||||
});
|
||||
|
||||
it('returns string kind with size', () => {
|
||||
expect(summarizeRequestBody('hello')).toEqual({ kind: 'string', size: 5 });
|
||||
});
|
||||
|
||||
it('returns blob kind with size', () => {
|
||||
const blob = new Blob(['abc']);
|
||||
expect(summarizeRequestBody(blob)).toEqual({ kind: 'blob', size: 3 });
|
||||
});
|
||||
|
||||
it('returns formdata kind', () => {
|
||||
expect(summarizeRequestBody(new FormData())).toEqual({ kind: 'formdata' });
|
||||
});
|
||||
|
||||
it('returns arraybuffer kind with size', () => {
|
||||
expect(summarizeRequestBody(new ArrayBuffer(8))).toEqual({ kind: 'arraybuffer', size: 8 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDiagnosticErrorMessage', () => {
|
||||
it('appends CORS hint for Failed to fetch', () => {
|
||||
expect(formatDiagnosticErrorMessage(new TypeError('Failed to fetch'))).toBe(
|
||||
'Failed to fetch (check CORS?)'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes through other error messages unchanged', () => {
|
||||
expect(formatDiagnosticErrorMessage(new Error('timeout'))).toBe('timeout');
|
||||
});
|
||||
|
||||
it('handles non-Error values', () => {
|
||||
expect(formatDiagnosticErrorMessage('some string')).toBe('some string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractJsonRpcMethods', () => {
|
||||
it('extracts methods from a JSON-RPC array', () => {
|
||||
const body = JSON.stringify([
|
||||
{ jsonrpc: '2.0', id: 1, method: 'initialize' },
|
||||
{ jsonrpc: '2.0', method: 'notifications/initialized' }
|
||||
]);
|
||||
expect(extractJsonRpcMethods(body)).toEqual(['initialize', 'notifications/initialized']);
|
||||
});
|
||||
|
||||
it('extracts method from a single JSON-RPC message', () => {
|
||||
const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' });
|
||||
expect(extractJsonRpcMethods(body)).toEqual(['tools/list']);
|
||||
});
|
||||
|
||||
it('returns undefined for non-string body', () => {
|
||||
expect(extractJsonRpcMethods(null)).toBeUndefined();
|
||||
expect(extractJsonRpcMethods(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for invalid JSON', () => {
|
||||
expect(extractJsonRpcMethods('not json')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when no methods found', () => {
|
||||
expect(extractJsonRpcMethods(JSON.stringify({ foo: 'bar' }))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { sanitizeHeaders } from '$lib/utils/api-headers';
|
||||
|
||||
describe('sanitizeHeaders', () => {
|
||||
it('returns empty object for undefined input', () => {
|
||||
expect(sanitizeHeaders()).toEqual({});
|
||||
});
|
||||
|
||||
it('passes through non-sensitive headers', () => {
|
||||
const headers = new Headers({ 'content-type': 'application/json', accept: 'text/html' });
|
||||
expect(sanitizeHeaders(headers)).toEqual({
|
||||
'content-type': 'application/json',
|
||||
accept: 'text/html'
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts known sensitive headers', () => {
|
||||
const headers = new Headers({
|
||||
authorization: 'Bearer secret',
|
||||
'x-api-key': 'key-123',
|
||||
'content-type': 'application/json'
|
||||
});
|
||||
const result = sanitizeHeaders(headers);
|
||||
expect(result.authorization).toBe('[redacted]');
|
||||
expect(result['x-api-key']).toBe('[redacted]');
|
||||
expect(result['content-type']).toBe('application/json');
|
||||
});
|
||||
|
||||
it('partially redacts headers specified in partialRedactHeaders', () => {
|
||||
const headers = new Headers({ 'mcp-session-id': 'session-12345' });
|
||||
const partial = new Map([['mcp-session-id', 5]]);
|
||||
expect(sanitizeHeaders(headers, undefined, partial)['mcp-session-id']).toBe('....12345');
|
||||
});
|
||||
|
||||
it('fully redacts mcp-session-id when no partialRedactHeaders is given', () => {
|
||||
const headers = new Headers({ 'mcp-session-id': 'session-12345' });
|
||||
expect(sanitizeHeaders(headers)['mcp-session-id']).toBe('[redacted]');
|
||||
});
|
||||
|
||||
it('redacts extra headers provided by the caller', () => {
|
||||
const headers = new Headers({
|
||||
'x-vendor-key': 'vendor-secret',
|
||||
'content-type': 'application/json'
|
||||
});
|
||||
const result = sanitizeHeaders(headers, ['x-vendor-key']);
|
||||
expect(result['x-vendor-key']).toBe('[redacted]');
|
||||
expect(result['content-type']).toBe('application/json');
|
||||
});
|
||||
|
||||
it('handles case-insensitive extra header names', () => {
|
||||
const headers = new Headers({ 'X-Custom-Token': 'token-value' });
|
||||
const result = sanitizeHeaders(headers, ['X-CUSTOM-TOKEN']);
|
||||
expect(result['x-custom-token']).toBe('[redacted]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
extractTemplateVariables,
|
||||
expandTemplate,
|
||||
isTemplateComplete,
|
||||
normalizeResourceUri
|
||||
} from '../../src/lib/utils/uri-template';
|
||||
import { URI_TEMPLATE_OPERATORS } from '../../src/lib/constants/uri-template';
|
||||
|
||||
describe('extractTemplateVariables', () => {
|
||||
it('extracts simple variables', () => {
|
||||
const vars = extractTemplateVariables('file:///{path}');
|
||||
expect(vars).toEqual([{ name: 'path', operator: '' }]);
|
||||
});
|
||||
|
||||
it('extracts multiple variables', () => {
|
||||
const vars = extractTemplateVariables('db://{schema}/{table}');
|
||||
expect(vars).toEqual([
|
||||
{ name: 'schema', operator: '' },
|
||||
{ name: 'table', operator: '' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts variables with operators', () => {
|
||||
const vars = extractTemplateVariables('http://example.com{+path}');
|
||||
expect(vars).toEqual([{ name: 'path', operator: URI_TEMPLATE_OPERATORS.RESERVED }]);
|
||||
});
|
||||
|
||||
it('extracts comma-separated variable lists', () => {
|
||||
const vars = extractTemplateVariables('{x,y,z}');
|
||||
expect(vars).toEqual([
|
||||
{ name: 'x', operator: '' },
|
||||
{ name: 'y', operator: '' },
|
||||
{ name: 'z', operator: '' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates variable names', () => {
|
||||
const vars = extractTemplateVariables('{name}/{name}');
|
||||
expect(vars).toEqual([{ name: 'name', operator: '' }]);
|
||||
});
|
||||
|
||||
it('handles fragment expansion', () => {
|
||||
const vars = extractTemplateVariables('http://example.com/page{#section}');
|
||||
expect(vars).toEqual([{ name: 'section', operator: URI_TEMPLATE_OPERATORS.FRAGMENT }]);
|
||||
});
|
||||
|
||||
it('handles path segment expansion', () => {
|
||||
const vars = extractTemplateVariables('http://example.com{/path}');
|
||||
expect(vars).toEqual([{ name: 'path', operator: URI_TEMPLATE_OPERATORS.PATH_SEGMENT }]);
|
||||
});
|
||||
|
||||
it('returns empty array for template without variables', () => {
|
||||
const vars = extractTemplateVariables('http://example.com/static');
|
||||
expect(vars).toEqual([]);
|
||||
});
|
||||
|
||||
it('strips explode modifier', () => {
|
||||
const vars = extractTemplateVariables('{list*}');
|
||||
expect(vars).toEqual([{ name: 'list', operator: '' }]);
|
||||
});
|
||||
|
||||
it('strips prefix modifier', () => {
|
||||
const vars = extractTemplateVariables('{value:5}');
|
||||
expect(vars).toEqual([{ name: 'value', operator: '' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandTemplate', () => {
|
||||
it('expands simple variable', () => {
|
||||
const result = expandTemplate('file:///{path}', { path: 'src/main.rs' });
|
||||
expect(result).toBe('file:///src%2Fmain.rs');
|
||||
});
|
||||
|
||||
it('expands reserved variable (no encoding)', () => {
|
||||
const result = expandTemplate('file:///{+path}', { path: 'src/main.rs' });
|
||||
expect(result).toBe('file:///src/main.rs');
|
||||
});
|
||||
|
||||
it('expands multiple variables', () => {
|
||||
const result = expandTemplate('db://{schema}/{table}', {
|
||||
schema: 'public',
|
||||
table: 'users'
|
||||
});
|
||||
expect(result).toBe('db://public/users');
|
||||
});
|
||||
|
||||
it('leaves empty for missing variables', () => {
|
||||
const result = expandTemplate('{missing}', {});
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('expands fragment', () => {
|
||||
const result = expandTemplate('http://example.com/page{#section}', {
|
||||
section: 'intro'
|
||||
});
|
||||
expect(result).toBe('http://example.com/page#intro');
|
||||
});
|
||||
|
||||
it('expands path segments', () => {
|
||||
const result = expandTemplate('http://example.com{/path}', { path: 'docs' });
|
||||
expect(result).toBe('http://example.com/docs');
|
||||
});
|
||||
|
||||
it('expands query parameters', () => {
|
||||
const result = expandTemplate('http://example.com{?q}', { q: 'search term' });
|
||||
expect(result).toBe('http://example.com?q=search%20term');
|
||||
});
|
||||
|
||||
it('keeps static parts unchanged', () => {
|
||||
const result = expandTemplate('http://example.com/static', {});
|
||||
expect(result).toBe('http://example.com/static');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTemplateComplete', () => {
|
||||
it('returns true when all variables are filled', () => {
|
||||
expect(isTemplateComplete('file:///{path}', { path: 'test.txt' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when a variable is missing', () => {
|
||||
expect(isTemplateComplete('db://{schema}/{table}', { schema: 'public' })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when a variable is empty', () => {
|
||||
expect(isTemplateComplete('file:///{path}', { path: '' })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when a variable is whitespace only', () => {
|
||||
expect(isTemplateComplete('file:///{path}', { path: ' ' })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for template without variables', () => {
|
||||
expect(isTemplateComplete('http://example.com/static', {})).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when all multiple variables are filled', () => {
|
||||
expect(isTemplateComplete('db://{schema}/{table}', { schema: 'public', table: 'users' })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeResourceUri', () => {
|
||||
it('passes through a normal URI unchanged', () => {
|
||||
expect(normalizeResourceUri('svelte://svelte/$effect.md')).toBe('svelte://svelte/$effect.md');
|
||||
});
|
||||
|
||||
it('normalizes triple-slash URIs from path-style template expansion', () => {
|
||||
expect(normalizeResourceUri('svelte:///svelte/$effect.md')).toBe('svelte://svelte/$effect.md');
|
||||
});
|
||||
|
||||
it('normalizes quadruple-slash URIs', () => {
|
||||
expect(normalizeResourceUri('svelte:////svelte/$effect.md')).toBe('svelte://svelte/$effect.md');
|
||||
});
|
||||
|
||||
it('handles file:// URIs', () => {
|
||||
expect(normalizeResourceUri('file:///home/user/doc.txt')).toBe('file://home/user/doc.txt');
|
||||
});
|
||||
|
||||
it('handles http URIs unchanged', () => {
|
||||
expect(normalizeResourceUri('http://example.com/path')).toBe('http://example.com/path');
|
||||
});
|
||||
|
||||
it('returns non-URI strings unchanged', () => {
|
||||
expect(normalizeResourceUri('not-a-uri')).toBe('not-a-uri');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user