chore: import upstream snapshot with attribution
Copilot Setup Steps / copilot-setup-steps (push) Failing after 2s
Python check requirements.txt / check-requirements (push) Has been cancelled
Python Type-Check / python type-check (push) Has been cancelled
Update Operations Documentation / update-ops-docs (push) Has been cancelled
Check Pre-Tokenizer Hashes / pre-tokenizer-hashes (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 11:57:56 +08:00
commit 09a3d3ab17
3146 changed files with 1305073 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
VITE_PUBLIC_APP_NAME='llama-ui'
VITE_DEBUG='true'
VITE_PUBLIC_SERVER_ORIGIN='http://localhost:8033'
+38
View File
@@ -0,0 +1,38 @@
test-results
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
dev-dist
dist
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# PWA Artifacts
apple-splash-*.png
apple-touch-icon-*.png
maskable-icon-*.png
pwa-*.png
static/favicon*
# Storybook
*storybook.log
storybook-static
*.code-workspace
+2
View File
@@ -0,0 +1,2 @@
engine-strict=true
ignore-scripts=true
+18
View File
@@ -0,0 +1,18 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/
dist/
.svelte-kit/
build/
# Build output
/dist/
/build/
/.svelte-kit/
test-results
+16
View File
@@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/app.css"
}
@@ -0,0 +1,36 @@
<script lang="ts">
import { ModeWatcher } from 'mode-watcher';
import { onMount } from 'svelte';
interface Props {
children?: any;
}
let { children }: Props = $props();
onMount(() => {
const root = document.documentElement;
const theme = localStorage.getItem('mode-watcher-mode') || 'system';
if (theme === 'dark') {
root.classList.add('dark');
} else if (theme === 'light') {
root.classList.remove('dark');
} else {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (prefersDark) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
}
});
</script>
<ModeWatcher />
{#if children}
{@const Component = children}
<Component />
{/if}
@@ -0,0 +1,13 @@
<script lang="ts">
import * as Tooltip from '../../src/lib/components/ui/tooltip';
interface Props {
children: any;
}
let { children }: Props = $props();
</script>
<Tooltip.Provider>
{@render children()}
</Tooltip.Provider>
+24
View File
@@ -0,0 +1,24 @@
import type { StorybookConfig } from '@storybook/sveltekit';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const config: StorybookConfig = {
stories: ['../tests/stories/**/*.mdx', '../tests/stories/**/*.stories.@(js|ts|svelte)'],
addons: [
'@storybook/addon-svelte-csf',
'@chromatic-com/storybook',
'@storybook/addon-vitest',
'@storybook/addon-a11y',
'@storybook/addon-docs'
],
framework: '@storybook/sveltekit',
viteFinal: async (config) => {
config.server = config.server || {};
config.server.fs = config.server.fs || {};
config.server.fs.allow = [...(config.server.fs.allow || []), resolve(__dirname, '../tests')];
return config;
}
};
export default config;
+42
View File
@@ -0,0 +1,42 @@
import type { Preview } from '@storybook/sveltekit';
import '../src/app.css';
import ModeWatcherDecorator from './decorators/ModeWatcherDecorator.svelte';
import TooltipProviderDecorator from './decorators/TooltipProviderDecorator.svelte';
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i
}
},
backgrounds: {
disabled: true
},
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: 'todo'
}
},
decorators: [
(story) => ({
Component: ModeWatcherDecorator,
props: {
children: story
}
}),
(story) => ({
Component: TooltipProviderDecorator,
props: {
children: story
}
})
]
};
export default preview;
+12
View File
@@ -0,0 +1,12 @@
import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview';
import { setProjectAnnotations } from '@storybook/sveltekit';
import * as previewAnnotations from './preview';
import { beforeAll } from 'vitest';
const project = setProjectAnnotations([a11yAddonAnnotations, previewAnnotations]);
beforeAll(async () => {
if (project.beforeAll) {
await project.beforeAll();
}
});
+107
View File
@@ -0,0 +1,107 @@
set(TARGET llama-ui)
set(LLAMA_UI_HF_BUCKET "ggml-org/llama-ui" CACHE STRING "Hugging Face bucket name for prebuilt UI assets")
set(LLAMA_UI_GZIP ON CACHE BOOL "Apply gzip compress to assets to save bandwidth")
# Backward compat: forward old var to new one
if(DEFINED LLAMA_BUILD_WEBUI)
set(LLAMA_BUILD_UI ${LLAMA_BUILD_WEBUI})
message(DEPRECATION "LLAMA_BUILD_WEBUI is deprecated, use LLAMA_BUILD_UI instead")
endif()
if(DEFINED LLAMA_USE_PREBUILT_WEBUI)
set(LLAMA_USE_PREBUILT_UI ${LLAMA_USE_PREBUILT_WEBUI})
message(DEPRECATION "LLAMA_USE_PREBUILT_WEBUI is deprecated, use LLAMA_USE_PREBUILT_UI instead")
endif()
if(DEFINED LLAMA_WEBUI_HF_BUCKET)
set(LLAMA_UI_HF_BUCKET ${LLAMA_WEBUI_HF_BUCKET})
message(DEPRECATION "LLAMA_WEBUI_HF_BUCKET is deprecated, use LLAMA_UI_HF_BUCKET instead")
endif()
# Resolve HF asset version: explicit env var > derived from build number > unset
if(DEFINED ENV{HF_WEBUI_VERSION})
set(HF_UI_VERSION "$ENV{HF_WEBUI_VERSION}")
message(DEPRECATION "HF_WEBUI_VERSION env var is deprecated, use HF_UI_VERSION instead")
elseif(DEFINED ENV{HF_UI_VERSION})
set(HF_UI_VERSION "$ENV{HF_UI_VERSION}")
elseif(DEFINED LLAMA_BUILD_NUMBER)
set(HF_UI_VERSION "b${LLAMA_BUILD_NUMBER}")
else()
set(HF_UI_VERSION "")
endif()
if(NOT "${HF_UI_VERSION}" STREQUAL "" AND NOT HF_UI_VERSION MATCHES "^[A-Za-z0-9._-]+$")
message(FATAL_ERROR "UI: invalid HF_UI_VERSION='${HF_UI_VERSION}' - must match ^[A-Za-z0-9._-]+$")
endif()
set(UI_CPP "${CMAKE_CURRENT_BINARY_DIR}/ui.cpp")
set(UI_H "${CMAKE_CURRENT_BINARY_DIR}/ui.h")
if(CMAKE_CROSSCOMPILING)
find_program(HOST_CXX_COMPILER NAMES g++ clang++ NO_CMAKE_FIND_ROOT_PATH)
if(NOT HOST_CXX_COMPILER)
message(FATAL_ERROR "UI: no host C++ compiler (g++/clang++) found to build llama-ui-embed; set -DHOST_CXX_COMPILER=<path>")
endif()
message(STATUS "UI: building llama-ui-embed with host compiler ${HOST_CXX_COMPILER}")
if(CMAKE_HOST_WIN32)
set(LLAMA_UI_EMBED_EXE "${CMAKE_CURRENT_BINARY_DIR}/llama-ui-embed-host.exe")
else()
set(LLAMA_UI_EMBED_EXE "${CMAKE_CURRENT_BINARY_DIR}/llama-ui-embed-host")
endif()
add_custom_command(
OUTPUT "${LLAMA_UI_EMBED_EXE}"
COMMAND "${HOST_CXX_COMPILER}" -O2 -std=c++17
-o "${LLAMA_UI_EMBED_EXE}" "${CMAKE_CURRENT_SOURCE_DIR}/embed.cpp"
DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/embed.cpp"
COMMENT "Building llama-ui-embed (host)"
VERBATIM
)
# phony target to tie it into the dependency graph
add_custom_target(llama-ui-embed DEPENDS "${LLAMA_UI_EMBED_EXE}")
else()
add_executable(llama-ui-embed embed.cpp)
target_compile_features(llama-ui-embed PRIVATE cxx_std_17)
set_target_properties(llama-ui-embed PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}"
)
set(LLAMA_UI_EMBED_EXE "$<TARGET_FILE:llama-ui-embed>")
endif()
# Run the provisioning script every build so source changes in tools/ui/ are
# always picked up. The script uses copy_if_different for ui.cpp/ui.h, so the
# library only recompiles when contents actually change.
add_custom_target(llama-ui-assets ALL
BYPRODUCTS ${UI_CPP} ${UI_H}
COMMAND ${CMAKE_COMMAND}
"-DUI_SOURCE_DIR=${CMAKE_CURRENT_SOURCE_DIR}"
"-DUI_BINARY_DIR=${CMAKE_CURRENT_BINARY_DIR}"
"-DLLAMA_SOURCE_DIR=${PROJECT_SOURCE_DIR}"
"-DLLAMA_BUILD_NUMBER=${LLAMA_BUILD_NUMBER}"
"-DHF_BUCKET=${LLAMA_UI_HF_BUCKET}"
"-DHF_VERSION=${HF_UI_VERSION}"
"-DHF_ENABLED=${LLAMA_USE_PREBUILT_UI}"
"-DBUILD_UI=${LLAMA_BUILD_UI}"
"-DLLAMA_UI_EMBED=${LLAMA_UI_EMBED_EXE}"
"-DLLAMA_UI_GZIP=${LLAMA_UI_GZIP}"
-P "${PROJECT_SOURCE_DIR}/scripts/ui-assets.cmake"
COMMENT "Provisioning UI assets"
VERBATIM
)
add_dependencies(llama-ui-assets llama-ui-embed)
set_source_files_properties(${UI_CPP} ${UI_H} PROPERTIES GENERATED TRUE)
add_library(${TARGET} STATIC ${UI_CPP} ${UI_H})
target_compile_features(${TARGET} PRIVATE cxx_std_17)
add_dependencies(${TARGET} llama-ui-assets)
if (BUILD_SHARED_LIBS)
set_target_properties(${TARGET} PROPERTIES POSITION_INDEPENDENT_CODE ON)
endif()
target_include_directories(${TARGET} PUBLIC
${CMAKE_CURRENT_BINARY_DIR}
)
+688
View File
@@ -0,0 +1,688 @@
# llama-ui
A modern, feature-rich web interface for llama-server built with SvelteKit. This UI provides an intuitive chat interface with advanced file handling, conversation management, and comprehensive model interaction capabilities.
Llama UI supports two server operation modes:
- **MODEL mode** - Single model operation (standard llama-server)
- **ROUTER mode** - Multi-model operation with dynamic model loading/unloading
---
## Table of Contents
- [Features](#features)
- [Getting Started](#getting-started)
- [Tech Stack](#tech-stack)
- [Build Pipeline](#build-pipeline)
- [Architecture](#architecture)
- [Data Flows](#data-flows)
- [Architectural Patterns](#architectural-patterns)
- [Testing](#testing)
---
## Features
### Chat Interface
- **Streaming responses** with real-time updates
- **Reasoning content** - Support for models with thinking/reasoning blocks
- **Dark/light theme** with system preference detection
- **Responsive design** for desktop and mobile
### File Attachments
- **Images** - JPEG, PNG, GIF, WebP, SVG (with PNG conversion)
- **Documents** - PDF (text extraction or image conversion for vision models)
- **Audio** - MP3, WAV for audio-capable models
- **Text files** - Source code, markdown, and other text formats
- **Drag-and-drop** and paste support with rich previews
### Conversation Management
- **Branching** - Branch messages conversations at any point by editing messages or regenerating responses, navigate between branches
- **Regeneration** - Regenerate responses with optional model switching (ROUTER mode)
- **Import/Export** - JSON format for backup and sharing
- **Search** - Find conversations by title or content
### Advanced Rendering
- **Syntax highlighting** - Code blocks with language detection
- **Math formulas** - KaTeX rendering for LaTeX expressions
- **Markdown** - Full GFM support with tables, lists, and more
### Multi-Model Support (ROUTER mode)
- **Model selector** with Loaded/Available groups
- **Automatic loading** - Models load on selection
- **Modality validation** - Prevents sending images to non-vision models
- **LRU unloading** - Server auto-manages model cache
### Keyboard Shortcuts
| Shortcut | Action |
| ------------------ | -------------------- |
| `Shift+Ctrl/Cmd+O` | New chat |
| `Shift+Ctrl/Cmd+E` | Edit conversation |
| `Shift+Ctrl/Cmd+D` | Delete conversation |
| `Ctrl/Cmd+K` | Search conversations |
| `Ctrl/Cmd+B` | Toggle sidebar |
### Developer Experience
- **Request tracking** - Monitor token generation with `/slots` endpoint
- **Storybook** - Component library with visual testing
- **Hot reload** - Instant updates during development
---
## Getting Started
### Prerequisites
- **Node.js** 18+ (20+ recommended)
- **npm** 9+
- **llama-server** running locally (for API access)
### 1. Install Dependencies
```bash
cd tools/ui
npm install
```
### 2. Start llama-server
In a separate terminal, start the backend server:
```bash
# Single model (MODEL mode)
./llama-server -m model.gguf
# Multi-model (ROUTER mode)
./llama-server --models-dir /path/to/models
```
### 3. Start Development Servers
```bash
npm run dev
```
This starts:
- **Vite dev server** at `http://localhost:5173` - The main UI frontend app
- **Storybook** at `http://localhost:6006` - Component documentation
The Vite dev server proxies API requests to `SERVER_ORIGIN` (with fallback to default llama-server `8080` port):
```typescript
// vite.config.ts proxy configuration
proxy: {
'/v1': SERVER_ORIGIN,
'/props': SERVER_ORIGIN,
'/models': SERVER_ORIGIN,
'/tools': SERVER_ORIGIN,
'/slots': SERVER_ORIGIN,
'/cors-proxy': SERVER_ORIGIN
},
```
### Development Workflow
1. Open `http://localhost:5173` in your browser
2. Make changes to `.svelte`, `.ts`, or `.css` files
3. Changes hot-reload instantly
4. Use Storybook at `http://localhost:6006` for isolated component development
---
## Tech Stack
| Layer | Technology | Purpose |
| ----------------- | ------------------------------- | -------------------------------------------------------- |
| **Framework** | SvelteKit + Svelte 5 | Reactive UI with runes (`$state`, `$derived`, `$effect`) |
| **UI Components** | shadcn-svelte + bits-ui | Accessible, customizable component library |
| **Styling** | TailwindCSS 4 | Utility-first CSS with design tokens |
| **Database** | IndexedDB (Dexie) | Client-side storage for conversations and messages |
| **Build** | Vite | Fast bundling with static adapter |
| **Testing** | Playwright + Vitest + Storybook | E2E, unit, and visual testing |
| **Markdown** | remark + rehype | Markdown processing with KaTeX and syntax highlighting |
### Key Dependencies
```json
{
"svelte": "^5.0.0",
"bits-ui": "^2.8.11",
"dexie": "^4.0.11",
"pdfjs-dist": "^5.4.54",
"highlight.js": "^11.11.1",
"rehype-katex": "^7.0.1"
}
```
---
## Build Pipeline
### Development Build
```bash
npm run dev
```
Runs Vite in development mode with:
- Hot Module Replacement (HMR)
- Source maps
- Proxy to llama-server
### Production Build
```bash
npm run build
```
The build process:
1. **Vite Build** - Bundles all TypeScript, Svelte, and CSS
2. **Static Adapter** - Outputs to `../../build/tools/ui/dist` (llama-server's static file directory)
3. **Post-Build Script** - Cleans up intermediate files
4. **Custom Plugin** - Creates `index.html` with:
- Inlined favicon as base64
- GZIP compression (level 9)
- Deterministic output (zeroed timestamps)
```text
tools/ui/ → build → build/tools/ui/dist/
├── src/ ├── index.html (served by llama-server)
├── static/ └── (favicon inlined)
└── ...
```
### SvelteKit Configuration
```javascript
// svelte.config.js
adapter: adapter({
pages: '../../build/tools/ui/dist', // Output directory
assets: '../../build/tools/ui/dist', // Static assets
fallback: 'index.html', // SPA fallback
strict: true
}),
output: {
bundleStrategy: 'inline' // Single-file bundle
}
```
### Integration with llama-server
llama-ui is embedded directly into the llama-server binary:
1. `npm run build` outputs `index.html` to `build/tools/ui/dist/`
2. llama-server compiles this into the binary at build time
3. When accessing `/`, llama-server serves the bundled HTML
This results in a **single portable binary** with the full Llama UI included.
---
## Architecture
Llama UI follows a layered architecture with unidirectional data flow:
```text
Routes → Components → Hooks → Stores → Services → Storage/API
```
### High-Level Architecture
See: [`docs/architecture/high-level-architecture-simplified.md`](docs/architecture/high-level-architecture-simplified.md)
```mermaid
flowchart TB
subgraph Routes["📍 Routes"]
R1["/ (Welcome)"]
R2["/chat/[id]"]
RL["+layout.svelte"]
end
subgraph Components["🧩 Components"]
C_Sidebar["ChatSidebar"]
C_Screen["ChatScreen"]
C_Form["ChatForm"]
C_Messages["ChatMessages"]
C_ModelsSelector["ModelsSelector"]
C_Settings["ChatSettings"]
end
subgraph Stores["🗄️ Stores"]
S1["chatStore"]
S2["conversationsStore"]
S3["modelsStore"]
S4["serverStore"]
S5["settingsStore"]
end
subgraph Services["⚙️ Services"]
SV1["ChatService"]
SV2["ModelsService"]
SV3["PropsService"]
SV4["DatabaseService"]
end
subgraph Storage["💾 Storage"]
ST1["IndexedDB"]
ST2["LocalStorage"]
end
subgraph APIs["🌐 llama-server"]
API1["/v1/chat/completions"]
API2["/props"]
API3["/models/*"]
end
R1 & R2 --> C_Screen
RL --> C_Sidebar
C_Screen --> C_Form & C_Messages & C_Settings
C_Screen --> S1 & S2
C_ModelsSelector --> S3 & S4
S1 --> SV1 & SV4
S3 --> SV2 & SV3
SV4 --> ST1
SV1 --> API1
SV2 --> API3
SV3 --> API2
```
### Layer Breakdown
#### Routes (`src/routes/`)
- **`/`** - Welcome screen, creates new conversation
- **`/chat/[id]`** - Active chat interface
- **`+layout.svelte`** - Sidebar, navigation, global initialization
#### Components (`src/lib/components/`)
Components are organized in `app/` (application-specific) and `ui/` (shadcn-svelte primitives).
**Chat Components** (`app/chat/`):
| Component | Responsibility |
| ------------------ | --------------------------------------------------------------------------- |
| `ChatScreen/` | Main chat container, coordinates message list, input form, and attachments |
| `ChatForm/` | Message input textarea with file upload, paste handling, keyboard shortcuts |
| `ChatMessages/` | Message list with branch navigation, regenerate/continue/edit actions |
| `ChatAttachments/` | File attachment previews, drag-and-drop, PDF/image/audio handling |
| `ChatSettings/` | Parameter sliders (temperature, top-p, etc.) with server default sync |
| `ChatSidebar/` | Conversation list, search, import/export, navigation |
**Dialog Components** (`app/dialogs/`):
| Component | Responsibility |
| ------------------------------- | -------------------------------------------------------- |
| `DialogChatSettings` | Full-screen settings configuration |
| `DialogModelInformation` | Model details (context size, modalities, parallel slots) |
| `DialogChatAttachmentPreview` | Full preview for images, PDFs (text or page view), code |
| `DialogConfirmation` | Generic confirmation for destructive actions |
| `DialogConversationTitleUpdate` | Edit conversation title |
**Server/Model Components** (`app/server/`, `app/models/`):
| Component | Responsibility |
| ------------------- | --------------------------------------------------------- |
| `ServerErrorSplash` | Error display when server is unreachable |
| `ModelsSelector` | Model dropdown with Loaded/Available groups (ROUTER mode) |
**Shared UI Components** (`app/misc/`):
| Component | Responsibility |
| -------------------------------- | ---------------------------------------------------------------- |
| `MarkdownContent` | Markdown rendering with KaTeX, syntax highlighting, copy buttons |
| `SyntaxHighlightedCode` | Code blocks with language detection and highlighting |
| `ActionButton`, `ActionDropdown` | Reusable action buttons and menus |
| `BadgeModality`, `BadgeInfo` | Status and capability badges |
#### Hooks (`src/lib/hooks/`)
- **`useModelChangeValidation`** - Validates model switch against conversation modalities
- **`useProcessingState`** - Tracks streaming progress and token generation
#### Stores (`src/lib/stores/`)
| Store | Responsibility |
| -------------------- | --------------------------------------------------------- |
| `chatStore` | Message sending, streaming, abort control, error handling |
| `conversationsStore` | CRUD for conversations, message branching, navigation |
| `modelsStore` | Model list, selection, loading/unloading (ROUTER) |
| `serverStore` | Server properties, role detection, modalities |
| `settingsStore` | User preferences, parameter sync with server defaults |
#### Services (`src/lib/services/`)
| Service | Responsibility |
| ---------------------- | ----------------------------------------------- |
| `ChatService` | API calls to`/v1/chat/completions`, SSE parsing |
| `ModelsService` | `/models`, `/models/load`, `/models/unload` |
| `PropsService` | `/props`, `/props?model=` |
| `DatabaseService` | IndexedDB operations via Dexie |
| `ParameterSyncService` | Syncs settings with server defaults |
---
## Data Flows
### MODEL Mode (Single Model)
See: [`docs/flows/data-flow-simplified-model-mode.md`](docs/flows/data-flow-simplified-model-mode.md)
```mermaid
sequenceDiagram
participant User
participant UI
participant Stores
participant DB as IndexedDB
participant API as llama-server
Note over User,API: Initialization
UI->>Stores: initialize()
Stores->>DB: load conversations
Stores->>API: GET /props
API-->>Stores: server config
Stores->>API: GET /v1/models
API-->>Stores: single model (auto-selected)
Note over User,API: Chat Flow
User->>UI: send message
Stores->>DB: save user message
Stores->>API: POST /v1/chat/completions (stream)
loop streaming
API-->>Stores: SSE chunks
Stores-->>UI: reactive update
end
Stores->>DB: save assistant message
```
### ROUTER Mode (Multi-Model)
See: [`docs/flows/data-flow-simplified-router-mode.md`](docs/flows/data-flow-simplified-router-mode.md)
```mermaid
sequenceDiagram
participant User
participant UI
participant Stores
participant API as llama-server
Note over User,API: Initialization
Stores->>API: GET /props
API-->>Stores: {role: "router"}
Stores->>API: GET /models
API-->>Stores: models[] with status
Note over User,API: Model Selection
User->>UI: select model
alt model not loaded
Stores->>API: POST /models/load
loop poll status
Stores->>API: GET /models
end
Stores->>API: GET /props?model=X
end
Stores->>Stores: validate modalities
Note over User,API: Chat Flow
Stores->>API: POST /v1/chat/completions {model: X}
loop streaming
API-->>Stores: SSE chunks + model info
end
```
### Detailed Flow Diagrams
| Flow | Description | File |
| ------------- | ------------------------------------------ | ----------------------------------------------------------- |
| Chat | Message lifecycle, streaming, regeneration | [`chat-flow.md`](docs/flows/chat-flow.md) |
| Models | Loading, unloading, modality caching | [`models-flow.md`](docs/flows/models-flow.md) |
| Server | Props fetching, role detection | [`server-flow.md`](docs/flows/server-flow.md) |
| Conversations | CRUD, branching, import/export | [`conversations-flow.md`](docs/flows/conversations-flow.md) |
| Database | IndexedDB schema, operations | [`database-flow.md`](docs/flows/database-flow.md) |
| Settings | Parameter sync, user overrides | [`settings-flow.md`](docs/flows/settings-flow.md) |
---
## Architectural Patterns
### 1. Reactive State with Svelte 5 Runes
All stores use Svelte 5's fine-grained reactivity:
```typescript
// Store with reactive state
class ChatStore {
#isLoading = $state(false);
#currentResponse = $state('');
// Derived values auto-update
get isStreaming() {
return $derived(this.#isLoading && this.#currentResponse.length > 0);
}
}
// Exported reactive accessors
export const isLoading = () => chatStore.isLoading;
export const currentResponse = () => chatStore.currentResponse;
```
### 2. Unidirectional Data Flow
Data flows in one direction, making state predictable:
```mermaid
flowchart LR
subgraph UI["UI Layer"]
A[User Action] --> B[Component]
end
subgraph State["State Layer"]
B --> C[Store Method]
C --> D[State Update]
end
subgraph IO["I/O Layer"]
C --> E[Service]
E --> F[API / IndexedDB]
F -.->|Response| D
end
D -->|Reactive| B
```
Components dispatch actions to stores, stores coordinate with services for I/O, and state updates reactively propagate back to the UI.
### 3. Per-Conversation State
Enables concurrent streaming across multiple conversations:
```typescript
class ChatStore {
chatLoadingStates = new Map<string, boolean>();
chatStreamingStates = new Map<string, { response: string; messageId: string }>();
abortControllers = new Map<string, AbortController>();
}
```
### 4. Message Branching with Tree Structure
Conversations are stored as a tree, not a linear list:
```typescript
interface DatabaseMessage {
id: string;
parent: string | null; // Points to parent message
children: string[]; // List of child message IDs
// ...
}
interface DatabaseConversation {
currentNode: string; // Currently viewed branch tip
// ...
}
```
Navigation between branches updates `currentNode` without losing history.
### 5. Layered Service Architecture
Stores handle state; services handle I/O:
```text
┌─────────────────┐
│ Stores │ Business logic, state management
├─────────────────┤
│ Services │ API calls, database operations
├─────────────────┤
│ Storage/API │ IndexedDB, LocalStorage, HTTP
└─────────────────┘
```
### 6. Server Role Abstraction
Single codebase handles both MODEL and ROUTER modes:
```typescript
// serverStore.ts
get isRouterMode() {
return this.role === ServerRole.ROUTER;
}
// Components conditionally render based on mode
{#if isRouterMode()}
<ModelsSelector />
{/if}
```
### 7. Modality Validation
Prevents sending attachments to incompatible models:
```typescript
// useModelChangeValidation hook
const validate = (modelId: string) => {
const modelModalities = modelsStore.getModelModalities(modelId);
const conversationModalities = conversationsStore.usedModalities;
// Check if model supports all used modalities
if (conversationModalities.hasImages && !modelModalities.vision) {
return { valid: false, reason: 'Model does not support images' };
}
// ...
};
```
### 8. Persistent Storage Strategy
Data is persisted across sessions using two storage mechanisms:
```mermaid
flowchart TB
subgraph Browser["Browser Storage"]
subgraph IDB["IndexedDB (Dexie)"]
C[Conversations]
M[Messages]
end
subgraph LS["LocalStorage"]
S[Settings Config]
O[User Overrides]
T[Theme Preference]
end
end
subgraph Stores["Svelte Stores"]
CS[conversationsStore] --> C
CS --> M
SS[settingsStore] --> S
SS --> O
SS --> T
end
```
- **IndexedDB**: Conversations and messages (large, structured data)
- **LocalStorage**: Settings, user parameter overrides, theme (small key-value data)
- **Memory only**: Server props, model list (fetched fresh on each session)
---
## Testing
### Test Types
| Type | Tool | Location | Command |
| ------------- | ------------------ | ---------------- | ------------------- |
| **Unit** | Vitest | `tests/unit/` | `npm run test:unit` |
| **UI/Visual** | Storybook + Vitest | `tests/stories/` | `npm run test:ui` |
| **E2E** | Playwright | `tests/e2e/` | `npm run test:e2e` |
| **Client** | Vitest | `tests/client/`. | `npm run test:unit` |
### Running Tests
```bash
# All tests
npm run test
# Individual test suites
npm run test:e2e # End-to-end (requires llama-server)
npm run test:client # Client-side unit tests
npm run test:server # Server-side unit tests
npm run test:ui # Storybook visual tests
```
### Storybook Development
```bash
npm run storybook # Start Storybook dev server on :6006
npm run build-storybook # Build static Storybook
```
### Linting and Formatting
```bash
npm run lint # Check code style
npm run format # Auto-format with Prettier
npm run check # TypeScript type checking
```
---
## Project Structure
```text
tools/ui/
├── src/
│ ├── lib/
│ │ ├── components/ # UI components (app/, ui/)
│ │ ├── hooks/ # Svelte hooks
│ │ ├── stores/ # State management
│ │ ├── services/ # API and database services
│ │ ├── types/ # TypeScript interfaces
│ │ └── utils/ # Utility functions
│ ├── routes/ # SvelteKit routes
│ └── styles/ # Global styles
├── static/ # Static assets
├── tests/ # Test files
├── docs/ # Architecture diagrams
│ ├── architecture/ # High-level architecture
│ └── flows/ # Feature-specific flows
└── .storybook/ # Storybook configuration
```
---
## Related Documentation
- [llama.cpp Server README](../server/README.md) - Full server documentation
- [Multimodal Documentation](../../docs/multimodal.md) - Image and audio support
- [Function Calling](../../docs/function-calling.md) - Tool use capabilities
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/app.css",
"baseColor": "neutral"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/components/ui/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
}
@@ -0,0 +1,145 @@
```mermaid
flowchart TB
subgraph Routes["📍 Routes"]
R1["/ (Welcome)"]
R2["/chat/[id]"]
RL["+layout.svelte"]
end
subgraph Components["🧩 Components"]
C_Sidebar["ChatSidebar"]
C_Screen["ChatScreen"]
C_Form["ChatForm"]
C_Messages["ChatMessages"]
C_Message["ChatMessage"]
C_ChatMessageAgenticContent["ChatMessageAgenticContent"]
C_MessageEditForm["ChatMessageEditForm"]
C_ModelsSelector["ModelsSelector"]
C_Settings["ChatSettings"]
C_McpSettings["McpServersSettings"]
C_McpResourceBrowser["McpResourceBrowser"]
C_McpServersSelector["McpServersSelector"]
end
subgraph Hooks["🪝 Hooks"]
H1["useModelChangeValidation"]
H2["useProcessingState"]
end
subgraph Stores["🗄️ Stores"]
S1["chatStore<br/><i>Chat interactions & streaming</i>"]
SA["agenticStore<br/><i>Multi-turn agentic loop orchestration</i>"]
S2["conversationsStore<br/><i>Conversation data, messages & MCP overrides</i>"]
S3["modelsStore<br/><i>Model selection & loading</i>"]
S4["serverStore<br/><i>Server props & role detection</i>"]
S5["settingsStore<br/><i>User configuration incl. MCP</i>"]
S6["mcpStore<br/><i>MCP servers, tools, prompts</i>"]
S7["mcpResourceStore<br/><i>MCP resources & attachments</i>"]
end
subgraph Services["⚙️ Services"]
SV1["ChatService"]
SV2["ModelsService"]
SV3["PropsService"]
SV4["DatabaseService"]
SV5["ParameterSyncService"]
SV6["MCPService<br/><i>protocol operations</i>"]
end
subgraph Storage["💾 Storage"]
ST1["IndexedDB<br/><i>conversations, messages</i>"]
ST2["LocalStorage<br/><i>config, userOverrides, mcpServers</i>"]
end
subgraph APIs["🌐 llama-server API"]
API1["/v1/chat/completions"]
API2["/props"]
API3["/models/*"]
API4["/v1/models"]
end
subgraph ExternalMCP["🔌 External MCP Servers"]
EXT1["MCP Server 1<br/><i>WebSocket/HTTP/SSE</i>"]
EXT2["MCP Server N"]
end
%% Routes → Components
R1 & R2 --> C_Screen
RL --> C_Sidebar
%% Layout runs MCP health checks
RL --> S6
%% Component hierarchy
C_Screen --> C_Form & C_Messages & C_Settings
C_Messages --> C_Message
C_Message --> C_ChatMessageAgenticContent
C_Message --> C_MessageEditForm
C_Form & C_MessageEditForm --> C_ModelsSelector
C_Form --> C_McpServersSelector
C_Settings --> C_McpSettings
C_McpSettings --> C_McpResourceBrowser
%% Components → Hooks → Stores
C_Form & C_Messages --> H1 & H2
H1 --> S3 & S4
H2 --> S1 & S5
%% Components → Stores
C_Screen --> S1 & S2
C_Sidebar --> S2
C_ModelsSelector --> S3 & S4
C_Settings --> S5
C_McpSettings --> S6
C_McpResourceBrowser --> S6 & S7
C_McpServersSelector --> S6
C_Form --> S6
%% chatStore → agenticStore → mcpStore (agentic loop)
S1 --> SA
SA --> SV1
SA --> S6
%% Stores → Services
S1 --> SV1 & SV4
S2 --> SV4
S3 --> SV2 & SV3
S4 --> SV3
S5 --> SV5
S6 --> SV6
S7 --> SV6
%% Services → Storage
SV4 --> ST1
SV5 --> ST2
%% Services → APIs
SV1 --> API1
SV2 --> API3 & API4
SV3 --> API2
%% MCP → External Servers
SV6 --> EXT1 & EXT2
%% Styling
classDef routeStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px
classDef componentStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
classDef hookStyle fill:#fff8e1,stroke:#ff8f00,stroke-width:2px
classDef storeStyle fill:#fff3e0,stroke:#e65100,stroke-width:2px
classDef serviceStyle fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
classDef storageStyle fill:#fce4ec,stroke:#c2185b,stroke-width:2px
classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
classDef mcpStyle fill:#e0f2f1,stroke:#00695c,stroke-width:2px
classDef agenticStyle fill:#e8eaf6,stroke:#283593,stroke-width:2px
classDef externalStyle fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px,stroke-dasharray: 5 5
class R1,R2,RL routeStyle
class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_ChatMessageAgenticContent,C_MessageEditForm,C_ModelsSelector,C_Settings componentStyle
class C_McpSettings,C_McpResourceBrowser,C_McpServersSelector componentStyle
class H1,H2 hookStyle
class S1,S2,S3,S4,S5,SA,S6,S7 storeStyle
class SV1,SV2,SV3,SV4,SV5,SV6 serviceStyle
class ST1,ST2 storageStyle
class API1,API2,API3,API4 apiStyle
class EXT1,EXT2 externalStyle
```
@@ -0,0 +1,373 @@
```mermaid
flowchart TB
subgraph Routes["📍 Routes"]
R1["/ (+page.svelte)"]
R2["/chat/[id]"]
RL["+layout.svelte"]
end
subgraph Components["🧩 Components"]
direction TB
subgraph LayoutComponents["Layout"]
C_Sidebar["ChatSidebar"]
C_Screen["ChatScreen"]
end
subgraph ChatUIComponents["Chat UI"]
C_Form["ChatForm"]
C_Messages["ChatMessages"]
C_Message["ChatMessage"]
C_MessageUser["ChatMessageUser"]
C_MessageEditForm["ChatMessageEditForm"]
C_Attach["ChatAttachments"]
C_ModelsSelector["ModelsSelector"]
C_Settings["ChatSettings"]
end
subgraph MCPComponents["MCP UI"]
C_McpSettings["McpServersSettings"]
C_McpServerCard["McpServerCard"]
C_McpResourceBrowser["McpResourceBrowser"]
C_McpResourcePreview["McpResourcePreview"]
C_McpServersSelector["McpServersSelector"]
end
end
subgraph Hooks["🪝 Hooks"]
H1["useModelChangeValidation"]
H2["useProcessingState"]
H3["isMobile"]
end
subgraph Stores["🗄️ Stores"]
direction TB
subgraph S1["chatStore"]
S1State["<b>State:</b><br/>isLoading, currentResponse<br/>errorDialogState<br/>activeProcessingState<br/>chatLoadingStates<br/>chatStreamingStates<br/>abortControllers<br/>processingStates<br/>activeConversationId<br/>isStreamingActive"]
S1LoadState["<b>Loading State:</b><br/>setChatLoading()<br/>isChatLoading()<br/>syncLoadingStateForChat()<br/>clearUIState()<br/>isChatLoadingPublic()<br/>getAllLoadingChats()<br/>getAllStreamingChats()"]
S1ProcState["<b>Processing State:</b><br/>setActiveProcessingConversation()<br/>getProcessingState()<br/>clearProcessingState()<br/>getActiveProcessingState()<br/>updateProcessingStateFromTimings()<br/>getCurrentProcessingStateSync()<br/>restoreProcessingStateFromMessages()"]
S1Stream["<b>Streaming:</b><br/>streamChatCompletion()<br/>startStreaming()<br/>stopStreaming()<br/>stopGeneration()<br/>isStreaming()"]
S1Error["<b>Error Handling:</b><br/>showErrorDialog()<br/>dismissErrorDialog()<br/>isAbortError()"]
S1Msg["<b>Message Operations:</b><br/>addMessage()<br/>sendMessage()<br/>updateMessage()<br/>deleteMessage()<br/>getDeletionInfo()"]
S1Regen["<b>Regeneration:</b><br/>regenerateMessage()<br/>regenerateMessageWithBranching()<br/>continueAssistantMessage()"]
S1Edit["<b>Editing:</b><br/>editAssistantMessage()<br/>editUserMessagePreserveResponses()<br/>editMessageWithBranching()<br/>clearEditMode()<br/>isEditModeActive()<br/>getAddFilesHandler()<br/>setEditModeActive()"]
S1Utils["<b>Utilities:</b><br/>getApiOptions()<br/>parseTimingData()<br/>getOrCreateAbortController()<br/>getConversationModel()"]
end
subgraph SA["agenticStore"]
SAState["<b>State:</b><br/>sessions (Map)<br/>isAnyRunning"]
SASession["<b>Session Management:</b><br/>getSession()<br/>updateSession()<br/>clearSession()<br/>getActiveSessions()<br/>isRunning()<br/>currentTurn()<br/>totalToolCalls()<br/>lastError()<br/>streamingToolCall()"]
SAConfig["<b>Configuration:</b><br/>getConfig()<br/>maxTurns, maxToolPreviewLines"]
SAFlow["<b>Agentic Loop:</b><br/>runAgenticFlow()<br/>executeAgenticLoop()<br/>normalizeToolCalls()<br/>emitToolCallResult()<br/>extractBase64Attachments()"]
end
subgraph S2["conversationsStore"]
S2State["<b>State:</b><br/>conversations<br/>activeConversation<br/>activeMessages<br/>isInitialized<br/>pendingMcpServerOverrides<br/>titleUpdateConfirmationCallback"]
S2Lifecycle["<b>Lifecycle:</b><br/>initialize()<br/>loadConversations()<br/>clearActiveConversation()"]
S2ConvCRUD["<b>Conversation CRUD:</b><br/>createConversation()<br/>loadConversation()<br/>deleteConversation()<br/>deleteAll()<br/>updateConversationName()<br/>updateConversationTitleWithConfirmation()"]
S2MsgMgmt["<b>Message Management:</b><br/>refreshActiveMessages()<br/>addMessageToActive()<br/>updateMessageAtIndex()<br/>findMessageIndex()<br/>sliceActiveMessages()<br/>removeMessageAtIndex()<br/>getConversationMessages()"]
S2Nav["<b>Navigation:</b><br/>navigateToSibling()<br/>updateCurrentNode()<br/>updateConversationTimestamp()"]
S2McpOverrides["<b>MCP Per-Chat Overrides:</b><br/>getMcpServerOverride()<br/>getAllMcpServerOverrides()<br/>setMcpServerOverride()<br/>toggleMcpServerForChat()<br/>removeMcpServerOverride()<br/>isMcpServerEnabledForChat()<br/>clearPendingMcpServerOverrides()"]
S2Export["<b>Import/Export:</b><br/>downloadConversation()<br/>exportAllConversations()<br/>importConversations()<br/>importConversationsData()<br/>triggerDownload()"]
S2Utils["<b>Utilities:</b><br/>setTitleUpdateConfirmationCallback()"]
end
subgraph S3["modelsStore"]
S3State["<b>State:</b><br/>models, routerModels<br/>selectedModelId<br/>selectedModelName<br/>loading, updating, error<br/>modelLoadingStates<br/>modelPropsCache<br/>modelPropsFetching<br/>propsCacheVersion"]
S3Getters["<b>Computed Getters:</b><br/>selectedModel<br/>loadedModelIds<br/>loadingModelIds<br/>singleModelName"]
S3Modal["<b>Modalities:</b><br/>getModelModalities()<br/>modelSupportsVision()<br/>modelSupportsAudio()<br/>getModelModalitiesArray()<br/>getModelProps()<br/>updateModelModalities()"]
S3Status["<b>Status Queries:</b><br/>isModelLoaded()<br/>isModelOperationInProgress()<br/>getModelStatus()<br/>isModelPropsFetching()"]
S3Fetch["<b>Data Fetching:</b><br/>fetch()<br/>fetchRouterModels()<br/>fetchModelProps()<br/>fetchModalitiesForLoadedModels()"]
S3Select["<b>Model Selection:</b><br/>selectModelById()<br/>selectModelByName()<br/>clearSelection()<br/>findModelByName()<br/>findModelById()<br/>hasModel()"]
S3LoadUnload["<b>Loading/Unloading Models:</b><br/>loadModel()<br/>unloadModel()<br/>ensureModelLoaded()<br/>waitForModelStatus()<br/>pollForModelStatus()"]
S3Utils["<b>Utilities:</b><br/>toDisplayName()<br/>clear()"]
end
subgraph S4["serverStore"]
S4State["<b>State:</b><br/>props<br/>loading, error<br/>role<br/>fetchPromise"]
S4Getters["<b>Getters:</b><br/>defaultParams<br/>contextSize<br/>isRouterMode<br/>isModelMode"]
S4Data["<b>Data Handling:</b><br/>fetch()<br/>getErrorMessage()<br/>clear()"]
S4Utils["<b>Utilities:</b><br/>detectRole()"]
end
subgraph S5["settingsStore"]
S5State["<b>State:</b><br/>config<br/>theme<br/>isInitialized<br/>userOverrides"]
S5Lifecycle["<b>Lifecycle:</b><br/>initialize()<br/>loadConfig()<br/>saveConfig()<br/>loadTheme()<br/>saveTheme()"]
S5Update["<b>Config Updates:</b><br/>updateConfig()<br/>updateMultipleConfig()<br/>updateTheme()"]
S5Reset["<b>Reset:</b><br/>resetConfig()<br/>resetTheme()<br/>resetAll()<br/>resetParameterToServerDefault()"]
S5Sync["<b>Server Sync:</b><br/>syncWithServerDefaults()<br/>forceSyncWithServerDefaults()"]
S5Utils["<b>Utilities:</b><br/>getConfig()<br/>getAllConfig()<br/>getParameterInfo()<br/>getParameterDiff()<br/>getServerDefaults()<br/>clearAllUserOverrides()"]
end
subgraph S6["mcpStore"]
S6State["<b>State:</b><br/>isInitializing, error<br/>toolCount, connectedServers<br/>healthChecks (Map)<br/>connections (Map)<br/>toolsIndex (Map)"]
S6Lifecycle["<b>Lifecycle:</b><br/>ensureInitialized()<br/>initialize()<br/>shutdown()<br/>acquireConnection()<br/>releaseConnection()"]
S6Health["<b>Health Checks:</b><br/>runHealthCheck()<br/>runHealthChecksForServers()<br/>updateHealthCheck()<br/>getHealthCheckState()<br/>clearHealthCheck()"]
S6Servers["<b>Server Management:</b><br/>getServers()<br/>addServer()<br/>updateServer()<br/>removeServer()<br/>getServerById()<br/>getServerDisplayName()"]
S6Tools["<b>Tool Operations:</b><br/>getToolDefinitionsForLLM()<br/>getToolNames()<br/>hasTool()<br/>getToolServer()<br/>executeTool()<br/>executeToolByName()"]
S6Prompts["<b>Prompt Operations:</b><br/>getAllPrompts()<br/>getPrompt()<br/>hasPromptsCapability()<br/>getPromptCompletions()"]
end
subgraph S7["mcpResourceStore"]
S7State["<b>State:</b><br/>serverResources (Map)<br/>cachedResources (Map)<br/>subscriptions (Map)<br/>attachments[]<br/>isLoading"]
S7Resources["<b>Resource Discovery:</b><br/>setServerResources()<br/>getServerResources()<br/>getAllResourceInfos()<br/>getAllTemplateInfos()<br/>clearServerResources()"]
S7Cache["<b>Caching:</b><br/>cacheResourceContent()<br/>getCachedContent()<br/>invalidateCache()<br/>clearCache()"]
S7Subs["<b>Subscriptions:</b><br/>addSubscription()<br/>removeSubscription()<br/>isSubscribed()<br/>handleResourceUpdate()"]
S7Attach["<b>Attachments:</b><br/>addAttachment()<br/>updateAttachmentContent()<br/>removeAttachment()<br/>clearAttachments()<br/>toMessageExtras()"]
end
subgraph ReactiveExports["⚡ Reactive Exports"]
direction LR
subgraph ChatExports["chatStore"]
RE1["isLoading()"]
RE2["currentResponse()"]
RE3["errorDialog()"]
RE4["activeProcessingState()"]
RE5["isChatStreaming()"]
RE6["isChatLoading()"]
RE7["getChatStreaming()"]
RE8["getAllLoadingChats()"]
RE9["getAllStreamingChats()"]
RE9a["isEditModeActive()"]
RE9b["getAddFilesHandler()"]
RE9c["setEditModeActive()"]
RE9d["clearEditMode()"]
end
subgraph AgenticExports["agenticStore"]
REA1["agenticIsRunning()"]
REA2["agenticCurrentTurn()"]
REA3["agenticTotalToolCalls()"]
REA4["agenticLastError()"]
REA5["agenticStreamingToolCall()"]
REA6["agenticIsAnyRunning()"]
end
subgraph ConvExports["conversationsStore"]
RE10["conversations()"]
RE11["activeConversation()"]
RE12["activeMessages()"]
RE13["isConversationsInitialized()"]
end
subgraph ModelsExports["modelsStore"]
RE15["modelOptions()"]
RE16["routerModels()"]
RE17["modelsLoading()"]
RE18["modelsUpdating()"]
RE19["modelsError()"]
RE20["selectedModelId()"]
RE21["selectedModelName()"]
RE22["selectedModelOption()"]
RE23["loadedModelIds()"]
RE24["loadingModelIds()"]
RE25["propsCacheVersion()"]
RE26["singleModelName()"]
end
subgraph ServerExports["serverStore"]
RE27["serverProps()"]
RE28["serverLoading()"]
RE29["serverError()"]
RE30["serverRole()"]
RE31["defaultParams()"]
RE32["contextSize()"]
RE33["isRouterMode()"]
RE34["isModelMode()"]
end
subgraph SettingsExports["settingsStore"]
RE35["config()"]
RE36["theme()"]
RE37["isInitialized()"]
end
subgraph MCPExports["mcpStore / mcpResourceStore"]
RE38["mcpResources()"]
RE39["mcpResourceAttachments()"]
RE40["mcpHasResourceAttachments()"]
RE41["mcpTotalResourceCount()"]
RE42["mcpResourcesLoading()"]
end
end
end
subgraph Services["⚙️ Services"]
direction TB
subgraph SV1["ChatService"]
SV1Msg["<b>Messaging:</b><br/>sendMessage()"]
SV1Stream["<b>Streaming:</b><br/>handleStreamResponse()<br/>handleNonStreamResponse()"]
SV1Convert["<b>Conversion:</b><br/>convertDbMessageToApiChatMessageData()<br/>mergeToolCallDeltas()"]
SV1Utils["<b>Utilities:</b><br/>stripReasoningContent()<br/>extractModelName()<br/>parseErrorResponse()"]
end
subgraph SV2["ModelsService"]
SV2List["<b>Listing:</b><br/>list()<br/>listRouter()"]
SV2LoadUnload["<b>Load/Unload:</b><br/>load()<br/>unload()"]
SV2Status["<b>Status:</b><br/>isModelLoaded()<br/>isModelLoading()"]
end
subgraph SV3["PropsService"]
SV3Fetch["<b>Fetching:</b><br/>fetch()<br/>fetchForModel()"]
end
subgraph SV4["DatabaseService"]
SV4Conv["<b>Conversations:</b><br/>createConversation()<br/>getConversation()<br/>getAllConversations()<br/>updateConversation()<br/>deleteConversation()"]
SV4Msg["<b>Messages:</b><br/>createMessageBranch()<br/>createRootMessage()<br/>createSystemMessage()<br/>getConversationMessages()<br/>updateMessage()<br/>deleteMessage()<br/>deleteMessageCascading()"]
SV4Node["<b>Navigation:</b><br/>updateCurrentNode()"]
SV4Import["<b>Import:</b><br/>importConversations()"]
end
subgraph SV5["ParameterSyncService"]
SV5Extract["<b>Extraction:</b><br/>extractServerDefaults()"]
SV5Merge["<b>Merging:</b><br/>mergeWithServerDefaults()"]
SV5Info["<b>Info:</b><br/>getParameterInfo()<br/>canSyncParameter()<br/>getSyncableParameterKeys()<br/>validateServerParameter()"]
SV5Diff["<b>Diff:</b><br/>createParameterDiff()"]
end
subgraph SV6["MCPService"]
SV6Transport["<b>Transport:</b><br/>createTransport()<br/>WebSocket / StreamableHTTP / SSE"]
SV6Conn["<b>Connection:</b><br/>connect()<br/>disconnect()"]
SV6Tools["<b>Tools:</b><br/>listTools()<br/>callTool()"]
SV6Prompts["<b>Prompts:</b><br/>listPrompts()<br/>getPrompt()"]
SV6Resources["<b>Resources:</b><br/>listResources()<br/>listResourceTemplates()<br/>readResource()<br/>subscribeResource()<br/>unsubscribeResource()"]
SV6Complete["<b>Completions:</b><br/>complete()"]
end
end
subgraph ExternalMCP["🔌 External MCP Servers"]
EXT1["MCP Server 1<br/>(WebSocket/StreamableHTTP/SSE)"]
EXT2["MCP Server N"]
end
subgraph Storage["💾 Storage"]
ST1["IndexedDB"]
ST2["conversations"]
ST3["messages"]
ST5["LocalStorage"]
ST6["config"]
ST7["userOverrides"]
ST8["mcpServers"]
end
subgraph APIs["🌐 llama-server API"]
API1["/v1/chat/completions"]
API2["/props<br/>/props?model="]
API3["/models<br/>/models/load<br/>/models/unload"]
API4["/v1/models"]
end
%% Routes render Components
R1 --> C_Screen
R2 --> C_Screen
RL --> C_Sidebar
%% Layout runs MCP health checks on startup
RL --> S6
%% Component hierarchy
C_Screen --> C_Form & C_Messages & C_Settings
C_Messages --> C_Message
C_Message --> C_MessageUser
C_MessageUser --> C_MessageEditForm
C_MessageEditForm --> C_ModelsSelector
C_MessageEditForm --> C_Attach
C_Form --> C_ModelsSelector
C_Form --> C_Attach
C_Form --> C_McpServersSelector
C_Message --> C_Attach
%% MCP Components hierarchy
C_Settings --> C_McpSettings
C_McpSettings --> C_McpServerCard
C_McpServerCard --> C_McpResourceBrowser
C_McpResourceBrowser --> C_McpResourcePreview
%% Components use Hooks
C_Form --> H1
C_Message --> H1 & H2
C_MessageEditForm --> H1
C_Screen --> H2
%% Hooks use Stores
H1 --> S3 & S4
H2 --> S1 & S5
%% Components use Stores
C_Screen --> S1 & S2
C_Messages --> S2
C_Message --> S1 & S2 & S3
C_Form --> S1 & S3 & S6
C_Sidebar --> S2
C_ModelsSelector --> S3 & S4
C_Settings --> S5
C_McpSettings --> S6
C_McpServerCard --> S6
C_McpResourceBrowser --> S6 & S7
C_McpServersSelector --> S6
%% Stores export Reactive State
S1 -. exports .-> ChatExports
SA -. exports .-> AgenticExports
S2 -. exports .-> ConvExports
S3 -. exports .-> ModelsExports
S4 -. exports .-> ServerExports
S5 -. exports .-> SettingsExports
S6 -. exports .-> MCPExports
S7 -. exports .-> MCPExports
%% chatStore → agenticStore (agentic loop orchestration)
S1 --> SA
SA --> SV1
SA --> S6
%% Stores use Services
S1 --> SV1 & SV4
S2 --> SV4
S3 --> SV2 & SV3
S4 --> SV3
S5 --> SV5
S6 --> SV6
S7 --> SV6
%% Services to Storage
SV4 --> ST1
ST1 --> ST2 & ST3
SV5 --> ST5
ST5 --> ST6 & ST7 & ST8
%% Services to APIs
SV1 --> API1
SV2 --> API3 & API4
SV3 --> API2
%% MCP → External Servers
SV6 --> EXT1 & EXT2
%% Styling
classDef routeStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px
classDef componentStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
classDef componentGroupStyle fill:#e1bee7,stroke:#7b1fa2,stroke-width:1px
classDef hookStyle fill:#fff8e1,stroke:#ff8f00,stroke-width:2px
classDef storeStyle fill:#fff3e0,stroke:#e65100,stroke-width:2px
classDef stateStyle fill:#ffe0b2,stroke:#e65100,stroke-width:1px
classDef methodStyle fill:#ffecb3,stroke:#e65100,stroke-width:1px
classDef reactiveStyle fill:#fffde7,stroke:#f9a825,stroke-width:1px
classDef serviceStyle fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
classDef serviceMStyle fill:#c8e6c9,stroke:#2e7d32,stroke-width:1px
classDef externalStyle fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px,stroke-dasharray: 5 5
classDef storageStyle fill:#fce4ec,stroke:#c2185b,stroke-width:2px
classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
class R1,R2,RL routeStyle
class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_MessageUser,C_MessageEditForm componentStyle
class C_ModelsSelector,C_Settings componentStyle
class C_Attach componentStyle
class C_McpSettings,C_McpServerCard,C_McpResourceBrowser,C_McpResourcePreview,C_McpServersSelector componentStyle
class H1,H2,H3 hookStyle
class LayoutComponents,ChatUIComponents,MCPComponents componentGroupStyle
class Hooks hookStyle
classDef agenticStyle fill:#e8eaf6,stroke:#283593,stroke-width:2px
classDef agenticMethodStyle fill:#c5cae9,stroke:#283593,stroke-width:1px
class S1,S2,S3,S4,S5,SA,S6,S7 storeStyle
class S1State,S2State,S3State,S4State,S5State,SAState,S6State,S7State stateStyle
class S1Msg,S1Regen,S1Edit,S1Stream,S1LoadState,S1ProcState,S1Error,S1Utils methodStyle
class SASession,SAConfig,SAFlow methodStyle
class S2Lifecycle,S2ConvCRUD,S2MsgMgmt,S2Nav,S2McpOverrides,S2Export,S2Utils methodStyle
class S3Getters,S3Modal,S3Status,S3Fetch,S3Select,S3LoadUnload,S3Utils methodStyle
class S4Getters,S4Data,S4Utils methodStyle
class S5Lifecycle,S5Update,S5Reset,S5Sync,S5Utils methodStyle
class S6Lifecycle,S6Health,S6Servers,S6Tools,S6Prompts methodStyle
class S7Resources,S7Cache,S7Subs,S7Attach methodStyle
class ChatExports,AgenticExports,ConvExports,ModelsExports,ServerExports,SettingsExports,MCPExports reactiveStyle
class SV1,SV2,SV3,SV4,SV5,SV6 serviceStyle
class SV6Transport,SV6Conn,SV6Tools,SV6Prompts,SV6Resources,SV6Complete serviceMStyle
class EXT1,EXT2 externalStyle
class SV1Msg,SV1Stream,SV1Convert,SV1Utils serviceMStyle
class SV2List,SV2LoadUnload,SV2Status serviceMStyle
class SV3Fetch serviceMStyle
class SV4Conv,SV4Msg,SV4Node,SV4Import serviceMStyle
class SV5Extract,SV5Merge,SV5Info,SV5Diff serviceMStyle
class ST1,ST2,ST3,ST5,ST6,ST7,ST8 storageStyle
class API1,API2,API3,API4 apiStyle
```
+228
View File
@@ -0,0 +1,228 @@
```mermaid
sequenceDiagram
participant UI as 🧩 ChatForm / ChatMessage
participant chatStore as 🗄️ chatStore
participant agenticStore as 🗄️ agenticStore
participant convStore as 🗄️ conversationsStore
participant settingsStore as 🗄️ settingsStore
participant mcpStore as 🗄️ mcpStore
participant ChatSvc as ⚙️ ChatService
participant DbSvc as ⚙️ DatabaseService
participant API as 🌐 /v1/chat/completions
Note over chatStore: State:<br/>isLoading, currentResponse<br/>errorDialogState, activeProcessingState<br/>chatLoadingStates (Map)<br/>chatStreamingStates (Map)<br/>abortControllers (Map)<br/>processingStates (Map)
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 💬 SEND MESSAGE
%% ═══════════════════════════════════════════════════════════════════════════
UI->>chatStore: sendMessage(content, extras)
activate chatStore
chatStore->>chatStore: setChatLoading(convId, true)
chatStore->>chatStore: clearChatStreaming(convId)
alt no active conversation
chatStore->>convStore: createConversation()
Note over convStore: → see conversations-flow.mmd
end
chatStore->>mcpStore: consumeResourceAttachmentsAsExtras()
Note right of mcpStore: Converts pending MCP resource<br/>attachments into message extras
chatStore->>chatStore: addMessage("user", content, extras)
chatStore->>DbSvc: createMessageBranch(userMsg, parentId)
chatStore->>convStore: addMessageToActive(userMsg)
chatStore->>convStore: updateCurrentNode(userMsg.id)
chatStore->>chatStore: createAssistantMessage(userMsg.id)
chatStore->>DbSvc: createMessageBranch(assistantMsg, userMsg.id)
chatStore->>convStore: addMessageToActive(assistantMsg)
chatStore->>chatStore: streamChatCompletion(messages, assistantMsg)
deactivate chatStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 🌊 STREAMING (with agentic flow detection)
%% ═══════════════════════════════════════════════════════════════════════════
activate chatStore
chatStore->>chatStore: startStreaming()
Note right of chatStore: isStreamingActive = true
chatStore->>chatStore: setActiveProcessingConversation(convId)
chatStore->>chatStore: getOrCreateAbortController(convId)
Note right of chatStore: abortControllers.set(convId, new AbortController())
chatStore->>chatStore: getApiOptions()
Note right of chatStore: Merge from settingsStore.config:<br/>temperature, max_tokens, top_p, etc.
alt agenticConfig.enabled && mcpStore has connected servers
chatStore->>agenticStore: runAgenticFlow(convId, messages, assistantMsg, options, signal)
Note over agenticStore: Multi-turn agentic loop:<br/>1. Call ChatService.sendMessage()<br/>2. If response has tool_calls → execute via mcpStore<br/>3. Append tool results as messages<br/>4. Loop until no more tool_calls or maxTurns<br/>→ see agentic flow details below
agenticStore-->>chatStore: final response with timings
else standard (non-agentic) flow
chatStore->>ChatSvc: sendMessage(messages, options, signal)
end
activate ChatSvc
ChatSvc->>ChatSvc: convertDbMessageToApiChatMessageData(messages)
Note right of ChatSvc: DatabaseMessage[] → ApiChatMessageData[]<br/>Process attachments (images, PDFs, audio)
ChatSvc->>API: POST /v1/chat/completions
Note right of API: {messages, model?, stream: true, ...params}
loop SSE chunks
API-->>ChatSvc: data: {"choices":[{"delta":{...}}]}
ChatSvc->>ChatSvc: handleStreamResponse(response)
alt content chunk
ChatSvc-->>chatStore: onChunk(content)
chatStore->>chatStore: setChatStreaming(convId, response, msgId)
Note right of chatStore: currentResponse = $state(accumulated)
chatStore->>convStore: updateMessageAtIndex(idx, {content})
end
alt reasoning chunk
ChatSvc-->>chatStore: onReasoningChunk(reasoning)
chatStore->>convStore: updateMessageAtIndex(idx, {thinking})
end
alt tool_calls chunk
ChatSvc-->>chatStore: onToolCallChunk(toolCalls)
chatStore->>convStore: updateMessageAtIndex(idx, {toolCalls})
end
alt model info
ChatSvc-->>chatStore: onModel(modelName)
chatStore->>chatStore: recordModel(modelName)
chatStore->>DbSvc: updateMessage(msgId, {model})
end
alt timings (during stream)
ChatSvc-->>chatStore: onTimings(timings, promptProgress)
chatStore->>chatStore: updateProcessingStateFromTimings()
end
chatStore-->>UI: reactive $state update
end
API-->>ChatSvc: data: [DONE]
ChatSvc-->>chatStore: onComplete(content, reasoning, timings, toolCalls)
deactivate ChatSvc
chatStore->>chatStore: stopStreaming()
chatStore->>DbSvc: updateMessage(msgId, {content, timings, model})
chatStore->>convStore: updateCurrentNode(msgId)
chatStore->>chatStore: setChatLoading(convId, false)
chatStore->>chatStore: clearChatStreaming(convId)
chatStore->>chatStore: clearProcessingState(convId)
deactivate chatStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: ⏹️ STOP GENERATION
%% ═══════════════════════════════════════════════════════════════════════════
UI->>chatStore: stopGeneration()
activate chatStore
chatStore->>chatStore: savePartialResponseIfNeeded(convId)
Note right of chatStore: Save currentResponse to DB if non-empty
chatStore->>chatStore: abortControllers.get(convId).abort()
Note right of chatStore: fetch throws AbortError → caught by isAbortError()
chatStore->>chatStore: stopStreaming()
chatStore->>chatStore: setChatLoading(convId, false)
chatStore->>chatStore: clearChatStreaming(convId)
chatStore->>chatStore: clearProcessingState(convId)
deactivate chatStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 🔁 REGENERATE
%% ═══════════════════════════════════════════════════════════════════════════
UI->>chatStore: regenerateMessageWithBranching(msgId, model?)
activate chatStore
chatStore->>convStore: findMessageIndex(msgId)
chatStore->>chatStore: Get parent of target message
chatStore->>chatStore: createAssistantMessage(parentId)
chatStore->>DbSvc: createMessageBranch(newAssistantMsg, parentId)
chatStore->>convStore: refreshActiveMessages()
Note right of chatStore: Same streaming flow
chatStore->>chatStore: streamChatCompletion(...)
deactivate chatStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: ➡️ CONTINUE
%% ═══════════════════════════════════════════════════════════════════════════
UI->>chatStore: continueAssistantMessage(msgId)
activate chatStore
chatStore->>chatStore: Get existing content from message
chatStore->>chatStore: streamChatCompletion(..., existingContent)
Note right of chatStore: Appends to existing message content
deactivate chatStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: ✏️ EDIT USER MESSAGE
%% ═══════════════════════════════════════════════════════════════════════════
UI->>chatStore: editMessageWithBranching(msgId, newContent, extras)
activate chatStore
chatStore->>chatStore: Get parent of target message
chatStore->>DbSvc: createMessageBranch(editedMsg, parentId)
chatStore->>convStore: refreshActiveMessages()
Note right of chatStore: Creates new branch, original preserved
chatStore->>chatStore: createAssistantMessage(editedMsg.id)
chatStore->>chatStore: streamChatCompletion(...)
Note right of chatStore: Automatically regenerates response
deactivate chatStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: ❌ ERROR HANDLING
%% ═══════════════════════════════════════════════════════════════════════════
Note over chatStore: On stream error (non-abort):
chatStore->>chatStore: showErrorDialog(type, message)
Note right of chatStore: errorDialogState = {type: 'timeout'|'server', message}
chatStore->>convStore: removeMessageAtIndex(failedMsgIdx)
chatStore->>DbSvc: deleteMessage(failedMsgId)
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 🤖 AGENTIC LOOP (when agenticConfig.enabled)
%% ═══════════════════════════════════════════════════════════════════════════
Note over agenticStore: agenticStore.runAgenticFlow(convId, messages, assistantMsg, options, signal)
activate agenticStore
agenticStore->>agenticStore: getSession(convId) or create new
agenticStore->>agenticStore: updateSession(turn: 0, running: true)
loop executeAgenticLoop (until no tool_calls or maxTurns)
agenticStore->>agenticStore: turn++
agenticStore->>ChatSvc: sendMessage(messages, options, signal)
ChatSvc->>API: POST /v1/chat/completions
API-->>ChatSvc: response with potential tool_calls
ChatSvc-->>agenticStore: onComplete(content, reasoning, timings, toolCalls)
alt response has tool_calls
agenticStore->>agenticStore: normalizeToolCalls(toolCalls)
loop for each tool_call
agenticStore->>agenticStore: updateSession(streamingToolCall)
agenticStore->>mcpStore: executeTool(mcpCall, signal)
mcpStore-->>agenticStore: tool result
agenticStore->>agenticStore: extractBase64Attachments(result)
agenticStore->>agenticStore: emitToolCallResult(convId, ...)
agenticStore->>convStore: addMessageToActive(toolResultMsg)
agenticStore->>DbSvc: createMessageBranch(toolResultMsg)
end
agenticStore->>agenticStore: Create new assistantMsg for next turn
Note right of agenticStore: Continue loop with updated messages
else no tool_calls (final response)
agenticStore->>agenticStore: buildFinalTimings(allTurns)
Note right of agenticStore: Break loop, return final response
end
end
agenticStore->>agenticStore: updateSession(running: false)
agenticStore-->>chatStore: final content, timings, model
deactivate agenticStore
```
+183
View File
@@ -0,0 +1,183 @@
```mermaid
sequenceDiagram
participant UI as 🧩 ChatSidebar / ChatScreen
participant convStore as 🗄️ conversationsStore
participant chatStore as 🗄️ chatStore
participant DbSvc as ⚙️ DatabaseService
participant IDB as 💾 IndexedDB
Note over convStore: State:<br/>conversations: DatabaseConversation[]<br/>activeConversation: DatabaseConversation | null<br/>activeMessages: DatabaseMessage[]<br/>isInitialized: boolean<br/>pendingMcpServerOverrides: Map&lt;string, McpServerOverride&gt;
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: 🚀 INITIALIZATION
%% ═══════════════════════════════════════════════════════════════════════════
Note over convStore: Auto-initialized in constructor (browser only)
convStore->>convStore: initialize()
activate convStore
convStore->>convStore: loadConversations()
convStore->>DbSvc: getAllConversations()
DbSvc->>IDB: SELECT * FROM conversations ORDER BY lastModified DESC
IDB-->>DbSvc: Conversation[]
DbSvc-->>convStore: conversations
convStore->>convStore: conversations = $state(data)
convStore->>convStore: isInitialized = true
deactivate convStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: CREATE CONVERSATION
%% ═══════════════════════════════════════════════════════════════════════════
UI->>convStore: createConversation(name?)
activate convStore
convStore->>DbSvc: createConversation(name || "New Chat")
DbSvc->>IDB: INSERT INTO conversations
IDB-->>DbSvc: conversation {id, name, lastModified, currNode: ""}
DbSvc-->>convStore: conversation
convStore->>convStore: conversations.unshift(conversation)
convStore->>convStore: activeConversation = $state(conversation)
convStore->>convStore: activeMessages = $state([])
alt pendingMcpServerOverrides has entries
loop each pending override
convStore->>DbSvc: Store MCP server override for new conversation
end
convStore->>convStore: clearPendingMcpServerOverrides()
end
deactivate convStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: 📂 LOAD CONVERSATION
%% ═══════════════════════════════════════════════════════════════════════════
UI->>convStore: loadConversation(convId)
activate convStore
convStore->>DbSvc: getConversation(convId)
DbSvc->>IDB: SELECT * FROM conversations WHERE id = ?
IDB-->>DbSvc: conversation
convStore->>convStore: activeConversation = $state(conversation)
convStore->>convStore: refreshActiveMessages()
convStore->>DbSvc: getConversationMessages(convId)
DbSvc->>IDB: SELECT * FROM messages WHERE convId = ?
IDB-->>DbSvc: allMessages[]
convStore->>convStore: filterByLeafNodeId(allMessages, currNode)
Note right of convStore: Filter to show only current branch path
convStore->>convStore: activeMessages = $state(filtered)
Note right of convStore: Route (+page.svelte) then calls:<br/>chatStore.syncLoadingStateForChat(convId)
deactivate convStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: 🌳 MESSAGE BRANCHING MODEL
%% ═══════════════════════════════════════════════════════════════════════════
Note over IDB: Message Tree Structure:<br/>- Each message has parent (null for root)<br/>- Each message has children[] array<br/>- Conversation.currNode points to active leaf<br/>- filterByLeafNodeId() traverses from root to currNode
rect rgb(240, 240, 255)
Note over convStore: Example Branch Structure:
Note over convStore: root → user1 → assistant1 → user2 → assistant2a (currNode)<br/> ↘ assistant2b (alt branch)
end
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: ↔️ BRANCH NAVIGATION
%% ═══════════════════════════════════════════════════════════════════════════
UI->>convStore: navigateToSibling(msgId, direction)
activate convStore
convStore->>convStore: Find message in activeMessages
convStore->>convStore: Get parent message
convStore->>convStore: Find sibling in parent.children[]
convStore->>convStore: findLeafNode(siblingId, allMessages)
Note right of convStore: Navigate to leaf of sibling branch
convStore->>convStore: updateCurrentNode(leafId)
convStore->>DbSvc: updateCurrentNode(convId, leafId)
DbSvc->>IDB: UPDATE conversations SET currNode = ?
convStore->>convStore: refreshActiveMessages()
deactivate convStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: 📝 UPDATE CONVERSATION
%% ═══════════════════════════════════════════════════════════════════════════
UI->>convStore: updateConversationName(convId, newName)
activate convStore
convStore->>DbSvc: updateConversation(convId, {name: newName})
DbSvc->>IDB: UPDATE conversations SET name = ?
convStore->>convStore: Update in conversations array
deactivate convStore
Note over convStore: Auto-title update (after first response):
convStore->>convStore: updateConversationTitleWithConfirmation()
convStore->>convStore: titleUpdateConfirmationCallback?()
Note right of convStore: Shows dialog if title would change
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: 🗑️ DELETE CONVERSATION
%% ═══════════════════════════════════════════════════════════════════════════
UI->>convStore: deleteConversation(convId)
activate convStore
convStore->>DbSvc: deleteConversation(convId)
DbSvc->>IDB: DELETE FROM conversations WHERE id = ?
DbSvc->>IDB: DELETE FROM messages WHERE convId = ?
convStore->>convStore: conversations.filter(c => c.id !== convId)
alt deleted active conversation
convStore->>convStore: clearActiveConversation()
end
deactivate convStore
UI->>convStore: deleteAll()
activate convStore
convStore->>DbSvc: Delete all conversations and messages
convStore->>convStore: conversations = []
convStore->>convStore: clearActiveConversation()
deactivate convStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: MCP SERVER PER-CHAT OVERRIDES
%% ═══════════════════════════════════════════════════════════════════════════
Note over convStore: Conversations can override which MCP servers are enabled.
Note over convStore: Uses pendingMcpServerOverrides before conversation<br/>is created, then persists to conversation metadata.
UI->>convStore: setMcpServerOverride(convId, serverName, override)
Note right of convStore: override = {enabled: boolean}
UI->>convStore: toggleMcpServerForChat(convId, serverName, enabled)
activate convStore
convStore->>convStore: setMcpServerOverride(convId, serverName, {enabled})
deactivate convStore
UI->>convStore: isMcpServerEnabledForChat(convId, serverName)
Note right of convStore: Check override → fall back to global MCP config
UI->>convStore: getAllMcpServerOverrides(convId)
Note right of convStore: Returns all overrides for a conversation
UI->>convStore: removeMcpServerOverride(convId, serverName)
UI->>convStore: getMcpServerOverride(convId, serverName)
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: 📤 EXPORT / 📥 IMPORT
%% ═══════════════════════════════════════════════════════════════════════════
UI->>convStore: exportAllConversations()
activate convStore
convStore->>DbSvc: getAllConversations()
loop each conversation
convStore->>DbSvc: getConversationMessages(convId)
end
convStore->>convStore: triggerDownload(JSON blob)
deactivate convStore
UI->>convStore: importConversations(file)
activate convStore
convStore->>convStore: Parse JSON file
convStore->>convStore: importConversationsData(parsed)
convStore->>DbSvc: importConversations(parsed)
Note right of DbSvc: Skips duplicate conversations<br/>(checks existing by ID)
DbSvc->>IDB: INSERT conversations + messages (skip existing)
convStore->>convStore: loadConversations()
deactivate convStore
```
@@ -0,0 +1,45 @@
```mermaid
%% MODEL Mode Data Flow (single model)
%% Detailed flows: ./flows/server-flow.mmd, ./flows/models-flow.mmd, ./flows/chat-flow.mmd
sequenceDiagram
participant User as 👤 User
participant UI as 🧩 UI
participant Stores as 🗄️ Stores
participant DB as 💾 IndexedDB
participant API as 🌐 llama-server
Note over User,API: 🚀 Initialization (see: server-flow.mmd, models-flow.mmd)
UI->>Stores: initialize()
Stores->>DB: load conversations
Stores->>API: GET /props
API-->>Stores: server config + modalities
Stores->>API: GET /v1/models
API-->>Stores: single model (auto-selected)
Note over User,API: 💬 Chat Flow (see: chat-flow.mmd)
User->>UI: send message
UI->>Stores: sendMessage()
Stores->>DB: save user message
Stores->>API: POST /v1/chat/completions (stream)
loop streaming
API-->>Stores: SSE chunks
Stores-->>UI: reactive update
end
API-->>Stores: done + timings
Stores->>DB: save assistant message
Note over User,API: 🔁 Regenerate
User->>UI: regenerate
Stores->>DB: create message branch
Note right of Stores: same streaming flow
Note over User,API: ⏹️ Stop
User->>UI: stop
Stores->>Stores: abort stream
Stores->>DB: save partial response
```
@@ -0,0 +1,77 @@
```mermaid
%% ROUTER Mode Data Flow (multi-model)
%% Detailed flows: ./flows/server-flow.mmd, ./flows/models-flow.mmd, ./flows/chat-flow.mmd
sequenceDiagram
participant User as 👤 User
participant UI as 🧩 UI
participant Stores as 🗄️ Stores
participant DB as 💾 IndexedDB
participant API as 🌐 llama-server
Note over User,API: 🚀 Initialization (see: server-flow.mmd, models-flow.mmd)
UI->>Stores: initialize()
Stores->>DB: load conversations
Stores->>API: GET /props
API-->>Stores: {role: "router"}
Stores->>API: GET /v1/models
API-->>Stores: models[] with status (loaded/available)
loop each loaded model
Stores->>API: GET /props?model=X
API-->>Stores: modalities (vision/audio)
end
Note over User,API: 🔄 Model Selection (see: models-flow.mmd)
User->>UI: select model
alt model not loaded
Stores->>API: POST /models/load
loop poll status
Stores->>API: GET /v1/models
API-->>Stores: check if loaded
end
Stores->>API: GET /props?model=X
API-->>Stores: cache modalities
end
Stores->>Stores: validate modalities vs conversation
alt valid
Stores->>Stores: select model
else invalid
Stores->>API: POST /models/unload
UI->>User: show error toast
end
Note over User,API: 💬 Chat Flow (see: chat-flow.mmd)
User->>UI: send message
UI->>Stores: sendMessage()
Stores->>DB: save user message
Stores->>API: POST /v1/chat/completions {model: X}
Note right of API: router forwards to model
loop streaming
API-->>Stores: SSE chunks + model info
Stores-->>UI: reactive update
end
API-->>Stores: done + timings
Stores->>DB: save assistant message + model used
Note over User,API: 🔁 Regenerate (optional: different model)
User->>UI: regenerate
Stores->>Stores: validate modalities up to this message
Stores->>DB: create message branch
Note right of Stores: same streaming flow
Note over User,API: ⏹️ Stop
User->>UI: stop
Stores->>Stores: abort stream
Stores->>DB: save partial response
Note over User,API: 🗑️ LRU Unloading
Note right of API: Server auto-unloads LRU models<br/>when cache full
User->>UI: select unloaded model
Note right of Stores: triggers load flow again
```
+174
View File
@@ -0,0 +1,174 @@
```mermaid
sequenceDiagram
participant Store as 🗄️ Stores
participant DbSvc as ⚙️ DatabaseService
participant Dexie as 📦 Dexie ORM
participant IDB as 💾 IndexedDB
Note over DbSvc: Stateless service - all methods static<br/>Database: "LlamacppWebui"
%% ═══════════════════════════════════════════════════════════════════════════
Note over Store,IDB: 📊 SCHEMA
%% ═══════════════════════════════════════════════════════════════════════════
rect rgb(240, 248, 255)
Note over IDB: conversations table:<br/>id (PK), lastModified, currNode, name
end
rect rgb(255, 248, 240)
Note over IDB: messages table:<br/>id (PK), convId (FK), type, role, timestamp,<br/>parent, children[], content, thinking,<br/>toolCalls, extra[], model, timings
end
%% ═══════════════════════════════════════════════════════════════════════════
Note over Store,IDB: 💬 CONVERSATIONS CRUD
%% ═══════════════════════════════════════════════════════════════════════════
Store->>DbSvc: createConversation(name)
activate DbSvc
DbSvc->>DbSvc: Generate UUID
DbSvc->>Dexie: db.conversations.add({id, name, lastModified, currNode: ""})
Dexie->>IDB: INSERT
IDB-->>Dexie: success
DbSvc-->>Store: DatabaseConversation
deactivate DbSvc
Store->>DbSvc: getConversation(convId)
DbSvc->>Dexie: db.conversations.get(convId)
Dexie->>IDB: SELECT WHERE id = ?
IDB-->>DbSvc: DatabaseConversation
Store->>DbSvc: getAllConversations()
DbSvc->>Dexie: db.conversations.orderBy('lastModified').reverse().toArray()
Dexie->>IDB: SELECT ORDER BY lastModified DESC
IDB-->>DbSvc: DatabaseConversation[]
Store->>DbSvc: updateConversation(convId, updates)
DbSvc->>Dexie: db.conversations.update(convId, {...updates, lastModified})
Dexie->>IDB: UPDATE
Store->>DbSvc: deleteConversation(convId)
activate DbSvc
DbSvc->>Dexie: db.conversations.delete(convId)
Dexie->>IDB: DELETE FROM conversations
DbSvc->>Dexie: db.messages.where('convId').equals(convId).delete()
Dexie->>IDB: DELETE FROM messages WHERE convId = ?
deactivate DbSvc
%% ═══════════════════════════════════════════════════════════════════════════
Note over Store,IDB: 📝 MESSAGES CRUD
%% ═══════════════════════════════════════════════════════════════════════════
Store->>DbSvc: createRootMessage(convId)
activate DbSvc
DbSvc->>DbSvc: Create root message {type: "root", parent: null}
DbSvc->>Dexie: db.messages.add(rootMsg)
Dexie->>IDB: INSERT
DbSvc-->>Store: rootMessageId
deactivate DbSvc
Store->>DbSvc: createSystemMessage(convId, content, parentId)
activate DbSvc
DbSvc->>DbSvc: Create message {role: "system", parent: parentId}
DbSvc->>Dexie: db.messages.add(systemMsg)
Dexie->>IDB: INSERT
DbSvc-->>Store: DatabaseMessage
deactivate DbSvc
Store->>DbSvc: createMessageBranch(message, parentId)
activate DbSvc
DbSvc->>DbSvc: Generate UUID for new message
DbSvc->>Dexie: db.messages.add({...message, id, parent: parentId})
Dexie->>IDB: INSERT message
alt parentId exists
DbSvc->>Dexie: db.messages.get(parentId)
Dexie->>IDB: SELECT parent
DbSvc->>DbSvc: parent.children.push(newId)
DbSvc->>Dexie: db.messages.update(parentId, {children})
Dexie->>IDB: UPDATE parent.children
end
DbSvc->>Dexie: db.conversations.update(convId, {currNode: newId})
Dexie->>IDB: UPDATE conversation.currNode
DbSvc-->>Store: DatabaseMessage
deactivate DbSvc
Store->>DbSvc: getConversationMessages(convId)
DbSvc->>Dexie: db.messages.where('convId').equals(convId).toArray()
Dexie->>IDB: SELECT WHERE convId = ?
IDB-->>DbSvc: DatabaseMessage[]
Store->>DbSvc: updateMessage(msgId, updates)
DbSvc->>Dexie: db.messages.update(msgId, updates)
Dexie->>IDB: UPDATE
Store->>DbSvc: deleteMessage(msgId)
DbSvc->>Dexie: db.messages.delete(msgId)
Dexie->>IDB: DELETE
%% ═══════════════════════════════════════════════════════════════════════════
Note over Store,IDB: 🌳 BRANCHING OPERATIONS
%% ═══════════════════════════════════════════════════════════════════════════
Store->>DbSvc: updateCurrentNode(convId, nodeId)
DbSvc->>Dexie: db.conversations.update(convId, {currNode: nodeId, lastModified})
Dexie->>IDB: UPDATE
Store->>DbSvc: deleteMessageCascading(msgId)
activate DbSvc
DbSvc->>DbSvc: findDescendantMessages(msgId, allMessages)
Note right of DbSvc: Recursively find all children
loop each descendant
DbSvc->>Dexie: db.messages.delete(descendantId)
Dexie->>IDB: DELETE
end
DbSvc->>Dexie: db.messages.delete(msgId)
Dexie->>IDB: DELETE target message
alt target message has a parent
DbSvc->>Dexie: db.messages.get(parentId)
DbSvc->>DbSvc: parent.children.filter(id !== msgId)
DbSvc->>Dexie: db.messages.update(parentId, {children})
Note right of DbSvc: Remove deleted message from parent's children[]
end
deactivate DbSvc
%% ═══════════════════════════════════════════════════════════════════════════
Note over Store,IDB: 📥 IMPORT
%% ═══════════════════════════════════════════════════════════════════════════
Store->>DbSvc: importConversations(data)
activate DbSvc
loop each conversation in data
DbSvc->>Dexie: db.conversations.get(conv.id)
alt conversation already exists
Note right of DbSvc: Skip duplicate (keep existing)
else conversation is new
DbSvc->>Dexie: db.conversations.add(conversation)
Dexie->>IDB: INSERT conversation
loop each message
DbSvc->>Dexie: db.messages.add(message)
Dexie->>IDB: INSERT message
end
end
end
deactivate DbSvc
%% ═══════════════════════════════════════════════════════════════════════════
Note over Store,IDB: 🔗 MESSAGE TREE UTILITIES
%% ═══════════════════════════════════════════════════════════════════════════
Note over DbSvc: Used by stores (imported from utils):
rect rgb(240, 255, 240)
Note over DbSvc: filterByLeafNodeId(messages, leafId)<br/>→ Returns path from root to leaf<br/>→ Used to display current branch
end
rect rgb(240, 255, 240)
Note over DbSvc: findLeafNode(startId, messages)<br/>→ Traverse to deepest child<br/>→ Used for branch navigation
end
rect rgb(240, 255, 240)
Note over DbSvc: findDescendantMessages(msgId, messages)<br/>→ Find all children recursively<br/>→ Used for cascading deletes
end
```
+226
View File
@@ -0,0 +1,226 @@
```mermaid
sequenceDiagram
participant UI as 🧩 McpServersSettings / ChatForm
participant chatStore as 🗄️ chatStore
participant mcpStore as 🗄️ mcpStore
participant mcpResStore as 🗄️ mcpResourceStore
participant convStore as 🗄️ conversationsStore
participant MCPSvc as ⚙️ MCPService
participant LS as 💾 LocalStorage
participant ExtMCP as 🔌 External MCP Server
Note over mcpStore: State:<br/>isInitializing, error<br/>toolCount, connectedServers<br/>healthChecks (Map)<br/>connections (Map)<br/>toolsIndex (Map)<br/>serverConfigs (Map)
Note over mcpResStore: State:<br/>serverResources (Map)<br/>cachedResources (Map)<br/>subscriptions (Map)<br/>attachments[]
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,ExtMCP: 🚀 INITIALIZATION (App Startup)
%% ═══════════════════════════════════════════════════════════════════════════
UI->>mcpStore: ensureInitialized()
activate mcpStore
mcpStore->>LS: get(MCP_SERVERS_LOCALSTORAGE_KEY)
LS-->>mcpStore: MCPServerSettingsEntry[]
mcpStore->>mcpStore: parseServerSettings(servers)
Note right of mcpStore: Filter enabled servers<br/>Build MCPServerConfig objects<br/>Per-chat overrides checked via convStore
loop For each enabled server
mcpStore->>mcpStore: runHealthCheck(serverId)
mcpStore->>mcpStore: updateHealthCheck(id, CONNECTING)
mcpStore->>MCPSvc: connect(serverName, config, clientInfo, capabilities, onPhase)
activate MCPSvc
MCPSvc->>MCPSvc: createTransport(config)
Note right of MCPSvc: WebSocket / StreamableHTTP / SSE<br/>with optional CORS proxy
MCPSvc->>ExtMCP: Transport handshake
ExtMCP-->>MCPSvc: Connection established
MCPSvc->>ExtMCP: Initialize request
Note right of ExtMCP: Exchange capabilities<br/>Server info, protocol version
ExtMCP-->>MCPSvc: InitializeResult (serverInfo, capabilities)
MCPSvc->>ExtMCP: listTools()
ExtMCP-->>MCPSvc: Tool[]
MCPSvc-->>mcpStore: MCPConnection
deactivate MCPSvc
mcpStore->>mcpStore: connections.set(serverName, connection)
mcpStore->>mcpStore: indexTools(connection.tools, serverName)
Note right of mcpStore: toolsIndex.set(toolName, serverName)<br/>Handle name conflicts with prefixes
mcpStore->>mcpStore: updateHealthCheck(id, SUCCESS)
mcpStore->>mcpStore: _connectedServers.push(serverName)
alt Server supports resources
mcpStore->>MCPSvc: listAllResources(connection)
MCPSvc->>ExtMCP: listResources()
ExtMCP-->>MCPSvc: MCPResource[]
MCPSvc-->>mcpStore: resources
mcpStore->>MCPSvc: listAllResourceTemplates(connection)
MCPSvc->>ExtMCP: listResourceTemplates()
ExtMCP-->>MCPSvc: MCPResourceTemplate[]
MCPSvc-->>mcpStore: templates
mcpStore->>mcpResStore: setServerResources(serverName, resources, templates)
end
end
mcpStore->>mcpStore: _isInitializing = false
deactivate mcpStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,ExtMCP: 🔧 TOOL EXECUTION (Chat with Tools)
%% ═══════════════════════════════════════════════════════════════════════════
UI->>mcpStore: executeTool(mcpCall: MCPToolCall, signal?)
activate mcpStore
mcpStore->>mcpStore: toolsIndex.get(mcpCall.function.name)
Note right of mcpStore: Resolve serverName from toolsIndex<br/>MCPToolCall = {id, type, function: {name, arguments}}
mcpStore->>mcpStore: acquireConnection()
Note right of mcpStore: activeFlowCount++<br/>Prevent shutdown during execution
mcpStore->>mcpStore: connection = connections.get(serverName)
mcpStore->>MCPSvc: callTool(connection, {name, arguments}, signal)
activate MCPSvc
MCPSvc->>MCPSvc: throwIfAborted(signal)
MCPSvc->>ExtMCP: callTool(name, arguments)
alt Tool execution success
ExtMCP-->>MCPSvc: ToolCallResult (content, isError)
MCPSvc->>MCPSvc: formatToolResult(result)
Note right of MCPSvc: Handle text, image (base64),<br/>embedded resource content
MCPSvc-->>mcpStore: ToolExecutionResult
else Tool execution error
ExtMCP-->>MCPSvc: Error
MCPSvc-->>mcpStore: throw Error
else Aborted
MCPSvc-->>mcpStore: throw AbortError
end
deactivate MCPSvc
mcpStore->>mcpStore: releaseConnection()
Note right of mcpStore: activeFlowCount--
mcpStore-->>UI: ToolExecutionResult
deactivate mcpStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,ExtMCP: RESOURCE ATTACHMENT CONSUMPTION
%% ═══════════════════════════════════════════════════════════════════════════
chatStore->>mcpStore: consumeResourceAttachmentsAsExtras()
activate mcpStore
mcpStore->>mcpResStore: getAttachments()
mcpResStore-->>mcpStore: MCPResourceAttachment[]
mcpStore->>mcpStore: Convert attachments to message extras
mcpStore->>mcpResStore: clearAttachments()
mcpStore-->>chatStore: MessageExtra[] (for user message)
deactivate mcpStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,ExtMCP: 📝 PROMPT OPERATIONS
%% ═══════════════════════════════════════════════════════════════════════════
UI->>mcpStore: getAllPrompts()
activate mcpStore
loop For each connected server with prompts capability
mcpStore->>MCPSvc: listPrompts(connection)
MCPSvc->>ExtMCP: listPrompts()
ExtMCP-->>MCPSvc: Prompt[]
MCPSvc-->>mcpStore: prompts
end
mcpStore-->>UI: MCPPromptInfo[] (with serverName)
deactivate mcpStore
UI->>mcpStore: getPrompt(serverName, promptName, args?)
activate mcpStore
mcpStore->>MCPSvc: getPrompt(connection, name, args)
MCPSvc->>ExtMCP: getPrompt({name, arguments})
ExtMCP-->>MCPSvc: GetPromptResult (messages)
MCPSvc-->>mcpStore: GetPromptResult
mcpStore-->>UI: GetPromptResult
deactivate mcpStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,ExtMCP: 📁 RESOURCE OPERATIONS
%% ═══════════════════════════════════════════════════════════════════════════
UI->>mcpResStore: addAttachment(resourceInfo)
activate mcpResStore
mcpResStore->>mcpResStore: Create MCPResourceAttachment (loading: true)
mcpResStore-->>UI: attachment
UI->>mcpStore: readResource(serverName, uri)
activate mcpStore
mcpStore->>MCPSvc: readResource(connection, uri)
MCPSvc->>ExtMCP: readResource({uri})
ExtMCP-->>MCPSvc: MCPReadResourceResult (contents)
MCPSvc-->>mcpStore: contents
mcpStore-->>UI: MCPResourceContent[]
deactivate mcpStore
UI->>mcpResStore: updateAttachmentContent(attachmentId, content)
mcpResStore->>mcpResStore: cacheResourceContent(resource, content)
deactivate mcpResStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,ExtMCP: 🔄 AUTO-RECONNECTION
%% ═══════════════════════════════════════════════════════════════════════════
Note over mcpStore: On WebSocket close or connection error:
mcpStore->>mcpStore: autoReconnect(serverName, attempt)
activate mcpStore
mcpStore->>mcpStore: Calculate backoff delay
Note right of mcpStore: delay = min(30s, 1s * 2^attempt)
mcpStore->>mcpStore: Wait for delay
mcpStore->>mcpStore: reconnectServer(serverName)
alt Reconnection success
mcpStore->>mcpStore: updateHealthCheck(id, SUCCESS)
else Max attempts reached
mcpStore->>mcpStore: updateHealthCheck(id, ERROR)
end
deactivate mcpStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,ExtMCP: 🛑 SHUTDOWN
%% ═══════════════════════════════════════════════════════════════════════════
UI->>mcpStore: shutdown()
activate mcpStore
mcpStore->>mcpStore: Wait for activeFlowCount == 0
loop For each connection
mcpStore->>MCPSvc: disconnect(connection)
MCPSvc->>MCPSvc: transport.onclose = undefined
MCPSvc->>ExtMCP: close()
end
mcpStore->>mcpStore: connections.clear()
mcpStore->>mcpStore: toolsIndex.clear()
mcpStore->>mcpStore: _connectedServers = []
mcpStore->>mcpResStore: clear()
deactivate mcpStore
```
+181
View File
@@ -0,0 +1,181 @@
```mermaid
sequenceDiagram
participant UI as 🧩 ModelsSelector
participant Hooks as 🪝 useModelChangeValidation
participant modelsStore as 🗄️ modelsStore
participant serverStore as 🗄️ serverStore
participant convStore as 🗄️ conversationsStore
participant ModelsSvc as ⚙️ ModelsService
participant PropsSvc as ⚙️ PropsService
participant API as 🌐 llama-server
Note over modelsStore: State:<br/>models: ModelOption[]<br/>routerModels: ApiModelDataEntry[]<br/>selectedModelId, selectedModelName<br/>loading, updating, error<br/>modelLoadingStates (Map)<br/>modelPropsCache (Map)<br/>propsCacheVersion
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 🚀 INITIALIZATION (MODEL mode)
%% ═══════════════════════════════════════════════════════════════════════════
UI->>modelsStore: fetch()
activate modelsStore
modelsStore->>modelsStore: loading = true
alt serverStore.props not loaded
modelsStore->>serverStore: fetch()
Note over serverStore: → see server-flow.mmd
end
modelsStore->>ModelsSvc: list()
ModelsSvc->>API: GET /v1/models
API-->>ModelsSvc: ApiModelListResponse {data: [model]}
modelsStore->>modelsStore: models = $state(mapped)
Note right of modelsStore: Map to ModelOption[]:<br/>{id, name, model, description, capabilities}
Note over modelsStore: MODEL mode: Get modalities from serverStore.props
modelsStore->>modelsStore: modelPropsCache.set(model.id, serverStore.props)
modelsStore->>modelsStore: models[0].modalities = props.modalities
modelsStore->>modelsStore: Auto-select single model
Note right of modelsStore: selectedModelId = models[0].id
modelsStore->>modelsStore: loading = false
deactivate modelsStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 🚀 INITIALIZATION (ROUTER mode)
%% ═══════════════════════════════════════════════════════════════════════════
UI->>modelsStore: fetch()
activate modelsStore
modelsStore->>ModelsSvc: list()
ModelsSvc->>API: GET /v1/models
API-->>ModelsSvc: ApiModelListResponse
modelsStore->>modelsStore: models = $state(mapped)
deactivate modelsStore
Note over UI: After models loaded, layout triggers:
UI->>modelsStore: fetchRouterModels()
activate modelsStore
modelsStore->>ModelsSvc: listRouter()
ModelsSvc->>API: GET /v1/models
API-->>ModelsSvc: ApiRouterModelsListResponse
Note right of API: {data: [{id, status, path, in_cache}]}
modelsStore->>modelsStore: routerModels = $state(data)
modelsStore->>modelsStore: fetchModalitiesForLoadedModels()
loop each model where status === "loaded"
modelsStore->>PropsSvc: fetchForModel(modelId)
PropsSvc->>API: GET /props?model={modelId}
API-->>PropsSvc: ApiLlamaCppServerProps
modelsStore->>modelsStore: modelPropsCache.set(modelId, props)
end
modelsStore->>modelsStore: propsCacheVersion++
deactivate modelsStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 🔄 MODEL SELECTION (ROUTER mode)
%% ═══════════════════════════════════════════════════════════════════════════
UI->>Hooks: useModelChangeValidation({getRequiredModalities, onSuccess?, onValidationFailure?})
Note over Hooks: Hook configured per-component:<br/>ChatForm: getRequiredModalities = usedModalities<br/>ChatMessage: getRequiredModalities = getModalitiesUpToMessage(msgId)
UI->>Hooks: handleModelChange(modelId, modelName)
activate Hooks
Hooks->>Hooks: previousSelectedModelId = modelsStore.selectedModelId
Hooks->>modelsStore: isModelLoaded(modelName)?
alt model NOT loaded
Hooks->>modelsStore: loadModel(modelName)
Note over modelsStore: → see LOAD MODEL section below
end
Note over Hooks: Always fetch props (from cache or API)
Hooks->>modelsStore: fetchModelProps(modelName)
modelsStore-->>Hooks: props
Hooks->>convStore: getRequiredModalities()
convStore-->>Hooks: {vision, audio}
Hooks->>Hooks: Validate: model.modalities ⊇ required?
alt validation PASSED
Hooks->>modelsStore: selectModelById(modelId)
Hooks-->>UI: return true
else validation FAILED
Hooks->>UI: toast.error("Model doesn't support required modalities")
alt model was just loaded
Hooks->>modelsStore: unloadModel(modelName)
end
alt onValidationFailure provided
Hooks->>modelsStore: selectModelById(previousSelectedModelId)
end
Hooks-->>UI: return false
end
deactivate Hooks
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: ⬆️ LOAD MODEL (ROUTER mode)
%% ═══════════════════════════════════════════════════════════════════════════
modelsStore->>modelsStore: loadModel(modelId)
activate modelsStore
alt already loaded
modelsStore-->>modelsStore: return (no-op)
end
modelsStore->>modelsStore: modelLoadingStates.set(modelId, true)
modelsStore->>ModelsSvc: load(modelId)
ModelsSvc->>API: POST /models/load {model: modelId}
API-->>ModelsSvc: {status: "loading"}
modelsStore->>modelsStore: pollForModelStatus(modelId, LOADED)
loop poll every 500ms (max 60 attempts)
modelsStore->>modelsStore: fetchRouterModels()
modelsStore->>ModelsSvc: listRouter()
ModelsSvc->>API: GET /v1/models
API-->>ModelsSvc: models[]
modelsStore->>modelsStore: getModelStatus(modelId)
alt status === LOADED
Note right of modelsStore: break loop
else status === LOADING
Note right of modelsStore: wait 500ms, continue
end
end
modelsStore->>modelsStore: updateModelModalities(modelId)
modelsStore->>PropsSvc: fetchForModel(modelId)
PropsSvc->>API: GET /props?model={modelId}
API-->>PropsSvc: props with modalities
modelsStore->>modelsStore: modelPropsCache.set(modelId, props)
modelsStore->>modelsStore: propsCacheVersion++
modelsStore->>modelsStore: modelLoadingStates.set(modelId, false)
deactivate modelsStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: ⬇️ UNLOAD MODEL (ROUTER mode)
%% ═══════════════════════════════════════════════════════════════════════════
modelsStore->>modelsStore: unloadModel(modelId)
activate modelsStore
modelsStore->>modelsStore: modelLoadingStates.set(modelId, true)
modelsStore->>ModelsSvc: unload(modelId)
ModelsSvc->>API: POST /models/unload {model: modelId}
modelsStore->>modelsStore: pollForModelStatus(modelId, UNLOADED)
loop poll until unloaded
modelsStore->>ModelsSvc: listRouter()
ModelsSvc->>API: GET /v1/models
end
modelsStore->>modelsStore: modelLoadingStates.set(modelId, false)
deactivate modelsStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 📊 COMPUTED GETTERS
%% ═══════════════════════════════════════════════════════════════════════════
Note over modelsStore: Getters:<br/>- selectedModel: ModelOption | null<br/>- loadedModelIds: string[] (from routerModels)<br/>- loadingModelIds: string[] (from modelLoadingStates)<br/>- singleModelName: string | null (MODEL mode only)
Note over modelsStore: Modality helpers:<br/>- getModelModalities(modelId): {vision, audio}<br/>- modelSupportsVision(modelId): boolean<br/>- modelSupportsAudio(modelId): boolean
```
+76
View File
@@ -0,0 +1,76 @@
```mermaid
sequenceDiagram
participant UI as 🧩 +layout.svelte
participant serverStore as 🗄️ serverStore
participant PropsSvc as ⚙️ PropsService
participant API as 🌐 llama-server
Note over serverStore: State:<br/>props: ApiLlamaCppServerProps | null<br/>loading, error<br/>role: ServerRole | null (MODEL | ROUTER)<br/>fetchPromise (deduplication)
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 🚀 INITIALIZATION
%% ═══════════════════════════════════════════════════════════════════════════
UI->>serverStore: fetch()
activate serverStore
alt fetchPromise exists (already fetching)
serverStore-->>UI: return fetchPromise
Note right of serverStore: Deduplicate concurrent calls
end
serverStore->>serverStore: loading = true
serverStore->>serverStore: fetchPromise = new Promise()
serverStore->>PropsSvc: fetch()
PropsSvc->>API: GET /props
API-->>PropsSvc: ApiLlamaCppServerProps
Note right of API: {role, model_path, model_alias,<br/>modalities, default_generation_settings, ...}
PropsSvc-->>serverStore: props
serverStore->>serverStore: props = $state(data)
serverStore->>serverStore: detectRole(props)
Note right of serverStore: role = props.role === "router"<br/> ? ServerRole.ROUTER<br/> : ServerRole.MODEL
serverStore->>serverStore: loading = false
serverStore->>serverStore: fetchPromise = null
deactivate serverStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 📊 COMPUTED GETTERS
%% ═══════════════════════════════════════════════════════════════════════════
Note over serverStore: Getters from props:
rect rgb(240, 255, 240)
Note over serverStore: defaultParams<br/>→ props.default_generation_settings.params<br/>(temperature, top_p, top_k, etc.)
end
rect rgb(240, 255, 240)
Note over serverStore: contextSize<br/>→ props.default_generation_settings.n_ctx
end
rect rgb(255, 240, 240)
Note over serverStore: isRouterMode<br/>→ role === ServerRole.ROUTER
end
rect rgb(255, 240, 240)
Note over serverStore: isModelMode<br/>→ role === ServerRole.MODEL
end
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 🔗 RELATIONSHIPS
%% ═══════════════════════════════════════════════════════════════════════════
Note over serverStore: Used by:
Note right of serverStore: - modelsStore: role detection, MODEL mode modalities<br/>- settingsStore: syncWithServerDefaults (defaultParams)<br/>- chatStore: contextSize for processing state<br/>- UI components: isRouterMode for conditional rendering
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: ❌ ERROR HANDLING
%% ═══════════════════════════════════════════════════════════════════════════
Note over serverStore: getErrorMessage(): string | null<br/>Returns formatted error for UI display
Note over serverStore: clear(): void<br/>Resets all state (props, error, loading, role)
```
+156
View File
@@ -0,0 +1,156 @@
```mermaid
sequenceDiagram
participant UI as 🧩 ChatSettings
participant settingsStore as 🗄️ settingsStore
participant serverStore as 🗄️ serverStore
participant ParamSvc as ⚙️ ParameterSyncService
participant LS as 💾 LocalStorage
Note over settingsStore: State:<br/>config: SettingsConfigType<br/>theme: string ("auto" | "light" | "dark")<br/>isInitialized: boolean<br/>userOverrides: Set&lt;string&gt;
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,LS: 🚀 INITIALIZATION
%% ═══════════════════════════════════════════════════════════════════════════
Note over settingsStore: Auto-initialized in constructor (browser only)
settingsStore->>settingsStore: initialize()
activate settingsStore
settingsStore->>settingsStore: loadConfig()
settingsStore->>LS: get("llama-config")
LS-->>settingsStore: StoredConfig | null
alt config exists
settingsStore->>settingsStore: Merge with SETTING_CONFIG_DEFAULT
Note right of settingsStore: Fill missing keys with defaults
else no config
settingsStore->>settingsStore: config = SETTING_CONFIG_DEFAULT
end
settingsStore->>LS: get("llama-userOverrides")
LS-->>settingsStore: string[] | null
settingsStore->>settingsStore: userOverrides = new Set(data)
settingsStore->>settingsStore: loadTheme()
settingsStore->>LS: get("llama-theme")
LS-->>settingsStore: theme | "auto"
settingsStore->>settingsStore: isInitialized = true
deactivate settingsStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,LS: 🔄 SYNC WITH SERVER DEFAULTS
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI: Triggered from +layout.svelte when serverStore.props loaded
UI->>settingsStore: syncWithServerDefaults()
activate settingsStore
settingsStore->>serverStore: defaultParams
serverStore-->>settingsStore: {temperature, top_p, top_k, ...}
loop each SYNCABLE_PARAMETER
alt key NOT in userOverrides
settingsStore->>settingsStore: config[key] = serverDefault[key]
Note right of settingsStore: Non-overridden params adopt server default
else key in userOverrides
Note right of settingsStore: Keep user value, skip server default
end
end
alt serverStore.props has uiSettings
settingsStore->>settingsStore: Apply uiSettings from server
Note right of settingsStore: Server-provided UI settings<br/>(e.g. showRawOutputSwitch)
end
settingsStore->>settingsStore: saveConfig()
deactivate settingsStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,LS: ⚙️ UPDATE CONFIG
%% ═══════════════════════════════════════════════════════════════════════════
UI->>settingsStore: updateConfig(key, value)
activate settingsStore
settingsStore->>settingsStore: config[key] = value
alt value matches server default for key
settingsStore->>settingsStore: userOverrides.delete(key)
Note right of settingsStore: Matches server default, remove override
else value differs from server default
settingsStore->>settingsStore: userOverrides.add(key)
Note right of settingsStore: Mark as user-modified (won't be overwritten)
end
settingsStore->>settingsStore: saveConfig()
settingsStore->>LS: set(CONFIG_LOCALSTORAGE_KEY, config)
settingsStore->>LS: set(USER_OVERRIDES_LOCALSTORAGE_KEY, [...userOverrides])
deactivate settingsStore
UI->>settingsStore: updateMultipleConfig({key1: val1, key2: val2})
activate settingsStore
Note right of settingsStore: Batch update, single save
settingsStore->>settingsStore: For each key: config[key] = value
settingsStore->>settingsStore: For each key: userOverrides.add(key)
settingsStore->>settingsStore: saveConfig()
deactivate settingsStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,LS: 🔄 RESET
%% ═══════════════════════════════════════════════════════════════════════════
UI->>settingsStore: resetConfig()
activate settingsStore
settingsStore->>settingsStore: config = {...SETTING_CONFIG_DEFAULT}
settingsStore->>settingsStore: userOverrides.clear()
Note right of settingsStore: All params reset to defaults<br/>Next syncWithServerDefaults will adopt server values
settingsStore->>settingsStore: saveConfig()
deactivate settingsStore
UI->>settingsStore: resetParameterToServerDefault(key)
activate settingsStore
settingsStore->>settingsStore: userOverrides.delete(key)
settingsStore->>serverStore: defaultParams[key]
settingsStore->>settingsStore: config[key] = serverDefault
settingsStore->>settingsStore: saveConfig()
deactivate settingsStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,LS: 🎨 THEME
%% ═══════════════════════════════════════════════════════════════════════════
UI->>settingsStore: updateTheme(newTheme)
activate settingsStore
settingsStore->>settingsStore: theme = newTheme
settingsStore->>settingsStore: saveTheme()
settingsStore->>LS: set("llama-theme", theme)
deactivate settingsStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,LS: 📊 PARAMETER INFO
%% ═══════════════════════════════════════════════════════════════════════════
UI->>settingsStore: getParameterInfo(key)
settingsStore->>ParamSvc: getParameterInfo(key, config, serverDefaults, userOverrides)
ParamSvc-->>settingsStore: ParameterInfo
Note right of ParamSvc: {<br/> currentValue,<br/> serverDefault,<br/> isUserOverride: boolean,<br/> canSync: boolean,<br/> isDifferentFromServer: boolean<br/>}
UI->>settingsStore: getParameterDiff()
settingsStore->>ParamSvc: createParameterDiff(config, serverDefaults, userOverrides)
ParamSvc-->>settingsStore: ParameterDiff[]
Note right of ParamSvc: Array of parameters where user != server
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,LS: 📋 CONFIG CATEGORIES
%% ═══════════════════════════════════════════════════════════════════════════
Note over settingsStore: Syncable with server (from /props):
rect rgb(240, 255, 240)
Note over settingsStore: temperature, top_p, top_k, min_p<br/>repeat_penalty, presence_penalty, frequency_penalty<br/>dynatemp_range, dynatemp_exponent<br/>typ_p, xtc_probability, xtc_threshold<br/>dry_multiplier, dry_base, dry_allowed_length, dry_penalty_last_n
end
Note over settingsStore: UI-only (not synced):
rect rgb(255, 240, 240)
Note over settingsStore: systemMessage, custom (JSON)<br/>showStatistics, enableContinueGeneration<br/>autoMicOnEmpty, disableAutoScroll<br/>apiKey, pdfAsImage, disableReasoningParsing, showRawOutputSwitch
end
```
+306
View File
@@ -0,0 +1,306 @@
// llama-ui-embed: generate ui.cpp / ui.h that embed UI assets as C arrays.
//
// Usage:
// llama-ui-embed <out_cpp> <out_h> [<asset_dir>]
//
// Recursively embeds every regular file under <asset_dir>.
// Asset names are relative paths from <asset_dir> (e.g. "_app/immutable/bundle.HASH.js").
// Without <asset_dir>, emits an empty asset table.
#include <inttypes.h>
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <functional>
#include <string>
#include <vector>
static const char * mime_from_ext(const std::string & name) {
auto ext = name.rfind('.');
if (ext == std::string::npos) return "application/octet-stream";
std::string e = name.substr(ext + 1);
if (e == "html") return "text/html; charset=utf-8";
if (e == "css") return "text/css";
if (e == "js") return "application/javascript";
if (e == "json") return "application/json";
if (e == "webmanifest") return "application/manifest+json";
if (e == "svg") return "image/svg+xml";
if (e == "png") return "image/png";
if (e == "jpg" ||
e == "jpeg") return "image/jpeg";
if (e == "ico") return "image/x-icon";
if (e == "woff") return "font/woff";
if (e == "woff2") return "font/woff2";
return "application/octet-stream";
}
// Computes FNV-1a hash of the data
static uint64_t fnv_hash(const uint8_t * data, size_t len) {
const uint64_t fnv_prime = 0x100000001b3ULL;
uint64_t hash = 0xcbf29ce484222325ULL;
for (size_t i = 0; i < len; ++i) {
hash ^= data[i];
hash *= fnv_prime;
}
return hash;
}
static bool read_file(const std::filesystem::path & path, std::vector<unsigned char> & out) {
std::ifstream f(path, std::ios::binary | std::ios::ate);
if (!f) {
fprintf(stderr, "embed: cannot open %s\n", path.string().c_str());
return false;
}
const auto sz = f.tellg();
if (sz < 0) {
return false;
}
f.seekg(0);
out.resize(static_cast<size_t>(sz));
if (sz > 0 && !f.read(reinterpret_cast<char *>(out.data()), sz)) {
return false;
}
return true;
}
static void append_bytes_hex(std::string & out, const std::vector<unsigned char> & bytes) {
static const char hex[] = "0123456789abcdef";
out.reserve(out.size() + bytes.size() * 5);
for (unsigned char b : bytes) {
out += '0';
out += 'x';
out += hex[b >> 4];
out += hex[b & 0xf];
out += ',';
}
}
static bool write_if_different(const std::string & path, const std::string & content) {
std::ifstream f(path, std::ios::binary | std::ios::ate);
if (f) {
const auto sz = f.tellg();
if (sz >= 0 && static_cast<size_t>(sz) == content.size()) {
std::string existing(static_cast<size_t>(sz), '\0');
f.seekg(0);
if (sz == 0 || f.read(existing.data(), sz)) {
if (existing == content) {
return true;
}
}
}
}
std::ofstream out(path, std::ios::binary | std::ios::trunc);
if (!out) {
fprintf(stderr, "embed: cannot write %s\n", path.c_str());
return false;
}
if (!content.empty()) {
out.write(content.data(), static_cast<std::streamsize>(content.size()));
}
bool ok = out.good();
if (ok) {
printf("embed: write output file %s\n", path.c_str());
}
return ok;
}
static std::string path_basename(const std::string & name) {
const size_t p = name.rfind('/');
return p == std::string::npos ? name : name.substr(p + 1);
}
static bool str_starts_with(const std::string & s, const char * prefix) {
const size_t n = strlen(prefix);
return s.size() >= n && s.compare(0, n, prefix) == 0;
}
static bool str_ends_with(const std::string & s, const char * suffix) {
const size_t n = strlen(suffix);
return s.size() >= n && s.compare(s.size() - n, n, suffix) == 0;
}
static std::string fmt(const char * pattern, ...) {
char tmp[512];
va_list ap;
va_start(ap, pattern);
const int n = vsnprintf(tmp, sizeof(tmp), pattern, ap);
va_end(ap);
return (n > 0) ? std::string(tmp, static_cast<size_t>(n)) : std::string();
}
struct asset_entry {
std::string name;
std::filesystem::path path;
};
int main(int argc, char ** argv) {
if (argc < 3 || argc > 4) {
fprintf(stderr, "usage: %s <out_cpp> <out_h> [<asset_dir>]\n", argv[0]);
return 1;
}
const std::string out_cpp = argv[1];
const std::string out_h = argv[2];
const std::string asset_dir = (argc >= 4) ? argv[3] : std::string();
const bool use_gzip = !asset_dir.empty() && std::filesystem::exists(asset_dir + "/_gzip");
const std::string in_dir = use_gzip ? (asset_dir + "/_gzip") : asset_dir;
std::vector<asset_entry> assets;
if (!in_dir.empty()) {
const std::filesystem::path dir = in_dir;
std::error_code ec;
std::filesystem::recursive_directory_iterator it(dir, ec);
if (ec) {
fprintf(stderr, "embed: cannot iterate %s: %s\n", argv[3], ec.message().c_str());
return 1;
}
for (const auto & entry : it) {
if (!entry.is_regular_file()) {
continue;
}
// name is the relative path from dir, with forward slashes
const std::string name = entry.path().lexically_relative(dir).generic_string();
assets.push_back({ name, entry.path() });
}
// directory iteration order is unspecified; sort for reproducible output
std::sort(assets.begin(), assets.end(),
[](const asset_entry & a, const asset_entry & b) { return a.name < b.name; });
}
const int n_assets = static_cast<int>(assets.size());
if (n_assets > 0) {
using match_fn = std::function<bool(const std::string &)>;
auto exact = [](const char * name) -> match_fn {
return [name](const std::string & base) { return base == name; };
};
struct required_check { const char * label; match_fn match; bool found; };
required_check checks[] = {
{ "index.html", exact("index.html"), false },
{ "manifest.webmanifest", exact("manifest.webmanifest"), false },
{ "sw.js", exact("sw.js"), false },
{ "build.json", exact("build.json"), false },
{ "version.json", exact("version.json"), false },
{ "bundle[hash].js", [](const std::string & b) {
return str_starts_with(b, "bundle") && str_ends_with(b, ".js");
}, false },
{ "bundle[hash].css", [](const std::string & b) {
return str_starts_with(b, "bundle") && str_ends_with(b, ".css");
}, false },
{ "workbox[hash].js", [](const std::string & b) {
return str_starts_with(b, "workbox") && str_ends_with(b, ".js");
}, false },
};
for (const auto & a : assets) {
const std::string base = path_basename(a.name);
for (auto & c : checks) {
if (!c.found) { c.found = c.match(base); }
}
}
std::vector<const char *> missing;
for (const auto & c : checks) {
if (!c.found) { missing.push_back(c.label); }
}
if (!missing.empty()) {
fprintf(stderr, "\ncurrent asset files:\n");
for (const auto & a : assets) {
fprintf(stderr, " %s\n", a.name.c_str());
}
fprintf(stderr, "missing required asset(s):\n");
for (const char * m : missing) {
fprintf(stderr, " %s\n", m);
}
fprintf(stderr, "hint: try cleaning your build directory: %s\n", in_dir.c_str());
return 1;
}
}
std::string h;
h += "#pragma once\n\n#include <array>\n#include <string>\n\n";
if (n_assets > 0) {
h += "#define LLAMA_UI_HAS_ASSETS 1\n\n";
}
h +=
"struct llama_ui_asset {\n"
" std::string name;\n"
" const unsigned char * data;\n"
" std::size_t size;\n"
" std::string etag;\n"
" std::string type;\n"
"};\n\n"
"const llama_ui_asset * llama_ui_find_asset(const std::string & name);\n"
"bool llama_ui_use_gzip();\n";
h += fmt("const std::array<llama_ui_asset, %d> & llama_ui_get_assets();\n", n_assets);
std::string cpp;
cpp += "#include \"ui.h\"\n\n";
if (n_assets > 0) {
for (int i = 0; i < n_assets; i++) {
std::vector<unsigned char> bytes;
if (!read_file(assets[i].path, bytes)) {
return 1;
}
if (bytes.empty()) {
fprintf(stderr, "embed: empty file: %s\n", assets[i].path.generic_string().c_str());
return 1;
}
cpp += fmt("static const unsigned char asset_%d_data[] = {", i);
append_bytes_hex(cpp, bytes);
const auto hash = fnv_hash(bytes.data(), bytes.size());
cpp += fmt("};\nstatic const std::size_t asset_%d_size = %zu;\n",
i, bytes.size());
cpp += fmt("static const char asset_%d_etag[] = \"\\\"0x%016" PRIx64 "\\\"\";\n\n",
i, hash);
}
cpp += fmt("static const std::array<llama_ui_asset, %d> g_assets = {{\n", n_assets);
for (int i = 0; i < n_assets; i++) {
const std::string & name = assets[i].name;
cpp += fmt(" { \"%s\", asset_%d_data, asset_%d_size, asset_%d_etag, \"%s\" },\n",
name.c_str(), i, i, i, mime_from_ext(name));
}
cpp += "}};\n\n";
cpp +=
"const llama_ui_asset * llama_ui_find_asset(const std::string & name) {\n"
" for (const auto & a : g_assets) {\n"
" if (a.name == name) {\n"
" return &a;\n"
" }\n"
" }\n"
" return nullptr;\n"
"}\n";
cpp += fmt("const std::array<llama_ui_asset, %d> & llama_ui_get_assets() {\n", n_assets);
cpp += " return g_assets;\n"
"}\n";
} else {
cpp +=
"const llama_ui_asset * llama_ui_find_asset(const std::string &) {\n"
" return nullptr;\n"
"}\n"
"const std::array<llama_ui_asset, 0> & llama_ui_get_assets() {\n"
" static const std::array<llama_ui_asset, 0> empty{};\n"
" return empty;\n"
"}\n";
}
cpp += fmt("bool llama_ui_use_gzip() { return %s; }\n", use_gzip ? "true" : "false");
bool ok = true;
ok = write_if_different(out_h, h) && ok;
ok = write_if_different(out_cpp, cpp) && ok;
return ok ? 0 : 1;
}
+59
View File
@@ -0,0 +1,59 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from 'eslint-plugin-storybook';
import prettier from 'eslint-config-prettier';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default ts.config(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off',
'svelte/no-at-html-tags': 'off',
// This app uses hash-based routing (#/) where resolve() from $app/paths does not apply
'svelte/no-navigation-without-resolve': 'off',
// Enforce empty line at end of file
'eol-last': 'error'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
},
{
// Exclude generated build output and Storybook files from ESLint
ignores: [
'dist/**',
'build/**',
'.svelte-kit/**',
'test-results/**',
'.storybook/**/*',
'src/lib/services/sandbox-worker.js'
]
},
storybook.configs['flat/recommended']
);
+17523
View File
File diff suppressed because it is too large Load Diff
+107
View File
@@ -0,0 +1,107 @@
{
"name": "llama-ui",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "npm run build-pwa-assets && vite build",
"build-pwa-assets": "npx @vite-pwa/assets-generator --root . --config pwa-assets.config.ts && npx @vite-pwa/assets-generator --root . --config pwa-assets-dark.config.ts && node scripts/make-icons-circular.js",
"dev": "bash scripts/dev.sh",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"reset": "rm -rf .svelte-kit node_modules",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"test": "npm run test:ui -- --run && npm run test:client -- --run && npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright test",
"test:e2e:pwa": "playwright test tests/e2e/pwa.e2e.ts",
"test:client": "vitest --project=client",
"test:unit": "vitest --project=unit",
"test:unit:pwa": "vitest --project=unit --run tests/unit/pwa.spec.ts",
"test:pwa": "npm run test:unit:pwa && npm run test:e2e:pwa",
"test:ui": "vitest --project=ui",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"cleanup": "rm -rf .svelte-kit build node_modules test-results dist dev-dist debug-storybook.log static/pwa-*.png static/maskable-icon-*.png static/apple-touch-icon-*.png static/apple-splash-*.png static/favicon*.ico"
},
"devDependencies": {
"@chromatic-com/storybook": "5.0.0",
"@eslint/compat": "1.4.1",
"@eslint/js": "9.39.2",
"@internationalized/date": "3.12.2",
"@lucide/svelte": "0.515.0",
"@modelcontextprotocol/sdk": "1.26.0",
"@playwright/test": "1.56.1",
"@storybook/addon-a11y": "10.2.4",
"@storybook/addon-docs": "10.2.4",
"@storybook/addon-svelte-csf": "5.0.10",
"@storybook/addon-vitest": "10.2.4",
"@storybook/sveltekit": "10.2.4",
"@sveltejs/adapter-static": "3.0.10",
"@sveltejs/kit": "2.60.1",
"@sveltejs/vite-plugin-svelte": "6.2.1",
"@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16",
"@tailwindcss/vite": "4.1.11",
"@types/node": "24.13.0",
"@vite-pwa/assets-generator": "1.0.2",
"@vite-pwa/sveltekit": "1.1.0",
"@vitest/browser": "4.1.8",
"@vitest/browser-playwright": "4.1.8",
"@vitest/coverage-v8": "4.1.8",
"bits-ui": "2.18.1",
"clsx": "2.1.1",
"dexie": "4.4.3",
"dompurify": "3.4.11",
"eslint": "9.39.4",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-storybook": "10.4.2",
"eslint-plugin-svelte": "3.19.0",
"fflate": "0.8.3",
"globals": "16.5.0",
"highlight.js": "11.11.1",
"http-server": "14.1.1",
"mdast": "3.0.0",
"mdsvex": "0.12.7",
"mermaid": "11.15.0",
"mode-watcher": "1.1.0",
"pdfjs-dist": "5.4.54",
"playwright": "1.56.1",
"prettier": "3.8.3",
"prettier-plugin-svelte": "4.1.0",
"prettier-plugin-tailwindcss": "0.8.0",
"rehype-highlight": "7.0.2",
"rehype-katex": "7.0.1",
"rehype-stringify": "10.0.1",
"remark": "15.0.1",
"remark-breaks": "4.0.0",
"remark-gfm": "4.0.1",
"remark-html": "16.0.1",
"remark-math": "6.0.0",
"remark-rehype": "11.1.2",
"sass": "1.100.0",
"storybook": "10.4.2",
"svelte": "5.56.1",
"svelte-check": "4.6.0",
"svelte-sonner": "1.1.1",
"tailwind-merge": "3.6.0",
"tailwind-variants": "3.2.2",
"tailwindcss": "4.3.0",
"tw-animate-css": "1.4.0",
"typescript": "5.9.3",
"typescript-eslint": "8.60.1",
"unified": "11.0.5",
"unist-util-visit": "5.1.0",
"uuid": "13.0.2",
"vite": "7.3.5",
"vite-plugin-devtools-json": "0.2.1",
"vitest": "4.1.8",
"vitest-browser-svelte": "2.1.1",
"workbox-window": "7.4.1"
},
"overrides": {
"cookie": "1.1.1"
}
}
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: 'tests/e2e',
testMatch: ['**/*.e2e.ts'],
timeout: 30000,
expect: {
timeout: 5000
},
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'line',
use: {
baseURL: 'http://localhost:8181',
trace: 'on-first-retry'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
}
],
webServer: {
command: 'npm run build && npx http-server ./dist -p 8181',
port: 8181,
timeout: 120000,
reuseExistingServer: !process.env.CI
}
});
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig } from '@vite-pwa/assets-generator/config';
import { FAVICON_COLORS, PWA_ASSET_GENERATOR } from './src/lib/constants/pwa';
import { writeThemeFavicons } from './scripts/favicon-colorize';
writeThemeFavicons(FAVICON_COLORS.LIGHT, FAVICON_COLORS.DARK, {
padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
});
export default defineConfig({
headLinkOptions: {
preset: '2023'
},
preset: {
transparent: {
sizes: [],
favicons: [[48, 'favicon-dark.ico']],
padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
},
maskable: {
sizes: []
},
apple: {
sizes: []
}
},
images: ['static/favicon-dark.svg']
});
+68
View File
@@ -0,0 +1,68 @@
import {
combinePresetAndAppleSplashScreens,
defineConfig,
minimal2023Preset
} from '@vite-pwa/assets-generator/config';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
THEME_COLORS,
PWA_GENERATOR_DEVICES,
PWA_ASSET_GENERATOR,
FAVICON_COLORS
} from './src/lib/constants/pwa';
import { SplashOrientation } from './src/lib/enums/splash.enums';
import { writeThemeFavicons } from './scripts/favicon-colorize';
writeThemeFavicons(FAVICON_COLORS.LIGHT, FAVICON_COLORS.DARK, {
padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
});
export default defineConfig({
headLinkOptions: {
preset: PWA_ASSET_GENERATOR.LINK_PRESET
},
preset: combinePresetAndAppleSplashScreens(
{
...minimal2023Preset,
// tiny margin so favicon.ico / pwa-*.png breathe inside the canvas
transparent: {
...minimal2023Preset.transparent,
padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
}
},
{
padding: PWA_ASSET_GENERATOR.SPLASH_PADDING,
resizeOptions: {
background: THEME_COLORS.BACKGROUND_LIGHT,
fit: PWA_ASSET_GENERATOR.FIT_MODE
},
darkResizeOptions: {
background: THEME_COLORS.BACKGROUND_DARK,
fit: PWA_ASSET_GENERATOR.FIT_MODE
},
darkImageResolver: async (imageName: string) => {
if (imageName.endsWith('favicon.svg')) {
return readFileSync(resolve('static/favicon-dark.svg'));
}
},
linkMediaOptions: {
log: true,
addMediaScreen: PWA_ASSET_GENERATOR.ADD_MEDIA_SCREEN,
basePath: PWA_ASSET_GENERATOR.BASE_PATH,
xhtml: PWA_ASSET_GENERATOR.XHTML
},
png: {
compressionLevel: PWA_ASSET_GENERATOR.PNG_COMPRESSION_LEVEL,
quality: PWA_ASSET_GENERATOR.PNG_QUALITY
},
name: (landscape, size, dark) => {
const orientation = landscape ? SplashOrientation.LANDSCAPE : SplashOrientation.PORTRAIT;
const darkPrefix = dark ? PWA_ASSET_GENERATOR.DARK_PREFIX : '';
return `apple-splash-${orientation}-${darkPrefix}${size.width}x${size.height}.png`;
}
},
PWA_GENERATOR_DEVICES
),
images: ['static/favicon.svg']
});
+61
View File
@@ -0,0 +1,61 @@
#!/bin/bash
# Development script for llama-ui
#
# This script starts the llama-ui development servers (Storybook and Vite).
# Note: You need to start llama-server separately.
#
# Usage:
# bash scripts/dev.sh
# npm run dev
cd ../../
# Ensure node_modules are installed
if [ ! -d "tools/ui/node_modules" ]; then
echo "📦 Installing npm dependencies..."
cd tools/ui && npm install && cd ../../
fi
# Check and install git hooks if missing
check_and_install_hooks() {
local hooks_missing=false
# Check for required hooks
if [ ! -f ".git/hooks/pre-commit" ] || [ ! -f ".git/hooks/pre-push" ]; then
hooks_missing=true
fi
if [ "$hooks_missing" = true ]; then
echo "🔧 Git hooks missing, installing them..."
if bash "$(dirname "$0")/git-hooks/install.sh"; then
echo "✅ Git hooks installed successfully"
else
echo "⚠️ Failed to install git hooks, continuing anyway..."
fi
else
echo "✅ Git hooks already installed"
fi
}
# Install git hooks if needed
check_and_install_hooks
# Cleanup function
cleanup() {
echo "🧹 Cleaning up..."
exit
}
# Set up signal handlers
trap cleanup SIGINT SIGTERM
echo "🚀 Starting development servers..."
echo "📝 Note: Make sure to start llama-server separately if needed"
cd tools/ui
# Use --insecure-http-parser to handle malformed HTTP responses from llama-server
# (some responses have both Content-Length and Transfer-Encoding headers)
storybook dev -p 6006 --ci & NODE_OPTIONS="--insecure-http-parser" vite dev --host 0.0.0.0 &
# Wait for all background processes
wait
+107
View File
@@ -0,0 +1,107 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = resolve(HERE, '..');
const DEFAULT_LOGO = resolve(PROJECT_ROOT, 'src/lib/assets/logo.svg');
const DEFAULT_OUT_DIR = resolve(PROJECT_ROOT, 'static');
const DEFAULT_OUT_LIGHT = resolve(DEFAULT_OUT_DIR, 'favicon.svg');
const DEFAULT_OUT_DARK = resolve(DEFAULT_OUT_DIR, 'favicon-dark.svg');
const CURRENT_COLOR = 'currentColor';
export interface ColorizedFavicon {
light: string;
dark: string;
}
export interface WriteThemeFaviconsOptions {
sourcePath?: string;
lightOutPath?: string;
darkOutPath?: string;
/**
* Fraction of the icon (0..1) to leave as an even margin on each side.
* Applied by wrapping the inner content in a `<g transform="...">` so the
* source `src/lib/assets/logo.svg` is not modified. Pass 0 to disable.
*/
padding?: number;
}
/**
* Replace every `currentColor` occurrence in the SVG with the given color.
* Pure: no filesystem access, so it is straightforward to unit-test.
*/
export function colorizeFaviconSvg(
svg: string,
lightColor: string,
darkColor: string
): ColorizedFavicon {
return {
light: svg.replaceAll(CURRENT_COLOR, lightColor),
dark: svg.replaceAll(CURRENT_COLOR, darkColor)
};
}
/**
* Shrink the inner SVG content uniformly and re-center it so `padding` (a
* 0..1 fraction) is reserved as equal margin on each side. Returns the input
* unchanged for non-positive padding, missing/invalid `viewBox`, or unexpected
* markup so the caller always gets a renderable SVG.
*/
export function padFaviconSvg(svg: string, padding: number): string {
if (!(padding > 0) || padding >= 1) return svg;
const viewBoxMatch = svg.match(/viewBox\s*=\s*["']([^"']+)["']/i);
if (!viewBoxMatch) return svg;
const parts = viewBoxMatch[1]
.trim()
.split(/[\s,]+/)
.map(Number);
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return svg;
const [, , width, height] = parts;
if (width <= 0 || height <= 0) return svg;
const scale = 1 - padding;
const translateX = (padding * width) / 2;
const translateY = (padding * height) / 2;
const openTagStart = svg.search(/<svg\b/i);
if (openTagStart === -1) return svg;
const openTagEnd = svg.indexOf('>', openTagStart);
if (openTagEnd === -1) return svg;
const closeStart = svg.lastIndexOf('</svg');
if (closeStart === -1 || closeStart <= openTagEnd) return svg;
const openTag = svg.slice(0, openTagEnd + 1);
const inner = svg.slice(openTagEnd + 1, closeStart);
const closeTag = svg.slice(closeStart);
const group = `<g transform="translate(${translateX} ${translateY}) scale(${scale})">`;
return `${openTag}${group}${inner}</g>${closeTag}`;
}
/**
* Read `src/lib/assets/logo.svg`, colorize it for both themes, and write
* the results to the static directory so the PWA asset generator can consume
* them. Paths can be overridden for tests.
*/
export function writeThemeFavicons(
lightColor: string,
darkColor: string,
{
sourcePath = DEFAULT_LOGO,
lightOutPath = DEFAULT_OUT_LIGHT,
darkOutPath = DEFAULT_OUT_DARK,
padding = 0
}: WriteThemeFaviconsOptions = {}
): void {
const source = readFileSync(sourcePath, 'utf-8');
const { light, dark } = colorizeFaviconSvg(source, lightColor, darkColor);
mkdirSync(dirname(lightOutPath), { recursive: true });
writeFileSync(lightOutPath, padFaviconSvg(light, padding));
writeFileSync(darkOutPath, padFaviconSvg(dark, padding));
}
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
#
# Install git hooks for llama-ui
# Copies pre-commit and pre-push hooks into the repo's .git/hooks directory.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
HOOKS_DIR="$REPO_ROOT/$(cd "$REPO_ROOT" && git rev-parse --git-path hooks)"
# Verify package.json exists
if [ ! -f "$REPO_ROOT/tools/ui/package.json" ]; then
echo "❌ package.json not found in tools/ui"
exit 1
fi
echo "Installing git hooks for llama-ui..."
for hook in pre-commit pre-push; do
src="$SCRIPT_DIR/${hook}.sh"
dst="$HOOKS_DIR/$hook"
if cp "$src" "$dst" && chmod +x "$dst"; then
echo "$hook"
else
echo " ❌ Failed to install $hook"
exit 1
fi
done
echo ""
echo "Pre-commit: format (staged) + type-check"
echo "Pre-push: lint + test"
echo ""
echo "Hooks stash unstaged changes temporarily and restore them after."
echo "Skip with: git commit --no-verify / git push --no-verify"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
#
# Pre-commit hook for llama-ui
# Runs: format (staged files only) + type-check
# Stashes unstaged changes temporarily and restores them after.
# Only run when there are staged changes in tools/ui/
if ! git diff --cached --name-only | grep -q "^tools/ui/"; then
exit 0
fi
REPO_ROOT=$(git rev-parse --show-toplevel)
cd "$REPO_ROOT/tools/ui"
# Check that node_modules exists
if [ ! -d "node_modules" ]; then
echo "❌ node_modules not found. Run 'npm install' first."
exit 1
fi
# Stash unstaged changes in tools/ui/ so they don't interfere
stash_name="pi-ui-precommit"
git stash push --keep-index -u -m "$stash_name" -- tools/ui/ 2>/dev/null || true
echo "Running pre-commit checks for llama-ui..."
# Format only staged files
staged_ui=$(git diff --cached --name-only -- tools/ui/)
if [ -n "$staged_ui" ]; then
echo "$staged_ui" | xargs npm run format
format_ok=$?
# Re-stage formatted files
git add tools/ui/
else
format_ok=0
fi
# Type-check the clean tree
npm run check
check_ok=$?
# Restore stashed changes
if git stash list | grep -q "$stash_name"; then
git stash pop 2>/dev/null || true
fi
if [ $format_ok -ne 0 ]; then
echo "❌ Format failed"
exit 1
fi
if [ $check_ok -ne 0 ]; then
echo "❌ Type check failed"
exit 1
fi
echo "✅ Pre-commit checks passed"
exit 0
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
#
# Pre-push hook for llama-ui
# Runs: lint + test
# Ignores unstaged changes (stashes them temporarily and restores after).
needs_check=false
# Read refs from stdin: local_ref local_sha remote_ref remote_sha
while read local_ref local_sha remote_ref remote_sha; do
# New branch or force-push — always check
if [ "$local_sha" = "0000000000000000000000000000000000000000" ] || \
[ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then
needs_check=true
continue
fi
# Check for changes in tools/ui/ between remote and local
if git diff --name-only "$remote_sha...$local_sha" -- tools/ui/ | grep -q .; then
needs_check=true
fi
done
if [ "$needs_check" = false ]; then
exit 0
fi
REPO_ROOT=$(git rev-parse --show-toplevel)
cd "$REPO_ROOT/tools/ui"
# Check that node_modules exists
if [ ! -d "node_modules" ]; then
echo "❌ node_modules not found. Run 'npm install' first."
exit 1
fi
# Stash unstaged changes so they don't interfere with checks
stash_name="pi-ui-prepush"
git stash push -u -m "$stash_name" -- tools/ui/ 2>/dev/null || true
echo "Running pre-push checks for llama-ui..."
# Lint
npm run lint
lint_ok=$?
# Test
npm test
test_ok=$?
# Restore stashed changes
if git stash list | grep -q "$stash_name"; then
git stash pop 2>/dev/null || true
fi
if [ $lint_ok -ne 0 ]; then
echo "❌ Lint failed"
exit 1
fi
if [ $test_ok -ne 0 ]; then
echo "❌ Tests failed"
exit 1
fi
echo "✅ Pre-push checks passed"
exit 0
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env node
/**
* Apply circular mask to pwa-*.png icons.
* Uses the maskable icon as source (white bg, full logo) to avoid
* the small-colormap pwa icons looking bad when cropped to a circle.
*
* Usage: node scripts/make-icons-circular.js [--padding-pct <0-50>] [--scale-pct <50-100>]
*
* - padding-pct: percentage of icon size kept as padding around the circle (default: 25)
* - scale-pct: scale down the source image before cropping (default: 85)
*
* maskable-icon and apple-touch-icon are left untouched.
*/
import sharp from 'sharp';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const STATIC_DIR = path.resolve(__dirname, '..', 'static');
const paddingPct = process.argv.reduce((acc, arg, i, args) => {
if (arg === '--padding-pct' && args[i + 1]) return parseFloat(args[i + 1]);
return acc;
}, 0);
// Scale down the source image before cropping to circle
const scalePct = process.argv.reduce((acc, arg, i, args) => {
if (arg === '--scale-pct' && args[i + 1]) return parseFloat(args[i + 1]);
return acc;
}, 85); // default 85% - icon fills 85% of the circular area
// Source for circular icons: the maskable icon (white bg, full logo)
const sourceIcon = 'maskable-icon-512x512.png';
const targetIcons = ['pwa-64x64.png', 'pwa-192x192.png', 'pwa-512x512.png'];
// maskable-icon and apple-touch-icon stay square
const untouchedIcons = ['maskable-icon-512x512.png', 'apple-touch-icon-180x180.png'];
async function makeCircle(targetFilename) {
const targetPath = path.join(STATIC_DIR, targetFilename);
const sourcePath = path.join(STATIC_DIR, sourceIcon);
if (!fs.existsSync(sourcePath)) {
console.log(`⏭️ ${sourceIcon} not found, skipping`);
return;
}
if (!fs.existsSync(targetPath)) {
console.log(`⏭️ ${targetFilename} not found, skipping`);
return;
}
const metadata = await sharp(targetPath).metadata();
const size = Math.max(metadata.width, metadata.height);
const radius = Math.floor((size * (1 - paddingPct / 100)) / 2);
const center = Math.floor(size / 2);
// Build circular mask as RGBA buffer: white opaque circle on transparent bg
const maskBuf = Buffer.alloc(size * size * 4, 0);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const dx = x - center;
const dy = y - center;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < radius) {
const i = (y * size + x) * 4;
maskBuf[i] = 255;
maskBuf[i + 1] = 255;
maskBuf[i + 2] = 255;
maskBuf[i + 3] = 255;
}
}
}
const tmpMask = path.join(STATIC_DIR, '.mask-tmp.png');
await sharp(maskBuf, {
raw: { width: size, height: size, channels: 4 }
})
.png()
.toFile(tmpMask);
// Step 1: Scale source relative to circle diameter (not full icon), composite centered onto white canvas of full size
const circleDiameter = Math.floor(size * (1 - paddingPct / 100));
const scaledSize = Math.floor((circleDiameter * scalePct) / 100);
const offset = Math.floor((size - scaledSize) / 2);
const scaledBuf = await sharp(sourcePath)
.resize(scaledSize, scaledSize, {
fit: 'cover',
background: { r: 255, g: 255, b: 255, alpha: 1 }
})
.ensureAlpha()
.png()
.toBuffer();
// Step 2: Composite scaled image onto white background, then apply circular mask
const output = await sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 255, g: 255, b: 255, alpha: 1 }
}
})
.composite([
{ input: scaledBuf, top: offset, left: offset },
{ input: tmpMask, top: 0, left: 0, blend: 'dest-in' }
])
.png()
.toBuffer();
fs.writeFileSync(targetPath, output);
fs.unlinkSync(tmpMask);
console.log(
`${targetFilename} → circle from ${sourceIcon}, ${paddingPct}% padding (size=${size}, r=${radius}, scale=${scalePct}%, circleDiameter=${circleDiameter})`
);
}
async function main() {
console.log(`Circular mask: ${paddingPct}% padding, ${scalePct}% scale, source=${sourceIcon}\n`);
for (const icon of targetIcons) {
await makeCircle(icon);
}
console.log('\nUnchanged:');
for (const icon of untouchedIcons) {
const fp = path.join(STATIC_DIR, icon);
console.log(` ${icon} (${fs.existsSync(fp) ? fs.statSync(fp).size + ' bytes' : 'missing'})`);
}
}
main();
@@ -0,0 +1,41 @@
import { writeFileSync, existsSync } from 'node:fs';
import { resolve } from 'path';
import type { Plugin } from 'vite';
import { BUILD_CONFIG } from '../src/lib/constants/pwa';
let processed = false;
const OUTPUT_DIR = process.env.LLAMA_UI_OUT_DIR ?? BUILD_CONFIG.OUTPUT_DIR;
/**
* Write build.json with the llama.cpp release build number.
*
* LLAMA_BUILD_NUMBER is passed from CMake -> npm -> vite via env var.
* Used for display of the current llama-server release (e.g. "b1234").
*/
export function buildInfoPlugin(): Plugin {
return {
name: 'llamacpp:build-info',
apply: 'build',
closeBundle() {
setTimeout(() => {
try {
if (processed) return;
processed = true;
const buildNumber = process.env.LLAMA_BUILD_NUMBER || 'b0000';
const outDir = resolve(OUTPUT_DIR);
const indexPath = resolve(outDir, 'index.html');
if (!existsSync(indexPath)) return;
const buildJsonPath = resolve(outDir, 'build.json');
writeFileSync(buildJsonPath, JSON.stringify({ version: buildNumber }), 'utf-8');
console.log(`Created build.json (version: ${buildNumber})`);
} catch (error) {
console.error('Failed to write build.json:', error);
}
}, 100);
}
};
}
@@ -0,0 +1,61 @@
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { resolve } from 'path';
import type { Plugin } from 'vite';
import { BUILD_CONFIG } from '../src/lib/constants/pwa';
let processed = false;
const OUTPUT_DIR = process.env.LLAMA_UI_OUT_DIR ?? BUILD_CONFIG.OUTPUT_DIR;
function rewrite(path: string, pairs: [string, string][]): void {
if (!existsSync(path)) {
return;
}
const text = readFileSync(path, 'utf-8');
let out = text;
for (const [from, to] of pairs) {
out = out.split(from).join(to);
}
if (out !== text) {
writeFileSync(path, out, 'utf-8');
}
}
/**
* Relativize SvelteKit absolute base refs so the build is relocatable under any subpath.
*
* SvelteKit bakes root absolute /_app/ paths into the SPA fallback because paths.relative
* does not apply to a depth agnostic fallback page. Rewriting to ./_app/ lets a plain
* recursive copy of the output into /any/subdir/ resolve assets against the document URL.
* Runs after adapter-static writes index.html and the PWA plugin writes sw.js, deferred the
* same way as buildInfoPlugin so the emitted files exist.
*/
export function relativizeBasePlugin(): Plugin {
return {
name: 'llamacpp:relativize-base',
apply: 'build',
closeBundle() {
setTimeout(() => {
try {
if (processed) return;
processed = true;
const outDir = resolve(OUTPUT_DIR);
// index.html: modulepreload, stylesheet and bootstrap import reference "/_app/
rewrite(resolve(outDir, 'index.html'), [['"/_app/', '"./_app/']]);
// sw.js: the only absolute entries are the navigate fallback precache key and handler
rewrite(resolve(outDir, 'sw.js'), [
['{url:"/"', '{url:"./"'],
['createHandlerBoundToURL("/"', 'createHandlerBoundToURL("./"']
]);
console.log('Relativized base refs in index.html and sw.js');
} catch (error) {
console.error('Failed to relativize base refs:', error);
}
}, 100);
}
};
}
@@ -0,0 +1,115 @@
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { resolve } from 'path';
import type { Plugin } from 'vite';
import { TAB, NEWLINE } from '../src/lib/constants/code';
import { APPLE_DEVICES, BUILD_CONFIG, REGEX_PATTERNS, SPLASH_LINK } from '../src/lib/constants/pwa';
import type { SplashDimensions } from '../src/lib/types';
import { SplashOrientation } from '../src/lib/enums/splash.enums';
let processed = false;
const OUTPUT_DIR = process.env.LLAMA_UI_OUT_DIR ?? BUILD_CONFIG.OUTPUT_DIR;
/**
* Generate iOS splash screen <link> tags from generated apple-splash-*.png files.
* Returns an array of HTML link strings to be injected into the page head.
*/
export function generateSplashScreenLinks(outDir: string): string[] {
const files = readdirSync(outDir).filter((f) => f.match(REGEX_PATTERNS.SPLASH_FILE));
if (files.length === 0) return [];
const dimMap = new Map<string, SplashDimensions>();
for (const [dims, spec] of Object.entries(APPLE_DEVICES)) {
const [w, h] = dims.split('x').map(Number);
// logical-point dimensions
dimMap.set(`${w}x${h}`, { deviceW: spec.width, deviceH: spec.height, dpr: spec.dpr });
dimMap.set(`${h}x${w}`, { deviceW: spec.width, deviceH: spec.height, dpr: spec.dpr });
// pixel dimensions (used by actual generated splash files)
dimMap.set(`${w * spec.dpr}x${h * spec.dpr}`, {
deviceW: spec.width,
deviceH: spec.height,
dpr: spec.dpr
});
dimMap.set(`${h * spec.dpr}x${w * spec.dpr}`, {
deviceW: spec.width,
deviceH: spec.height,
dpr: spec.dpr
});
}
const lightLinks: string[] = [];
const darkLinks: string[] = [];
for (const file of files) {
const match = file.match(REGEX_PATTERNS.SPLASH_FILE);
if (!match) continue;
const orientation = match[1] as SplashOrientation;
const isDark = !!match[2];
const pixelW = parseInt(match[3]);
const pixelH = parseInt(match[4]);
const key = `${pixelW}x${pixelH}`;
const spec = dimMap.get(key);
if (!spec) {
console.warn(`Unknown splash screen dimensions: ${key} (${file})`);
continue;
}
const { deviceW, deviceH, dpr } = spec;
const media = `screen and (device-width: ${deviceW}px) and (device-height: ${deviceH}px) and (-webkit-device-pixel-ratio: ${dpr}) and (orientation: ${orientation})`;
const href = `./${file}`;
if (isDark) {
darkLinks.push(
`${SPLASH_LINK.HTML} media="${media}${SPLASH_LINK.DARK_MEDIA_SUFFIX}" href="${href}">`
);
} else {
lightLinks.push(`${SPLASH_LINK.HTML} media="${media}" href="${href}">`);
}
}
return [...lightLinks, ...darkLinks];
}
export function splashScreenPlugin(): Plugin {
return {
name: 'llamacpp:splash-screen',
apply: 'build',
closeBundle() {
setTimeout(() => {
try {
if (processed) return;
processed = true;
const outDir = resolve(OUTPUT_DIR);
const indexPath = resolve(outDir, 'index.html');
if (!existsSync(indexPath)) return;
let content = readFileSync(indexPath, 'utf-8');
// Inject iOS splash screen <link> tags into <head>.
// The @vite-pwa/assets-generator generates apple-splash-*.png files;
// this scans them and creates the <link> tags SvelteKit needs.
const splashLinks = generateSplashScreenLinks(outDir);
if (splashLinks.length > 0) {
console.log(`Generated ${splashLinks.length} apple-splash link tags`);
const splashHtml = splashLinks.map((l) => TAB + TAB + l).join(NEWLINE);
content = content.replace(
REGEX_PATTERNS.HEAD_CLOSE,
splashHtml + NEWLINE + TAB + TAB + '</head>'
);
}
// Remove trailing \r from Windows line endings
content = content.replace(/\r/g, '');
content = BUILD_CONFIG.GUIDE_COMMENT + NEWLINE + content;
writeFileSync(indexPath, content, 'utf-8');
console.log('Updated index.html');
} catch (error) {
console.error('Failed to process build output:', error);
}
}, 100);
}
};
}
+16
View File
@@ -0,0 +1,16 @@
# Inputs used to decide whether the npm build output is up-to-date.
set(UI_SOURCE_GLOBS
src/*
static/*
)
set(UI_SOURCE_FILES
package.json
package-lock.json
src/.gitignore
vite.config.ts
svelte.config.js
tsconfig.json
scripts/vite-plugin-llama-cpp-build.ts
)
+1
View File
@@ -0,0 +1 @@
!*
+200
View File
@@ -0,0 +1,200 @@
@import 'tailwindcss';
@source '.';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.95 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.95 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.875 0 0);
--input: oklch(0.92 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--code-background: oklch(0.985 0 0);
--code-foreground: oklch(0.145 0 0);
--font-mono:
ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
'Liberation Mono', Menlo, monospace;
--layer-popover: 1000000;
--chat-form-area-height: 8rem;
--chat-form-area-offset: 2rem;
--chat-form-padding-top: 6rem;
--max-message-height: max(24rem, min(80dvh, calc(100dvh - var(--chat-form-area-height) - 12rem)));
}
@media (min-width: 640px) {
:root {
--chat-form-area-height: 24rem;
--chat-form-area-offset: 12rem;
--chat-form-padding-top: 6rem;
}
}
.dark {
--background: oklch(0.16 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.29 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 30%);
--input: oklch(1 0 0 / 30%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.2 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--code-background: oklch(0.225 0 0);
--code-foreground: oklch(0.875 0 0);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
scrollbar-width: thin;
scrollbar-gutter: stable;
}
/* Global scrollbar styling - visible only on hover */
* {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
*:hover {
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 3px;
transition: background 0.2s ease;
}
*:hover::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
}
*::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
}
:where(code, pre, kbd, samp) {
font-family: var(--font-mono);
}
}
@layer utilities {
.scrollbar-hide {
/* Hide scrollbar for Chrome, Safari and Opera */
&::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
-ms-overflow-style: none;
scrollbar-width: none;
}
}
.mermaidTooltip {
display: none !important;
}
+146
View File
@@ -0,0 +1,146 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
import 'vite-plugin-pwa/pwa-assets';
import 'vite-plugin-pwa/svelte';
// Import chat types from dedicated module
import type {
// API types
ApiChatCompletionRequest,
ApiChatCompletionResponse,
ApiChatCompletionStreamChunk,
ApiChatCompletionToolCall,
ApiChatCompletionToolCallDelta,
ApiChatMessageData,
ApiChatMessageContentPart,
ApiContextSizeError,
ApiErrorResponse,
ApiLlamaCppServerProps,
ApiModelDataEntry,
ApiModelLoadStage,
ApiModelsSseProgress,
ApiModelsSseData,
ApiModelsSseEvent,
ApiModelListResponse,
ApiProcessingState,
ApiRouterModelMeta,
ApiRouterModelsLoadRequest,
ApiRouterModelsLoadResponse,
ApiRouterModelsStatusRequest,
ApiRouterModelsStatusResponse,
ApiRouterModelsListResponse,
ApiRouterModelsUnloadRequest,
ApiRouterModelsUnloadResponse,
// Chat types
ChatAttachmentDisplayItem,
ChatMessageType,
ChatRole,
ChatUploadedFile,
ChatMessageSiblingInfo,
ChatMessagePromptProgress,
ChatMessageTimings,
// Database types
DatabaseConversation,
DatabaseMessage,
DatabaseMessageExtra,
DatabaseMessageExtraAudioFile,
DatabaseMessageExtraVideoFile,
DatabaseMessageExtraImageFile,
DatabaseMessageExtraTextFile,
DatabaseMessageExtraPdfFile,
DatabaseMessageExtraLegacyContext,
ExportedConversation,
ExportedConversations,
// Model types
ModelModalities,
ModelOption,
ModelLoadProgress,
// Settings types
SettingsChatServiceOptions,
SettingsConfigValue,
SettingsFieldConfig,
SettingsConfigType
} from '$lib/types';
import { ServerRole, ServerModelStatus, ModelModality } from '$lib/enums';
declare global {
// namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
// }
export {
// API types
ApiChatCompletionRequest,
ApiChatCompletionResponse,
ApiChatCompletionStreamChunk,
ApiChatCompletionToolCall,
ApiChatCompletionToolCallDelta,
ApiChatMessageData,
ApiChatMessageContentPart,
ApiContextSizeError,
ApiErrorResponse,
ApiLlamaCppServerProps,
ApiModelDataEntry,
ApiModelLoadStage,
ApiModelsSseProgress,
ApiModelsSseData,
ApiModelsSseEvent,
ApiModelListResponse,
ApiProcessingState,
ApiRouterModelMeta,
ApiRouterModelsLoadRequest,
ApiRouterModelsLoadResponse,
ApiRouterModelsStatusRequest,
ApiRouterModelsStatusResponse,
ApiRouterModelsListResponse,
ApiRouterModelsUnloadRequest,
ApiRouterModelsUnloadResponse,
// Chat types
ChatAttachmentDisplayItem,
ChatMessagePromptProgress,
ChatMessageSiblingInfo,
ChatMessageTimings,
ChatMessageType,
ChatRole,
ChatUploadedFile,
// Database types
DatabaseConversation,
DatabaseMessage,
DatabaseMessageExtra,
DatabaseMessageExtraAudioFile,
DatabaseMessageExtraVideoFile,
DatabaseMessageExtraImageFile,
DatabaseMessageExtraTextFile,
DatabaseMessageExtraPdfFile,
DatabaseMessageExtraLegacyContext,
ExportedConversation,
ExportedConversations,
// Enum types
ModelModality,
ServerRole,
ServerModelStatus,
// Model types
ModelModalities,
ModelOption,
ModelLoadProgress,
// Settings types
SettingsChatServiceOptions,
SettingsConfigValue,
SettingsFieldConfig,
SettingsConfigType
};
}
declare global {
interface Window {
idxThemeStyle?: number;
idxCodeBlock?: number;
}
}
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="favicon.ico" sizes="48x48" />
<link rel="icon" href="favicon.svg" sizes="any" type="image/svg+xml" />
<link rel="apple-touch-icon" href="apple-touch-icon-180x180.png" />
<link rel="manifest" href="./manifest.webmanifest" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
@@ -0,0 +1,49 @@
import { isElementInViewport } from '$lib/utils/viewport';
/**
* Svelte action that fades in an element when it enters the viewport.
* Uses IntersectionObserver for efficient viewport detection.
*
* If skipIfVisible is set and the element is already visible in the viewport
* when the action attaches (e.g. a markdown block promoted from unstable
* during streaming), the fade is skipped entirely to avoid a flash.
*/
export function fadeInView(
node: HTMLElement,
options: { duration?: number; y?: number; delay?: number; skipIfVisible?: boolean } = {}
) {
const { duration = 300, y = 0, delay = 0, skipIfVisible = false } = options;
if (skipIfVisible && isElementInViewport(node)) {
return;
}
node.style.opacity = '0';
node.style.transform = `translateY(${y}px)`;
node.style.transition = `opacity ${duration}ms ease-out, transform ${duration}ms ease-out`;
$effect(() => {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setTimeout(() => {
requestAnimationFrame(() => {
node.style.opacity = '1';
node.style.transform = 'translateY(0)';
});
}, delay);
observer.disconnect();
}
}
},
{ threshold: 0.05 }
);
observer.observe(node);
return () => {
observer.disconnect();
};
});
}
+7
View File
@@ -0,0 +1,7 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M244.95 8C215.233 8 187.774 23.8591 172.923 49.5999L95.6009 183.625C60.2162 244.959 104.481 321.6 175.29 321.6H208L316.977 132.708C348.959 77.2719 308.95 8 244.95 8ZM208 321.6H351.947C415.982 321.6 456.013 390.91 424.013 446.377C409.155 472.132 381.681 488 351.947 488H271.29C200.481 488 156.216 411.359 191.601 350.026L208 321.6Z" fill="currentColor"/>
<path d="M208 321.6H16L106.462 164.8L208 321.6Z" fill="currentColor"/>
<path d="M388.923 8L208 321.6L253.6 8H388.923Z" fill="currentColor"/>
<path d="M304 488H112L202.462 331.2L304 488Z" fill="currentColor"/>
<path d="M496 321.6H208L419.399 454.4L496 321.6Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 771 B

+11
View File
@@ -0,0 +1,11 @@
---
name: app
description: Opinionated app components building on top of ./ui primitives
---
- Can include business logic and state management
- Can include data fetching and caching logic
- Should use original spelling for HTML-native events and `camelCase` for custom events
- Props and markup attributes should be listed alphabetically
- Use JS Objects and Arrays for CSS classes and styles when they are dynamic
- Whenever there can be repetition in the component's markup, if it's too small to be decoupled as a separate component — use Svelte 5's `{#snippet}` + `{@render}`
@@ -0,0 +1,81 @@
<script lang="ts">
import { Button, type ButtonVariant, type ButtonSize } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
import type { Component } from 'svelte';
import { TooltipSide } from '$lib/enums';
interface Props {
ariaLabel?: string;
class?: string;
disabled?: boolean;
href?: string;
icon: Component;
iconSize?: string;
onclick?: (e?: MouseEvent) => void;
size?: ButtonSize;
stopPropagationOnClick?: boolean;
tooltip?: string;
variant?: ButtonVariant;
tooltipSide?: TooltipSide;
}
let {
icon,
tooltip,
variant = 'ghost',
href = '',
size = 'sm',
class: className = '',
disabled = false,
iconSize = 'h-3 w-3',
tooltipSide = TooltipSide.TOP,
stopPropagationOnClick = false,
onclick,
ariaLabel
}: Props = $props();
let innerWidth = $state(0);
const showTooltip = $derived(!!tooltip && innerWidth > 768);
</script>
{#snippet button(props = {})}
<Button
{...props}
{href}
{variant}
{size}
{disabled}
onclick={(e: MouseEvent) => {
if (stopPropagationOnClick) e.stopPropagation();
onclick?.(e);
}}
class="h-6 w-6 p-0 {className} flex hover:bg-transparent data-[state=open]:bg-transparent!"
aria-label={ariaLabel || tooltip}
>
{#if icon}
{@const IconComponent = icon}
<IconComponent class={iconSize} />
{/if}
</Button>
{/snippet}
{#if showTooltip}
<Tooltip.Root>
<Tooltip.Trigger>
<!-- prevent another nested button element -->
{#snippet child({ props })}
{@render button(props)}
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side={tooltipSide}>
<p>{tooltip}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
{@render button({ href })}
{/if}
<svelte:window bind:innerWidth />
@@ -0,0 +1,17 @@
<script lang="ts">
import { Copy } from '@lucide/svelte';
import { copyToClipboard } from '$lib/utils';
import ActionIcon from './ActionIcon.svelte';
export let ariaLabel: string = 'Copy to clipboard';
export let canCopy: boolean = true;
export let text: string;
</script>
<ActionIcon
icon={Copy}
tooltip={ariaLabel}
iconSize="h-4 w-4"
disabled={!canCopy}
onclick={() => canCopy && copyToClipboard(text)}
/>
@@ -0,0 +1,13 @@
/**
*
* ACTIONS
*
* Small interactive components for user actions.
*
*/
/** Styled icon button for action triggers with tooltip. */
export { default as ActionIcon } from './ActionIcon.svelte';
/** Copy-to-clipboard icon button with clipboard logic. */
export { default as ActionIconCopyToClipboard } from './ActionIconCopyToClipboard.svelte';
@@ -0,0 +1,26 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
interface Props extends HTMLButtonAttributes {
children: Snippet;
class?: string;
icon?: Snippet;
}
let { children, class: className = '', icon, ...rest }: Props = $props();
</script>
<button
{...rest}
class={[
'inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75',
className
]}
>
{#if icon}
{@render icon()}
{/if}
{@render children()}
</button>
@@ -0,0 +1,36 @@
<script lang="ts">
import { Eye, Mic, Video } from '@lucide/svelte';
import { ModelModality } from '$lib/enums';
interface Props {
modalities: ModelModality[];
class?: string;
}
let { modalities, class: className = '' }: Props = $props();
</script>
{#each modalities as modality (modality)}
{#if modality === ModelModality.VISION || modality === ModelModality.AUDIO || modality === ModelModality.VIDEO}
<span
class={[
'inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium',
className
]}
>
{#if modality === ModelModality.VISION}
<Eye class="h-3 w-3" />
Vision (Image)
{:else if modality === ModelModality.VIDEO}
<Video class="h-3 w-3" />
Vision (Video)
{:else}
<Mic class="h-3 w-3" />
Audio
{/if}
</span>
{/if}
{/each}
@@ -0,0 +1,13 @@
/**
*
* BADGES & INDICATORS
*
* Small visual indicators for status and metadata.
*
*/
/** Generic info badge with optional tooltip and click handler. */
export { default as BadgeInfo } from './BadgeInfo.svelte';
/** Badge indicating model modality (vision, audio, tools). */
export { default as BadgesModality } from './BadgesModality.svelte';
@@ -0,0 +1,119 @@
<script lang="ts">
import {
ChatAttachmentsListItem,
DialogChatAttachmentsPreview,
DialogMcpResourcePreview,
HorizontalScrollCarousel
} from '$lib/components/app';
import type { DatabaseMessageExtraMcpResource } from '$lib/types';
import { getAttachmentDisplayItems, isMcpPrompt, isMcpResource } from '$lib/utils';
interface Props {
class?: string;
style?: string;
// For ChatMessage - stored attachments
attachments?: DatabaseMessageExtra[];
readonly?: boolean;
// For ChatForm - pending uploads
onFileRemove?: (fileId: string) => void;
uploadedFiles?: ChatUploadedFile[];
// Image size customization
imageClass?: string;
imageHeight?: string;
imageWidth?: string;
// Limit display to single row with "+ X more" button
limitToSingleRow?: boolean;
// For vision modality check
activeModelId?: string;
}
let {
class: className = '',
style = '',
attachments = [],
readonly = false,
onFileRemove,
uploadedFiles = $bindable([]),
// Default to small size for form previews
imageClass = '',
imageHeight = 'h-24',
imageWidth = 'w-auto',
limitToSingleRow = false,
activeModelId
}: Props = $props();
let carouselRef: HorizontalScrollCarousel | undefined = $state();
let mcpResourcePreviewOpen = $state(false);
let mcpResourcePreviewExtra = $state<DatabaseMessageExtraMcpResource | null>(null);
let previewFocusIndex = $state(0);
let viewAllDialogOpen = $state(false);
let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
function openPreview(item: ChatAttachmentDisplayItem, event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
// Find the index of the clicked item among non-MCP attachments
const nonMcpItems = displayItems.filter((i) => !isMcpPrompt(i) && !isMcpResource(i));
const index = nonMcpItems.findIndex((i) => i.id === item.id);
previewFocusIndex = index >= 0 ? index : 0;
viewAllDialogOpen = true;
}
function openMcpResourcePreview(extra: DatabaseMessageExtraMcpResource) {
mcpResourcePreviewExtra = extra;
mcpResourcePreviewOpen = true;
}
$effect(() => {
if (carouselRef && displayItems.length) {
carouselRef.resetScroll();
}
});
</script>
{#snippet attachmentitem(item: ChatAttachmentDisplayItem)}
<ChatAttachmentsListItem
{imageClass}
{imageHeight}
{imageWidth}
{item}
{limitToSingleRow}
{onFileRemove}
onMcpResourcePreview={openMcpResourcePreview}
onPreview={(i: ChatAttachmentDisplayItem, event?: MouseEvent) => openPreview(i, event)}
{readonly}
/>
{/snippet}
{#if displayItems.length > 0}
<div class={className} {style}>
{#if limitToSingleRow}
<HorizontalScrollCarousel bind:this={carouselRef}>
{#each displayItems as item (item.id)}
{@render attachmentitem(item)}
{/each}
</HorizontalScrollCarousel>
{:else}
<div class="flex flex-wrap items-start justify-end gap-3">
{#each displayItems as item (item.id)}
{@render attachmentitem(item)}
{/each}
</div>
{/if}
</div>
{/if}
<DialogChatAttachmentsPreview
{activeModelId}
{attachments}
bind:open={viewAllDialogOpen}
{previewFocusIndex}
{uploadedFiles}
/>
{#if mcpResourcePreviewExtra}
<DialogMcpResourcePreview extra={mcpResourcePreviewExtra} bind:open={mcpResourcePreviewOpen} />
{/if}
@@ -0,0 +1,132 @@
<script lang="ts">
import {
ChatAttachmentsListItemMcpPrompt,
ChatAttachmentsListItemMcpResource,
ChatAttachmentsListItemThumbnailImage,
ChatAttachmentsListItemThumbnailFile
} from '$lib/components/app';
import { AttachmentType } from '$lib/enums';
import type {
ChatAttachmentDisplayItem,
DatabaseMessageExtraMcpPrompt,
DatabaseMessageExtraMcpResource,
MCPResourceAttachment
} from '$lib/types';
import { isMcpPrompt, isMcpResource, isPdfFile } from '$lib/utils';
interface Props {
class?: string;
imageClass?: string;
imageHeight?: string;
imageWidth?: string;
item: ChatAttachmentDisplayItem;
limitToSingleRow?: boolean;
onFileRemove?: (fileId: string) => void;
onMcpResourcePreview?: (extra: DatabaseMessageExtraMcpResource) => void;
onPreview?: (item: ChatAttachmentDisplayItem) => void;
readonly?: boolean;
}
let {
class: className = '',
imageClass = '',
imageHeight = 'h-24',
imageWidth = 'w-auto',
item,
limitToSingleRow = false,
onFileRemove,
onMcpResourcePreview,
onPreview,
readonly = false
}: Props = $props();
const scrollClasses = $derived(limitToSingleRow ? 'first:ml-4 last:mr-4' : '');
function toMcpResourceAttachment(
extra: DatabaseMessageExtraMcpResource,
id: string
): MCPResourceAttachment {
return {
id,
resource: {
uri: extra.uri,
name: extra.name,
title: extra.name,
serverName: extra.serverName
}
};
}
</script>
{#if isMcpPrompt(item)}
{@const mcpPrompt =
item.attachment?.type === AttachmentType.MCP_PROMPT
? (item.attachment as DatabaseMessageExtraMcpPrompt)
: item.uploadedFile?.mcpPrompt
? {
type: AttachmentType.MCP_PROMPT as const,
name: item.name,
serverName: item.uploadedFile.mcpPrompt.serverName,
promptName: item.uploadedFile.mcpPrompt.promptName,
content: item.textContent ?? '',
arguments: item.uploadedFile.mcpPrompt.arguments
}
: null}
{#if mcpPrompt}
<ChatAttachmentsListItemMcpPrompt
class="max-w-[300px] min-w-[200px] flex-shrink-0 {className} {scrollClasses}"
prompt={mcpPrompt}
{readonly}
isLoading={item.isLoading}
loadError={item.loadError}
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
/>
{/if}
{:else if isMcpResource(item)}
{@const mcpResource = item.attachment as DatabaseMessageExtraMcpResource}
<ChatAttachmentsListItemMcpResource
class="flex-shrink-0 {className} {scrollClasses}"
attachment={toMcpResourceAttachment(mcpResource, item.id)}
onclick={() => onMcpResourcePreview?.(mcpResource)}
/>
{:else if item.isImage && item.preview}
<ChatAttachmentsListItemThumbnailImage
class="flex-shrink-0 cursor-pointer {className} {scrollClasses}"
id={item.id}
name={item.name}
preview={item.preview}
{readonly}
onRemove={onFileRemove}
height={imageHeight}
width={imageWidth}
{imageClass}
onclick={() => onPreview?.(item)}
/>
{:else if isPdfFile(item.attachment, item.uploadedFile)}
<ChatAttachmentsListItemThumbnailFile
class="flex-shrink-0 cursor-pointer {className} {scrollClasses}"
id={item.id}
name={item.name}
size={item.size}
{readonly}
onRemove={onFileRemove}
textContent={item.textContent}
attachment={item.attachment}
uploadedFile={item.uploadedFile}
onclick={() => onPreview?.(item)}
/>
{:else}
<ChatAttachmentsListItemThumbnailFile
class="flex-shrink-0 cursor-pointer {className} {scrollClasses}"
id={item.id}
name={item.name}
size={item.size}
{readonly}
onRemove={onFileRemove}
textContent={item.textContent}
attachment={item.attachment}
uploadedFile={item.uploadedFile}
onclick={() => onPreview?.(item)}
/>
{/if}
@@ -0,0 +1,41 @@
<script lang="ts">
import { ChatMessageMcpPromptContent, ActionIcon } from '$lib/components/app';
import { X } from '@lucide/svelte';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
import { McpPromptVariant } from '$lib/enums';
interface Props {
class?: string;
isLoading?: boolean;
loadError?: string;
onRemove?: () => void;
prompt: DatabaseMessageExtraMcpPrompt;
readonly?: boolean;
}
let {
class: className = '',
isLoading = false,
loadError,
onRemove,
prompt,
readonly = false
}: Props = $props();
</script>
<div class="group relative {className}">
<ChatMessageMcpPromptContent
{isLoading}
{loadError}
{prompt}
variant={McpPromptVariant.ATTACHMENT}
/>
{#if !readonly && onRemove}
<div
class="absolute top-10 right-2 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
>
<ActionIcon icon={X} tooltip="Remove" stopPropagationOnClick onclick={() => onRemove?.()} />
</div>
{/if}
</div>
@@ -0,0 +1,89 @@
<script lang="ts">
import { Loader2, AlertCircle } from '@lucide/svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import type { MCPResourceAttachment } from '$lib/types';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ActionIcon } from '$lib/components/app';
import { X } from '@lucide/svelte';
import { getResourceIcon, getResourceDisplayName } from '$lib/utils';
interface Props {
attachment: MCPResourceAttachment;
class?: string;
onclick?: () => void;
onRemove?: (attachmentId: string) => void;
}
let { attachment, class: className, onclick, onRemove }: Props = $props();
const ResourceIcon = $derived(
getResourceIcon(attachment.resource.mimeType, attachment.resource.uri)
);
const serverName = $derived(mcpStore.getServerDisplayName(attachment.resource.serverName));
const favicon = $derived(mcpStore.getServerFavicon(attachment.resource.serverName));
function getStatusClass(attachment: MCPResourceAttachment): string {
if (attachment.error) return 'border-red-500/50 bg-red-500/10';
if (attachment.loading) return 'border-border/50 bg-muted/30';
return 'border-border/50 bg-muted/30';
}
</script>
<Tooltip.Root>
<Tooltip.Trigger>
<button
class={[
'flex flex-shrink-0 items-center gap-1.5 rounded-md border px-2 py-0.75 text-sm transition-colors',
getStatusClass(attachment),
onclick && 'cursor-pointer hover:bg-muted/50',
className
]}
disabled={!onclick}
{onclick}
type="button"
>
{#if attachment.loading}
<Loader2 class="h-3 w-3 animate-spin text-muted-foreground" />
{:else if attachment.error}
<AlertCircle class="h-3 w-3 text-red-500" />
{:else}
<ResourceIcon class="h-3 w-3 text-muted-foreground" />
{/if}
<span class="max-w-[150px] truncate text-xs">
{getResourceDisplayName(attachment.resource)}
</span>
{#if onRemove}
<ActionIcon
class="-my-2 -mr-1.5 bg-transparent"
icon={X}
iconSize="h-2 w-2"
onclick={() => onRemove?.(attachment.id)}
stopPropagationOnClick
tooltip="Remove"
/>
{/if}
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<div class="flex items-center gap-1 text-xs">
{#if favicon}
<img
alt={attachment.resource.serverName}
class="h-3 w-3 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={favicon}
/>
{/if}
<span class="truncate">
{serverName}
</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
@@ -0,0 +1,186 @@
<script lang="ts">
import { X, Music, Video } from '@lucide/svelte';
import {
formatFileSize,
getFileTypeLabel,
getPreviewText,
isPdfFile,
isAudioFile,
isVideoFile,
isTextFile
} from '$lib/utils';
import { ActionIcon } from '$lib/components/app';
import { AttachmentType } from '$lib/enums';
interface Props {
attachment?: DatabaseMessageExtra;
class?: string;
id: string;
onclick?: (event: MouseEvent) => void;
onRemove?: (id: string) => void;
name: string;
readonly?: boolean;
size?: number;
textContent?: string;
// Either uploaded file or stored attachment
uploadedFile?: ChatUploadedFile;
}
let {
attachment,
class: className = '',
id,
onclick,
onRemove,
name,
readonly = false,
size,
textContent,
uploadedFile
}: Props = $props();
let isPdf = $derived(isPdfFile(attachment, uploadedFile));
let isAudio = $derived(isAudioFile(attachment, uploadedFile));
let isVideo = $derived(isVideoFile(attachment, uploadedFile));
let isPdfWithContent = $derived(isPdf && !!textContent);
let isText = $derived(isTextFile(attachment, uploadedFile));
let isTextWithContent = $derived(isText && !!textContent);
let fileTypeLabel = $derived.by(() => {
if (uploadedFile?.type) {
return getFileTypeLabel(uploadedFile.type);
}
if (attachment) {
if ('mimeType' in attachment && attachment.mimeType) {
return getFileTypeLabel(attachment.mimeType);
}
if (attachment.type) {
return getFileTypeLabel(attachment.type);
}
}
return getFileTypeLabel(name);
});
let pdfProcessingMode = $derived.by(() => {
if (attachment?.type === AttachmentType.PDF) {
const pdfAttachment = attachment as DatabaseMessageExtraPdfFile;
return pdfAttachment.processedAsImages ? 'Sent as Image' : 'Sent as Text';
}
return null;
});
</script>
{#snippet textPreview(content: string)}
<div class="relative">
<div
class="font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground {!readonly
? 'max-h-3rem line-height-1.2'
: ''}"
>
{getPreviewText(content)}
</div>
{#if content.length > 150}
<div
class="pointer-events-none absolute right-0 bottom-0 left-0 h-4 bg-gradient-to-t from-muted to-transparent {readonly
? 'h-6'
: ''}"
></div>
{/if}
</div>
{/snippet}
{#snippet removeButton()}
<div
class="absolute top-2 right-2 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100"
>
<ActionIcon icon={X} tooltip="Remove" stopPropagationOnClick onclick={() => onRemove?.(id)} />
</div>
{/snippet}
{#snippet fileIcon()}
<div
class="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-xs font-medium text-primary"
>
{#if isAudio}
<Music class="h-4 w-4 text-white/70" />
{:else if isVideo}
<Video class="h-4 w-4 text-white/70" />
{:else}
{fileTypeLabel}
{/if}
</div>
{/snippet}
{#snippet info(text: string | undefined)}
{#if text}
<span class="text-xs text-muted-foreground">{text}</span>
{/if}
{/snippet}
{#if isTextWithContent || isPdfWithContent}
<button
aria-label={readonly ? `Preview ${name}` : undefined}
class="rounded-lg border border-border bg-muted p-3 {className} cursor-pointer {readonly
? 'w-full max-w-2xl transition-shadow hover:shadow-md'
: `group relative text-left ${textContent ? 'max-h-24 max-w-72' : 'max-w-36'}`} overflow-hidden"
{onclick}
type="button"
>
{#if !readonly}
{@render removeButton()}
{/if}
<div class={[!readonly && 'pr-8', 'overflow-hidden']}>
{#if readonly}
<div class="flex items-start gap-3">
<div class="flex min-w-0 flex-1 flex-col items-start text-left">
<span class="w-full truncate text-sm font-medium text-foreground">{name}</span>
{@render info(pdfProcessingMode || (size ? formatFileSize(size) : undefined))}
{#if textContent}
{@render textPreview(textContent)}
{/if}
</div>
</div>
{:else}
<span class="mb-3 block truncate text-sm font-medium text-foreground">{name}</span>
{#if textContent}
{@render textPreview(textContent)}
{/if}
{/if}
</div>
</button>
{:else}
<button
class="group flex items-center gap-3 rounded-lg border border-border bg-muted p-3 {className} relative"
{onclick}
type="button"
>
{@render fileIcon()}
<div class="flex flex-col items-start gap-0.5">
<span
class="max-w-24 truncate text-sm font-medium text-foreground {readonly
? ''
: 'group-hover:pr-6'} md:max-w-32"
>
{name}
</span>
{@render info(pdfProcessingMode || (size ? formatFileSize(size) : undefined))}
</div>
{#if !readonly}
{@render removeButton()}
{/if}
</button>
{/if}
@@ -0,0 +1,65 @@
<script lang="ts">
import { ActionIcon } from '$lib/components/app';
import { X } from '@lucide/svelte';
interface Props {
class?: string;
height?: string;
id: string;
imageClass?: string;
onclick?: (event?: MouseEvent) => void;
onRemove?: (id: string) => void;
name: string;
preview: string;
readonly?: boolean;
width?: string;
}
let {
class: className = '',
height = 'h-16',
id,
imageClass = '',
onclick,
onRemove,
name,
preview,
readonly = false,
width = 'w-auto'
}: Props = $props();
</script>
{#snippet image()}
<img src={preview} alt={name} class="{height} {width} cursor-pointer object-cover {imageClass}" />
{/snippet}
<div
class="group relative overflow-hidden rounded-lg bg-muted shadow-lg dark:border dark:border-muted {className}"
>
{#if onclick}
<button
aria-label="Preview {name}"
class="block h-full w-full rounded-lg focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:outline-none"
{onclick}
type="button"
>
{@render image()}
</button>
{:else}
{@render image()}
{/if}
{#if !readonly}
<div
class="absolute top-1 right-1 flex items-center justify-center opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100"
>
<ActionIcon
class="text-white"
icon={X}
onclick={() => onRemove?.(id)}
stopPropagationOnClick
tooltip="Remove"
/>
</div>
{/if}
</div>
@@ -0,0 +1,212 @@
<script lang="ts">
import {
ChatAttachmentsPreviewCurrentItem,
ChatAttachmentsPreviewFileInfo,
ChatAttachmentsPreviewNavButtons,
ChatAttachmentsPreviewThumbnailStrip
} from '$lib/components/app';
import { modelsStore } from '$lib/stores/models.svelte';
import {
createBase64DataUrl,
formatFileSize,
getAttachmentDisplayItems,
getLanguageFromFilename,
isAudioFile,
isVideoFile,
isImageFile,
isMcpPrompt,
isMcpResource,
isPdfFile,
isTextFile
} from '$lib/utils';
interface PreviewItem {
id: string;
name: string;
size?: number;
preview?: string;
uploadedFile?: ChatUploadedFile;
attachment?: DatabaseMessageExtra;
textContent?: string;
isImage: boolean;
isAudio: boolean;
isVideo: boolean;
}
interface Props {
uploadedFiles?: ChatUploadedFile[];
attachments?: DatabaseMessageExtra[];
activeModelId?: string;
class?: string;
previewFocusIndex?: number;
}
let {
uploadedFiles = [],
attachments = [],
activeModelId,
class: className = '',
previewFocusIndex = 0
}: Props = $props();
let allItems = $derived(
getAttachmentDisplayItems({ uploadedFiles, attachments })
.filter((item) => !isMcpPrompt(item) && !isMcpResource(item))
.map(
(item): PreviewItem => ({
...item,
isImage: isImageFile(item.attachment, item.uploadedFile),
isAudio: isAudioFile(item.attachment, item.uploadedFile),
isVideo: isVideoFile(item.attachment, item.uploadedFile)
})
)
);
let currentIndex = $state(0);
$effect(() => {
if (previewFocusIndex >= 0 && previewFocusIndex < allItems.length) {
currentIndex = previewFocusIndex;
}
});
$effect(() => {
const handler = (e: Event) => {
const delta = (e as CustomEvent).detail;
if (delta < 0) {
currentIndex = currentIndex > 0 ? currentIndex - 1 : allItems.length - 1;
} else {
currentIndex = currentIndex < allItems.length - 1 ? currentIndex + 1 : 0;
}
};
document.addEventListener('chat-attachments-nav', handler);
return () => document.removeEventListener('chat-attachments-nav', handler);
});
$effect(() => {
const index = currentIndex;
setTimeout(() => {
const thumbnail = document.querySelector(`[data-thumbnail-index="${index}"]`);
thumbnail?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
}, 0);
});
let currentItem = $derived(allItems[currentIndex] ?? null);
let displayName = $derived(
currentItem?.name ||
currentItem?.uploadedFile?.name ||
currentItem?.attachment?.name ||
'Unknown File'
);
let isAudio = $derived(
currentItem ? isAudioFile(currentItem.attachment, currentItem.uploadedFile) : false
);
let isVideo = $derived(
currentItem ? isVideoFile(currentItem.attachment, currentItem.uploadedFile) : false
);
let isImage = $derived(
currentItem ? isImageFile(currentItem.attachment, currentItem.uploadedFile) : false
);
let isPdf = $derived(
currentItem ? isPdfFile(currentItem.attachment, currentItem.uploadedFile) : false
);
let isText = $derived(
currentItem ? isTextFile(currentItem.attachment, currentItem.uploadedFile) : false
);
let displayPreview = $derived(
currentItem?.uploadedFile?.preview ||
(isImage && currentItem?.attachment && 'base64Url' in currentItem.attachment
? currentItem.attachment.base64Url
: currentItem?.preview)
);
let displayTextContent = $derived(
currentItem?.uploadedFile?.textContent ||
(currentItem?.attachment && 'content' in currentItem.attachment
? currentItem.attachment.content
: currentItem?.textContent)
);
let language = $derived(getLanguageFromFilename(displayName));
let fileSize = $derived(currentItem?.size ? formatFileSize(currentItem.size) : '');
let hasVisionModality = $derived(
currentItem && activeModelId ? modelsStore.modelSupportsVision(activeModelId) : false
);
let audioSrc = $derived(
isAudio && currentItem
? (currentItem.uploadedFile?.preview ??
(currentItem.attachment &&
'mimeType' in currentItem.attachment &&
'base64Data' in currentItem.attachment
? createBase64DataUrl(
currentItem.attachment.mimeType,
currentItem.attachment.base64Data
)
: null))
: null
);
let videoSrc = $derived(
isVideo && currentItem
? (currentItem.uploadedFile?.preview ??
(currentItem.attachment &&
'mimeType' in currentItem.attachment &&
'base64Data' in currentItem.attachment
? createBase64DataUrl(
currentItem.attachment.mimeType,
currentItem.attachment.base64Data
)
: null))
: null
);
export function prev() {
currentIndex = currentIndex > 0 ? currentIndex - 1 : allItems.length - 1;
}
export function next() {
currentIndex = currentIndex < allItems.length - 1 ? currentIndex + 1 : 0;
}
function onNavigate(index: number) {
currentIndex = index;
}
</script>
<div class="{className} flex flex-col text-white">
<div class="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden">
<ChatAttachmentsPreviewNavButtons onPrev={prev} onNext={next} show={allItems.length > 1} />
<div class="flex h-full w-full flex-col items-center justify-start overflow-auto py-4">
{#if currentItem}
<ChatAttachmentsPreviewFileInfo {displayName} {fileSize} />
<ChatAttachmentsPreviewCurrentItem
{currentItem}
{isImage}
{isAudio}
{isVideo}
{isPdf}
{isText}
{displayPreview}
{displayTextContent}
{audioSrc}
{videoSrc}
{language}
{hasVisionModality}
{activeModelId}
/>
{/if}
<ChatAttachmentsPreviewThumbnailStrip items={allItems} {currentIndex} {onNavigate} />
</div>
</div>
</div>
@@ -0,0 +1,74 @@
<script lang="ts">
import type { ChatAttachmentDisplayItem } from '$lib/types';
import { Image, Music, Video, FileText, FileIcon } from '@lucide/svelte';
import ChatAttachmentsPreviewCurrentItemPdf from './ChatAttachmentsPreviewCurrentItemPdf.svelte';
import ChatAttachmentsPreviewCurrentItemImage from './ChatAttachmentsPreviewCurrentItemImage.svelte';
import ChatAttachmentsPreviewCurrentItemAudio from './ChatAttachmentsPreviewCurrentItemAudio.svelte';
import ChatAttachmentsPreviewCurrentItemVideo from './ChatAttachmentsPreviewCurrentItemVideo.svelte';
import ChatAttachmentsPreviewCurrentItemText from './ChatAttachmentsPreviewCurrentItemText.svelte';
import ChatAttachmentsPreviewCurrentItemUnavailable from './ChatAttachmentsPreviewCurrentItemUnavailable.svelte';
interface Props {
currentItem: ChatAttachmentDisplayItem | null;
isImage: boolean;
isAudio: boolean;
isVideo: boolean;
isPdf: boolean;
isText: boolean;
displayPreview: string | undefined;
displayTextContent: string | undefined;
audioSrc: string | null;
videoSrc: string | null;
language: string;
hasVisionModality: boolean;
activeModelId?: string;
}
let {
currentItem,
isImage,
isAudio,
isVideo,
isPdf,
isText,
displayPreview,
displayTextContent,
audioSrc,
videoSrc,
language,
hasVisionModality,
activeModelId
}: Props = $props();
let IconComponent = $derived(
isImage ? Image : isText || isPdf ? FileText : isAudio ? Music : isVideo ? Video : FileIcon
);
let isUnavailable = $derived(
!isPdf && !isImage && !(isText && displayTextContent) && !isAudio && !isVideo
);
</script>
{#if currentItem}
{#key currentItem.id}
{#if isPdf}
<ChatAttachmentsPreviewCurrentItemPdf
{currentItem}
displayName={currentItem.name}
{displayTextContent}
{hasVisionModality}
{activeModelId}
/>
{:else if isImage}
<ChatAttachmentsPreviewCurrentItemImage {currentItem} {displayPreview} />
{:else if isText && displayTextContent}
<ChatAttachmentsPreviewCurrentItemText {displayTextContent} {language} />
{:else if isAudio}
<ChatAttachmentsPreviewCurrentItemAudio {currentItem} {audioSrc} />
{:else if isVideo}
<ChatAttachmentsPreviewCurrentItemVideo {currentItem} {videoSrc} />
{:else if isUnavailable}
<ChatAttachmentsPreviewCurrentItemUnavailable {IconComponent} />
{/if}
{/key}
{/if}
@@ -0,0 +1,26 @@
<script lang="ts">
import { Music } from '@lucide/svelte';
interface Props {
currentItem: { name?: string } | null;
audioSrc: string | null;
}
let { currentItem, audioSrc }: Props = $props();
</script>
<div class="flex flex-1 items-center justify-center p-8">
<div class="w-full max-w-md text-center">
<Music class="mx-auto mb-4 h-16 w-16 text-white/50" />
{#if audioSrc}
<audio controls class="mb-4 w-full" src={audioSrc}>
Your browser does not support the audio element.
</audio>
{:else}
<p class="mb-4 text-white/70">Audio preview not available</p>
{/if}
<p class="text-sm text-white/50">{currentItem?.name || 'Audio'}</p>
</div>
</div>
@@ -0,0 +1,18 @@
<script lang="ts">
interface Props {
currentItem: { name?: string } | null;
displayPreview: string | undefined;
}
let { currentItem, displayPreview }: Props = $props();
</script>
{#if displayPreview}
<div class="flex flex-1 items-center justify-center">
<img
src={displayPreview}
alt={currentItem?.name || 'preview'}
class="max-h-[80vh] max-w-[80vw] rounded-lg object-contain shadow-lg"
/>
</div>
{/if}
@@ -0,0 +1,174 @@
<script lang="ts">
import type { ChatAttachmentDisplayItem } from '$lib/types';
import { FileText, Eye, Info } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Alert from '$lib/components/ui/alert';
import { SyntaxHighlightedCode } from '$lib/components/app';
import { getLanguageFromFilename } from '$lib/utils';
import { convertPDFToImage } from '$lib/utils/browser-only';
import { PdfViewMode } from '$lib/enums';
interface Props {
currentItem: ChatAttachmentDisplayItem | null;
displayName: string;
displayTextContent: string | undefined;
hasVisionModality: boolean;
activeModelId?: string;
}
let { currentItem, displayName, displayTextContent, hasVisionModality, activeModelId }: Props =
$props();
let pdfViewMode = $state<PdfViewMode>(PdfViewMode.PAGES);
let pdfImages = $state<string[]>([]);
let pdfImagesLoading = $state(false);
let pdfImagesError = $state<string | null>(null);
let language = $derived(getLanguageFromFilename(displayName));
async function loadPdfImages() {
if (pdfImages.length > 0 || pdfImagesLoading || !currentItem) return;
pdfImagesLoading = true;
pdfImagesError = null;
try {
let file: File | null = null;
if (currentItem.uploadedFile?.file) {
file = currentItem.uploadedFile.file;
} else if (currentItem.attachment) {
// Check if we have pre-processed images
if (
'images' in currentItem.attachment &&
currentItem.attachment.images &&
Array.isArray(currentItem.attachment.images) &&
currentItem.attachment.images.length > 0
) {
pdfImages = currentItem.attachment.images;
return;
}
// Convert base64 back to File for processing
if ('base64Data' in currentItem.attachment && currentItem.attachment.base64Data) {
const base64Data = currentItem.attachment.base64Data;
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
file = new File([byteArray], displayName, { type: 'application/pdf' });
}
}
if (file) {
pdfImages = await convertPDFToImage(file);
} else {
throw new Error('No PDF file available for conversion');
}
} catch (error) {
pdfImagesError = error instanceof Error ? error.message : 'Failed to load PDF images';
} finally {
pdfImagesLoading = false;
}
}
$effect(() => {
if (pdfViewMode === PdfViewMode.PAGES) {
loadPdfImages();
}
});
</script>
<div class="mb-4 flex items-center justify-end gap-2">
<Button
variant={pdfViewMode === PdfViewMode.TEXT ? 'default' : 'outline'}
size="sm"
onclick={() => (pdfViewMode = PdfViewMode.TEXT)}
disabled={pdfImagesLoading}
>
<FileText class="mr-1 h-4 w-4" />
Text
</Button>
<Button
variant={pdfViewMode === PdfViewMode.PAGES ? 'default' : 'outline'}
size="sm"
onclick={() => (pdfViewMode = PdfViewMode.PAGES)}
disabled={pdfImagesLoading}
>
{#if pdfImagesLoading}
<div
class="mr-1 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
></div>
{:else}
<Eye class="mr-1 h-4 w-4" />
{/if}
Pages
</Button>
</div>
{#if !hasVisionModality && activeModelId && currentItem}
<Alert.Root class="mb-4 max-w-4xl">
<Info class="h-4 w-4" />
<Alert.Title>Preview only</Alert.Title>
<Alert.Description>
<span class="inline-flex">
The selected model does not support vision. Only the extracted
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="mx-1 cursor-pointer underline"
onclick={() => (pdfViewMode = PdfViewMode.TEXT)}
>
text
</span>
will be sent to the model.
</span>
</Alert.Description>
</Alert.Root>
{/if}
{#if pdfImagesLoading}
<div class="flex flex-1 items-center justify-center p-8">
<div class="text-center">
<div
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-white border-t-transparent"
></div>
<p class="text-white/70">Converting PDF to images...</p>
</div>
</div>
{:else if pdfImagesError}
<div class="flex flex-1 items-center justify-center p-8">
<div class="text-center">
<FileText class="mx-auto mb-4 h-16 w-16 text-white/50" />
<p class="mb-4 text-white/70">Failed to load PDF images</p>
<p class="text-sm text-white/50">{pdfImagesError}</p>
</div>
</div>
{:else if pdfImages.length > 0}
{#each pdfImages as image, index (image)}
<p class="mb-2 text-sm text-white/50">Page {index + 1}</p>
<img src={image} alt="PDF Page {index + 1}" class="mx-auto max-w-[85vw] rounded-lg shadow-lg" />
<div class="h-4"></div>
{/each}
{:else}
<div class="flex flex-1 items-center justify-center p-8">
<div class="text-center">
<FileText class="mx-auto mb-4 h-16 w-16 text-white/50" />
<p class="text-white/70">No PDF pages available</p>
</div>
</div>
{/if}
{#if pdfViewMode === PdfViewMode.TEXT && displayTextContent}
<div class="px-4 pb-4">
<SyntaxHighlightedCode
class="max-w-4xl"
code={displayTextContent}
{language}
maxHeight="none"
/>
</div>
{/if}
@@ -0,0 +1,21 @@
<script lang="ts">
import { SyntaxHighlightedCode } from '$lib/components/app';
interface Props {
displayTextContent: string | undefined;
language: string;
}
let { displayTextContent, language }: Props = $props();
</script>
{#if displayTextContent}
<div class="px-4 pb-4">
<SyntaxHighlightedCode
class="max-w-4xl"
code={displayTextContent}
{language}
maxHeight="none"
/>
</div>
{/if}
@@ -0,0 +1,17 @@
<script lang="ts">
import type { Component } from 'svelte';
interface Props {
IconComponent: Component;
}
let { IconComponent }: Props = $props();
</script>
<div class="flex flex-1 items-center justify-center p-8">
<div class="text-center">
<IconComponent class="mx-auto mb-4 h-16 w-16 text-white/50" />
<p class="text-white/70">Preview not available for this file type</p>
</div>
</div>
@@ -0,0 +1,27 @@
<script lang="ts">
import { Video } from '@lucide/svelte';
interface Props {
currentItem: { name?: string } | null;
videoSrc: string | null;
}
let { currentItem, videoSrc }: Props = $props();
</script>
<div class="flex flex-1 items-center justify-center p-8">
<div class="w-full max-w-md text-center">
<Video class="mx-auto mb-4 h-16 w-16 text-white/50" />
{#if videoSrc}
<video controls class="mb-4 w-full" src={videoSrc}>
<track kind="captions" src="" />
Your browser does not support the video element.
</video>
{:else}
<p class="mb-4 text-white/70">Video preview not available</p>
{/if}
<p class="text-sm text-white/50">{currentItem?.name || 'Video'}</p>
</div>
</div>
@@ -0,0 +1,16 @@
<script lang="ts">
interface Props {
displayName: string;
fileSize: string;
}
let { displayName, fileSize }: Props = $props();
</script>
<div class="sticky top-0 z-[20] mb-4 rounded-lg bg-black/5 px-4 py-2 text-center backdrop-blur-md">
<p class="font-medium text-white">{displayName}</p>
{#if fileSize}
<p class="text-xs text-white/60">{fileSize}</p>
{/if}
</div>
@@ -0,0 +1,34 @@
<script lang="ts">
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
interface Props {
onPrev: () => void;
onNext: () => void;
show: boolean;
}
let { onPrev, onNext, show }: Props = $props();
</script>
{#if show}
<Button
variant="secondary"
size="icon"
class="absolute top-1/2 left-4 z-10 h-8 w-8 -translate-y-1/2 rounded-full bg-background/5 p-0 text-white!"
onclick={onPrev}
aria-label="Previous"
>
<ChevronLeft class="size-4" />
</Button>
<Button
variant="secondary"
size="icon"
class="absolute top-1/2 right-4 z-10 h-8 w-8 -translate-y-1/2 rounded-full bg-background/5 p-0 text-white!"
onclick={onNext}
aria-label="Next"
>
<ChevronRight class="size-4" />
</Button>
{/if}
@@ -0,0 +1,66 @@
<script lang="ts">
import { Music, Video, FileText } from '@lucide/svelte';
import { HorizontalScrollCarousel } from '$lib/components/app/misc';
interface PreviewItem {
id: string;
name: string;
isImage: boolean;
isAudio: boolean;
isVideo: boolean;
preview?: string;
}
interface Props {
items: PreviewItem[];
currentIndex: number;
onNavigate: (index: number) => void;
}
let { items, currentIndex, onNavigate }: Props = $props();
function getFileExtension(name: string): string {
const parts = name.split('.');
if (parts.length > 1) {
return parts.pop()?.toUpperCase() ?? '';
}
return '';
}
</script>
{#if items.length > 1}
<div class="sticky bottom-0 z-10 mt-4 flex-shrink-0">
<HorizontalScrollCarousel class="max-w-full">
{#each items as item, index (item.id)}
<button
data-thumbnail-index={index}
class={[
'relative flex-shrink-0 cursor-pointer overflow-hidden rounded border-2 bg-black/80 backdrop-blur-sm transition-all hover:opacity-90',
index === currentIndex ? 'border-white' : 'border-transparent opacity-60',
'[&:not(:first-child)]:last:mr-4 [&:not(:last-child)]:first:ml-4'
]}
onclick={() => onNavigate(index)}
aria-label={`Go to ${item.name}`}
>
{#if item.isImage && item.preview}
<img src={item.preview} alt={item.name} class="h-12 w-12 object-cover" />
{:else}
<div
class="bg-foreground-muted/50 flex h-12 w-12 flex-col items-center justify-center gap-0.5 py-1"
>
{#if item.isAudio}
<Music class="h-4 w-4 text-white/70" />
{:else if item.isVideo}
<Video class="h-4 w-4 text-white/70" />
{:else}
<FileText class="h-4 w-4 text-white/70" />
{/if}
<span class="font-mono text-[9px] text-white/60">{getFileExtension(item.name)}</span>
</div>
{/if}
</button>
{/each}
</HorizontalScrollCarousel>
</div>
{/if}
@@ -0,0 +1,572 @@
<script lang="ts">
import {
ChatAttachmentsList,
ChatFormActions,
ChatFormFileInputInvisible,
ChatFormMcpResourcesList,
ChatFormPickers,
ChatFormTextarea,
DialogMcpResourcesBrowser
} from '$lib/components/app';
import {
CLIPBOARD_CONTENT_QUOTE_PREFIX,
INPUT_CLASSES,
SETTING_CONFIG_DEFAULT,
INITIAL_FILE_SIZE,
PROMPT_CONTENT_SEPARATOR,
PROMPT_TRIGGER_PREFIX,
RESOURCE_TRIGGER_PREFIX
} from '$lib/constants';
import {
ContentPartType,
FileExtensionText,
KeyboardKey,
MimeTypeText,
SpecialFileType
} from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { mcpHasResourceAttachments } from '$lib/stores/mcp-resources.svelte';
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
import type { GetPromptResult, MCPPromptInfo, MCPResourceInfo, PromptMessage } from '$lib/types';
import { isIMEComposing, parseClipboardContent, uuid } from '$lib/utils';
import {
AudioRecorder,
convertToWav,
createAudioFile,
isAudioRecordingSupported
} from '$lib/utils/browser-only';
import { onMount } from 'svelte';
interface Props {
// Data
attachments?: DatabaseMessageExtra[];
uploadedFiles?: ChatUploadedFile[];
value?: string;
// UI State
class?: string;
disabled?: boolean;
isLoading?: boolean;
placeholder?: string;
showMcpPromptButton?: boolean;
showAddButton?: boolean;
showModelSelector?: boolean;
// Event Handlers
onAttachmentRemove?: (index: number) => void;
onFilesAdd?: (files: File[]) => void;
onStop?: () => void;
onSubmit?: () => void;
onSystemPromptClick?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
onUploadedFileRemove?: (fileId: string) => void;
onUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
onValueChange?: (value: string) => void;
}
let {
attachments = [],
class: className = '',
disabled = false,
isLoading = false,
placeholder = 'Type a message...',
showMcpPromptButton = false,
showAddButton = true,
showModelSelector = true,
uploadedFiles = $bindable([]),
value = $bindable(''),
onAttachmentRemove,
onFilesAdd,
onStop,
onSubmit,
onSystemPromptClick,
onUploadedFileRemove,
onUploadedFilesChange,
onValueChange
}: Props = $props();
// Component References
let audioRecorder: AudioRecorder | undefined;
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
let pickersRef: { handleKeydown: (event: KeyboardEvent) => boolean } | undefined =
$state(undefined);
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
// Audio Recording State
let isRecording = $state(false);
let recordingSupported = $state(false);
// Picker State
let isPromptPickerOpen = $state(false);
let promptSearchQuery = $state('');
let isInlineResourcePickerOpen = $state(false);
let resourceSearchQuery = $state('');
// Resource Dialog State
let isResourceDialogOpen = $state(false);
let preSelectedResourceUri = $state<string | undefined>(undefined);
let currentConfig = $derived(config());
let pasteLongTextToFileLength = $derived.by(() => {
const n = Number(currentConfig.pasteLongTextToFileLen);
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
});
let isRouter = $derived(isRouterMode());
let conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
let activeModelId = $derived.by(() => {
const options = modelOptions();
if (!isRouter) {
return options.length > 0 ? options[0].model : null;
}
const selectedId = selectedModelId();
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
if (model) return model.model;
}
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
if (model) return model.model;
}
return null;
});
let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
let hasAttachments = $derived(
(attachments && attachments.length > 0) || (uploadedFiles && uploadedFiles.length > 0)
);
let canSubmit = $derived(value.trim().length > 0 || hasAttachments);
onMount(() => {
recordingSupported = isAudioRecordingSupported();
audioRecorder = new AudioRecorder();
});
export function focus() {
textareaRef?.focus();
}
export function resetTextareaHeight() {
textareaRef?.resetHeight();
}
export function openModelSelector() {
chatFormActionsRef?.openModelSelector();
}
export function checkModelSelected(): boolean {
if (!hasModelSelected) {
chatFormActionsRef?.openModelSelector();
return false;
}
return true;
}
function handleFileSelect(files: File[]) {
onFilesAdd?.(files);
}
function handleFileUpload() {
fileInputRef?.click();
}
function handleFileRemove(fileId: string) {
if (fileId.startsWith('attachment-')) {
const index = parseInt(fileId.replace('attachment-', ''), 10);
if (!isNaN(index) && index >= 0 && index < attachments.length) {
onAttachmentRemove?.(index);
}
} else {
onUploadedFileRemove?.(fileId);
}
}
function handleInput() {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const hasServers = mcpStore.hasEnabledServers(perChatOverrides);
if (value.startsWith(PROMPT_TRIGGER_PREFIX) && hasServers) {
isPromptPickerOpen = true;
promptSearchQuery = value.slice(1);
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
} else if (
value.startsWith(RESOURCE_TRIGGER_PREFIX) &&
hasServers &&
mcpStore.hasResourcesCapability(perChatOverrides)
) {
isInlineResourcePickerOpen = true;
resourceSearchQuery = value.slice(1);
isPromptPickerOpen = false;
promptSearchQuery = '';
} else {
isPromptPickerOpen = false;
promptSearchQuery = '';
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
}
}
function handleKeydown(event: KeyboardEvent) {
if (pickersRef?.handleKeydown(event)) {
return;
}
if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) {
isPromptPickerOpen = false;
promptSearchQuery = '';
return;
}
if (event.key === KeyboardKey.ESCAPE && isInlineResourcePickerOpen) {
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
return;
}
if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
const isModifier = event.ctrlKey || event.metaKey;
const sendOnEnter = currentConfig.sendOnEnter !== false;
if (sendOnEnter || isModifier) {
event.preventDefault();
if (!canSubmit || disabled || hasLoadingAttachments) return;
onSubmit?.();
}
}
}
function handlePaste(event: ClipboardEvent) {
if (!event.clipboardData) return;
const files = Array.from(event.clipboardData.items)
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null);
if (files.length > 0) {
event.preventDefault();
onFilesAdd?.(files);
return;
}
const text = event.clipboardData.getData(MimeTypeText.PLAIN);
if (text.startsWith(CLIPBOARD_CONTENT_QUOTE_PREFIX)) {
const parsed = parseClipboardContent(text);
if (parsed.textAttachments.length > 0 || parsed.mcpPromptAttachments.length > 0) {
event.preventDefault();
value = parsed.message;
onValueChange?.(parsed.message);
// Handle text attachments as files
if (parsed.textAttachments.length > 0) {
const attachmentFiles = parsed.textAttachments.map(
(att) =>
new File([att.content], att.name, {
type: MimeTypeText.PLAIN
})
);
onFilesAdd?.(attachmentFiles);
}
// Handle MCP prompt attachments as ChatUploadedFile with mcpPrompt data
if (parsed.mcpPromptAttachments.length > 0) {
const mcpPromptFiles: ChatUploadedFile[] = parsed.mcpPromptAttachments.map((att) => ({
id: uuid(),
name: att.name,
size: att.content.length,
type: SpecialFileType.MCP_PROMPT,
file: new File([att.content], `${att.name}${FileExtensionText.TXT}`, {
type: MimeTypeText.PLAIN
}),
isLoading: false,
textContent: att.content,
mcpPrompt: {
serverName: att.serverName,
promptName: att.promptName,
arguments: att.arguments
}
}));
uploadedFiles = [...uploadedFiles, ...mcpPromptFiles];
onUploadedFilesChange?.(uploadedFiles);
}
setTimeout(() => {
textareaRef?.focus();
}, 10);
return;
}
}
if (
text.length > 0 &&
pasteLongTextToFileLength > 0 &&
text.length > pasteLongTextToFileLength
) {
event.preventDefault();
const textFile = new File([text], 'Pasted', {
type: MimeTypeText.PLAIN
});
onFilesAdd?.([textFile]);
}
}
function handlePromptLoadStart(
placeholderId: string,
promptInfo: MCPPromptInfo,
args?: Record<string, string>
) {
// Only clear the value if the prompt was triggered by typing '/'
if (value.startsWith(PROMPT_TRIGGER_PREFIX)) {
value = '';
onValueChange?.('');
}
isPromptPickerOpen = false;
promptSearchQuery = '';
const promptName = promptInfo.title || promptInfo.name;
const placeholder: ChatUploadedFile = {
id: placeholderId,
name: promptName,
size: INITIAL_FILE_SIZE,
type: SpecialFileType.MCP_PROMPT,
file: new File([], 'loading'),
isLoading: true,
mcpPrompt: {
serverName: promptInfo.serverName,
promptName: promptInfo.name,
arguments: args ? { ...args } : undefined
}
};
uploadedFiles = [...uploadedFiles, placeholder];
onUploadedFilesChange?.(uploadedFiles);
textareaRef?.focus();
}
function handlePromptLoadComplete(placeholderId: string, result: GetPromptResult) {
const promptText = result.messages
?.map((msg: PromptMessage) => {
if (typeof msg.content === 'string') {
return msg.content;
}
if (msg.content.type === ContentPartType.TEXT) {
return msg.content.text;
}
return '';
})
.filter(Boolean)
.join(PROMPT_CONTENT_SEPARATOR);
uploadedFiles = uploadedFiles.map((f) =>
f.id === placeholderId
? {
...f,
isLoading: false,
textContent: promptText,
size: promptText.length,
file: new File([promptText], `${f.name}${FileExtensionText.TXT}`, {
type: MimeTypeText.PLAIN
})
}
: f
);
onUploadedFilesChange?.(uploadedFiles);
}
function handlePromptLoadError(placeholderId: string, error: string) {
uploadedFiles = uploadedFiles.map((f) =>
f.id === placeholderId ? { ...f, isLoading: false, loadError: error } : f
);
onUploadedFilesChange?.(uploadedFiles);
}
function handlePromptPickerClose() {
isPromptPickerOpen = false;
promptSearchQuery = '';
textareaRef?.focus();
}
function handleInlineResourcePickerClose() {
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
textareaRef?.focus();
}
function handleInlineResourceSelect() {
if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
value = '';
onValueChange?.('');
}
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
textareaRef?.focus();
}
function handleBrowseResources() {
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
value = '';
onValueChange?.('');
}
isResourceDialogOpen = true;
}
async function handleMicClick() {
if (!audioRecorder || !recordingSupported) {
console.warn('Audio recording not supported');
return;
}
if (isRecording) {
isRecording = false;
try {
const audioBlob = await audioRecorder.stopRecording();
const wavBlob = await convertToWav(audioBlob);
const audioFile = createAudioFile(wavBlob);
onFilesAdd?.([audioFile]);
} catch (error) {
console.error('Failed to stop recording:', error);
}
} else {
try {
await audioRecorder.startRecording();
isRecording = true;
} catch (error) {
console.error('Failed to start recording:', error);
}
}
}
</script>
<ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
<form
class="relative {className}"
onsubmit={(event) => {
event.preventDefault();
if (!canSubmit || disabled || hasLoadingAttachments) return;
onSubmit?.();
}}
>
<ChatFormPickers
bind:this={pickersRef}
{isPromptPickerOpen}
{promptSearchQuery}
{isInlineResourcePickerOpen}
{resourceSearchQuery}
onPromptPickerClose={handlePromptPickerClose}
onInlineResourcePickerClose={handleInlineResourcePickerClose}
onInlineResourceSelect={handleInlineResourceSelect}
onPromptLoadStart={handlePromptLoadStart}
onPromptLoadComplete={handlePromptLoadComplete}
onPromptLoadError={handlePromptLoadError}
onInlineResourceBrowse={handleBrowseResources}
/>
<div
class="{INPUT_CLASSES} overflow-hidden rounded-4xl md:rounded-3xl backdrop-blur-md {disabled
? 'cursor-not-allowed opacity-60'
: ''}"
data-slot="input-area"
>
<ChatAttachmentsList
{attachments}
bind:uploadedFiles
onFileRemove={handleFileRemove}
limitToSingleRow
class="py-5"
style="scroll-padding: 1rem;"
activeModelId={activeModelId ?? undefined}
/>
<div
class="flex-column relative min-h-12 items-center rounded-4xl md:rounded-3xl py-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:py-3!"
onpaste={handlePaste}
>
<ChatFormTextarea
class="px-5 py-1.5 md:pt-0"
bind:this={textareaRef}
bind:value
onKeydown={handleKeydown}
onInput={() => {
handleInput();
onValueChange?.(value);
}}
{disabled}
{placeholder}
/>
{#if mcpHasResourceAttachments()}
<ChatFormMcpResourcesList
class="mb-3"
onResourceClick={(uri) => {
preSelectedResourceUri = uri;
isResourceDialogOpen = true;
}}
/>
{/if}
<ChatFormActions
class="px-3"
bind:this={chatFormActionsRef}
canSend={canSubmit}
{disabled}
{isLoading}
isReasoning={chatStore.isReasoning}
{isRecording}
{showAddButton}
{showModelSelector}
{uploadedFiles}
onFileUpload={handleFileUpload}
onMicClick={handleMicClick}
{onStop}
onSystemPromptClick={() => onSystemPromptClick?.({ message: value, files: uploadedFiles })}
onMcpPromptClick={showMcpPromptButton ? () => (isPromptPickerOpen = true) : undefined}
onMcpResourcesClick={() => (isResourceDialogOpen = true)}
/>
</div>
</div>
</form>
<DialogMcpResourcesBrowser
bind:open={isResourceDialogOpen}
preSelectedUri={preSelectedResourceUri}
onAttach={(resource: MCPResourceInfo) => {
mcpStore.attachResource(resource.uri);
}}
onOpenChange={(newOpen: boolean) => {
if (!newOpen) {
preSelectedResourceUri = undefined;
}
}}
/>
@@ -0,0 +1,33 @@
<script lang="ts">
import { Plus } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ATTACHMENT_TOOLTIP_TEXT } from '$lib/constants';
interface Props {
disabled?: boolean;
onclick?: (e: MouseEvent) => void;
}
let { disabled = false, onclick }: Props = $props();
</script>
<Tooltip.Root>
<Tooltip.Trigger class="w-full">
<Button
class="file-upload-button md:h-8 md:w-8 h-9 w-9 rounded-full p-0"
{disabled}
{onclick}
variant="secondary"
type="button"
>
<span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
<Plus class="h-4 w-4" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{ATTACHMENT_TOOLTIP_TEXT}</p>
</Tooltip.Content>
</Tooltip.Root>
@@ -0,0 +1,184 @@
<script lang="ts">
import { Plus, File, MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { buttonVariants } from '$lib/components/ui/button';
import { cn } from '$lib/components/ui/utils';
import {
ATTACHMENT_FILE_ITEMS,
ATTACHMENT_TOOLTIP_TEXT,
TOOLTIP_DELAY_DURATION
} from '$lib/constants';
import {
ChatFormActionAddToolsSubmenu,
ChatFormActionAddMcpServersSubmenu,
ChatFormActionAddReasoningSubmenu
} from '$lib/components/app';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
interface Props {
class?: string;
disabled?: boolean;
hasAudioModality?: boolean;
hasVideoModality?: boolean;
hasVisionModality?: boolean;
hasMcpPromptsSupport?: boolean;
hasMcpResourcesSupport?: boolean;
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpSettingsClick?: () => void;
onMcpResourcesClick?: () => void;
}
let {
class: className = '',
disabled = false,
hasAudioModality = false,
hasVideoModality = false,
hasVisionModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
onFileUpload,
onSystemPromptClick,
onMcpPromptClick,
onMcpSettingsClick,
onMcpResourcesClick
}: Props = $props();
let dropdownOpen = $state(false);
function handleMcpSettingsClick() {
dropdownOpen = false;
onMcpSettingsClick?.();
}
const attachmentMenu = useAttachmentMenu(
() => ({
hasVisionModality,
hasAudioModality,
hasVideoModality,
hasMcpPromptsSupport,
hasMcpResourcesSupport
}),
() => ({ onFileUpload, onSystemPromptClick, onMcpPromptClick, onMcpResourcesClick }),
() => {
dropdownOpen = false;
}
);
</script>
<div class="flex items-center gap-1 {className}">
<DropdownMenu.Root bind:open={dropdownOpen}>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<DropdownMenu.Trigger
{...props}
class={cn(
buttonVariants({ variant: 'secondary' }),
'file-upload-button h-8 w-8 cursor-pointer rounded-full p-0'
)}
{disabled}
>
<span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
<Plus class="h-4 w-4" />
</DropdownMenu.Trigger>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{ATTACHMENT_TOOLTIP_TEXT}</p>
</Tooltip.Content>
</Tooltip.Root>
<DropdownMenu.Content align="start" class="w-52">
<ChatFormActionAddReasoningSubmenu />
<DropdownMenu.Separator />
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<File class="h-4 w-4" />
<span>Add files</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-48">
{#each ATTACHMENT_FILE_ITEMS as item (item.id)}
{@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)}
{#if enabled}
<DropdownMenu.Item
class="{item.class ?? ''} flex cursor-pointer items-center gap-2"
onclick={() => attachmentMenu.callbacks[item.action]()}
>
<item.icon class="h-4 w-4" />
<span>{item.label}</span>
</DropdownMenu.Item>
{:else if item.disabledTooltip}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger tabindex={-1}>
{#snippet child({ props })}
<div {...props} class="cursor-default">
<DropdownMenu.Item
class="{item.class ?? ''} flex items-center gap-2"
disabled
>
<item.icon class="h-4 w-4" />
<span>{item.label}</span>
</DropdownMenu.Item>
</div>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>{item.disabledTooltip}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={onSystemPromptClick}
>
<MessageSquare class="h-4 w-4" />
<span>System Message</span>
</DropdownMenu.Item>
<ChatFormActionAddToolsSubmenu />
<ChatFormActionAddMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} />
{#if hasMcpPromptsSupport}
<DropdownMenu.Separator />
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={onMcpPromptClick}
>
<Zap class="h-4 w-4" />
<span>MCP Prompt</span>
</DropdownMenu.Item>
{/if}
{#if hasMcpResourcesSupport}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={onMcpResourcesClick}
>
<FolderOpen class="h-4 w-4" />
<span>MCP Resources</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
@@ -0,0 +1,150 @@
<script lang="ts">
import { Settings, Plus } from '@lucide/svelte';
import { Switch } from '$lib/components/ui/switch';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { McpLogo, DropdownMenuSearchable, McpServerIdentity } from '$lib/components/app';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { HealthCheckStatus } from '$lib/enums';
import type { MCPServerSettingsEntry } from '$lib/types';
import { goto } from '$app/navigation';
import { ROUTES } from '$lib/constants/routes';
interface Props {
onMcpSettingsClick?: () => void;
}
let { onMcpSettingsClick }: Props = $props();
let mcpSearchQuery = $state('');
let allMcpServers = $derived(mcpStore.getServersSorted());
let mcpServers = $derived(mcpStore.visibleMcpServers);
let hasMcpServers = $derived(mcpServers.length > 0);
// let hasAnyMcpServers = $derived(allMcpServers.length > 0);
let filteredMcpServers = $derived.by(() => {
const query = mcpSearchQuery.toLowerCase().trim();
if (!query) return mcpServers;
return mcpServers.filter((s) => {
const name = getServerLabel(s).toLowerCase();
const url = s.url.toLowerCase();
return name.includes(query) || url.includes(query);
});
});
function getServerLabel(server: MCPServerSettingsEntry): string {
return mcpStore.getServerLabel(server);
}
function isServerEnabledForChat(serverId: string): boolean {
return conversationsStore.isMcpServerEnabledForChat(serverId);
}
async function toggleServerForChat(serverId: string) {
await conversationsStore.toggleMcpServerForChat(serverId);
}
function handleMcpSubMenuOpen(open: boolean) {
if (open) {
mcpSearchQuery = '';
mcpStore.runHealthChecksForServers(allMcpServers);
}
}
function handleMcpSettingsClick() {
onMcpSettingsClick?.();
goto(`${hasMcpServers ? '' : '?add'}${ROUTES.MCP_SERVERS}`);
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Sub onOpenChange={handleMcpSubMenuOpen}>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<McpLogo class="h-4 w-4" />
<span>MCP Servers</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-72 pt-0">
{#if hasMcpServers}
<DropdownMenuSearchable
placeholder="Search servers..."
bind:searchValue={mcpSearchQuery}
emptyMessage="No servers found"
isEmpty={filteredMcpServers.length === 0}
>
<div class="max-h-64 overflow-y-auto">
{#each filteredMcpServers as server (server.id)}
{@const healthState = mcpStore.getHealthCheckState(server.id)}
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
{@const isEnabledForChat = isServerEnabledForChat(server.id)}
{@const displayName = getServerLabel(server)}
{@const faviconUrl = mcpStore.getServerFavicon(server.id)}
<button
type="button"
class="flex w-full items-center justify-between gap-2 rounded-sm px-2 py-2 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => !hasError && toggleServerForChat(server.id)}
disabled={hasError}
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<div class="min-w-0 flex-1">
<McpServerIdentity
{displayName}
{faviconUrl}
iconClass="h-4 w-4"
iconRounded="rounded-sm"
showVersion={false}
nameClass="text-sm"
/>
</div>
{#if hasError}
<span
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
>
Error
</span>
{/if}
</div>
<Switch
checked={isEnabledForChat}
disabled={hasError}
onclick={(e) => e.stopPropagation()}
onCheckedChange={() => toggleServerForChat(server.id)}
/>
</button>
{/each}
</div>
{#snippet footer()}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpSettingsClick}
>
<Settings class="h-4 w-4" />
<span>Manage MCP Servers</span>
</DropdownMenu.Item>
{/snippet}
</DropdownMenuSearchable>
{:else}
<div class="px-2 py-3 text-center text-sm text-muted-foreground">
No MCP servers configured
</div>
<DropdownMenu.Separator />
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpSettingsClick}
>
<Plus class="h-4 w-4" />
<span>Add MCP Servers</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Root>
@@ -0,0 +1,128 @@
<script lang="ts">
import { Lightbulb, LightbulbOff, Check, Info } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ReasoningEffort } from '$lib/enums';
import { REASONING_EFFORT_TOKENS } from '$lib/constants/reasoning-effort-tokens';
import { REASONING_EFFORT_LEVELS } from '$lib/constants/reasoning-effort';
import type { ReasoningEffortLevel } from '$lib/types';
import {
modelsStore,
checkModelSupportsThinking,
supportsThinking,
propsCacheVersion,
loadedModelIds
} from '$lib/stores/models.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import type { DatabaseMessage } from '$lib/types/database';
let subOpen = $state(false);
let conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
let modelSupportsThinkingFromMessages = $derived.by(() => {
const modelId = isRouterMode() ? modelsStore.selectedModelName || conversationModel : null;
if (!modelId) return false;
const messages = conversationsStore.activeMessages;
return messages.some(
(m) => m.role === 'assistant' && m.model === modelId && !!m.reasoningContent
);
});
let modelSupportsThinking = $derived.by(() => {
loadedModelIds();
propsCacheVersion();
if (isRouterMode()) {
const modelId = modelsStore.selectedModelName || conversationModel;
return checkModelSupportsThinking(modelId ?? '') || modelSupportsThinkingFromMessages;
}
return supportsThinking() || modelSupportsThinkingFromMessages;
});
let thinkingEnabled = $derived(conversationsStore.getThinkingEnabled());
let currentEffort = $derived(conversationsStore.getReasoningEffort());
let isOff = $derived(!thinkingEnabled);
function isSelected(item: ReasoningEffortLevel): boolean {
if (item.isOff) return isOff;
return thinkingEnabled && currentEffort === item.value;
}
function handleSelection(item: ReasoningEffortLevel) {
if (item.isOff) {
conversationsStore.setThinkingEnabled(false);
} else {
conversationsStore.setThinkingEnabled(true);
conversationsStore.setReasoningEffort(item.value as ReasoningEffort);
}
subOpen = false;
}
</script>
{#if modelSupportsThinking}
<DropdownMenu.Sub bind:open={subOpen}>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
{#if thinkingEnabled}
<Lightbulb class="h-4 w-4 shrink-0 text-amber-400" />
{:else}
<LightbulbOff class="h-4 w-4 shrink-0 text-muted-foreground" />
{/if}
<span class="text-sm inline-flex gap-2 {!thinkingEnabled ? 'text-muted-foreground' : ''}">
Reasoning
<span class="capitalize text-muted-foreground">
{thinkingEnabled ? currentEffort : 'off'}
</span>
</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
>
{#each REASONING_EFFORT_LEVELS as level (level.value)}
<button
type="button"
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent"
class:bg-accent={isSelected(level)}
onclick={() => handleSelection(level)}
>
{#if isSelected(level)}
<Check class="h-4 w-4 shrink-0 text-foreground" />
{:else}
<div class="h-4 w-4 shrink-0"></div>
{/if}
<span class="flex-1">{level.label}</span>
{#if !level.isOff}
<span class="text-[11px] text-muted-foreground opacity-60">
{REASONING_EFFORT_TOKENS[level.value] === -1
? 'Unlimited'
: `Max ${REASONING_EFFORT_TOKENS[level.value].toLocaleString()} tokens`}
</span>
{/if}
{#if level.hasInfo}
<Tooltip.Root>
<Tooltip.Trigger>
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="left">
<p>Maximum reasoning effort with extended context usage</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</button>
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{/if}
@@ -0,0 +1,308 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import * as Sheet from '$lib/components/ui/sheet';
import * as Collapsible from '$lib/components/ui/collapsible';
import { File, MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
import { Switch } from '$lib/components/ui/switch';
import { Checkbox } from '$lib/components/ui/checkbox';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants';
import { ATTACHMENT_FILE_ITEMS } from '$lib/constants/attachment-menu';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { McpLogo } from '$lib/components/app';
import { PencilRuler, ChevronDown, ChevronRight } from '@lucide/svelte';
import { HealthCheckStatus } from '$lib/enums';
import { AttachmentAction } from '$lib/enums/attachment.enums';
interface Props {
class?: string;
disabled?: boolean;
hasAudioModality?: boolean;
hasVideoModality?: boolean;
hasVisionModality?: boolean;
hasMcpPromptsSupport?: boolean;
hasMcpResourcesSupport?: boolean;
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpResourcesClick?: () => void;
trigger: Snippet<[{ disabled: boolean; onclick?: () => void }]>;
}
let {
class: className = '',
disabled = false,
hasAudioModality = false,
hasVisionModality = false,
hasVideoModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
onFileUpload,
onSystemPromptClick,
onMcpPromptClick,
onMcpResourcesClick,
trigger
}: Props = $props();
let sheetOpen = $state(false);
let filesExpanded = $state(true);
let toolsExpanded = $state(false);
let mcpExpanded = $state(false);
const attachmentMenu = useAttachmentMenu(
() => ({
hasVisionModality,
hasAudioModality,
hasVideoModality,
hasMcpPromptsSupport,
hasMcpResourcesSupport
}),
() => ({ onFileUpload, onSystemPromptClick, onMcpPromptClick, onMcpResourcesClick }),
() => {
sheetOpen = false;
}
);
const toolsPanel = useToolsPanel();
const sheetItemClass =
'flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent active:bg-accent disabled:cursor-not-allowed disabled:opacity-50';
const sheetItemRowClass =
'flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent';
let visibleMcpServers = $derived(mcpStore.visibleMcpServers);
</script>
<div class="flex items-center gap-1 {className}">
<Sheet.Root bind:open={sheetOpen}>
{@render trigger({ disabled, onclick: () => (sheetOpen = true) })}
<Sheet.Content side="bottom" class="max-h-[85vh] gap-0 overflow-y-auto">
<Sheet.Header>
<Sheet.Title>Add to chat</Sheet.Title>
<Sheet.Description class="sr-only">
Add files, system prompt or configure MCP servers
</Sheet.Description>
</Sheet.Header>
<div class="flex flex-col gap-1 px-1.5 pb-2">
<Collapsible.Root open={filesExpanded} onOpenChange={(open) => (filesExpanded = open)}>
<Collapsible.Trigger class={sheetItemClass}>
{#if filesExpanded}
<ChevronDown class="h-4 w-4 shrink-0" />
{:else}
<ChevronRight class="h-4 w-4 shrink-0" />
{/if}
<File class="h-4 w-4 shrink-0" />
<span class="flex-1">Add files</span>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="flex flex-col gap-0.5 pl-4">
{#each ATTACHMENT_FILE_ITEMS as item (item.id)}
{@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)}
{#if enabled}
<button
type="button"
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[item.action]()}
>
<item.icon class="h-4 w-4 shrink-0" />
<span>{item.label}</span>
</button>
{:else if item.disabledTooltip}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger>
<button type="button" class={sheetItemClass} disabled>
<item.icon class="h-4 w-4 shrink-0" />
<span>{item.label}</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>{item.disabledTooltip}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
<Collapsible.Root open={mcpExpanded} onOpenChange={(open) => (mcpExpanded = open)}>
<Collapsible.Trigger class={sheetItemClass}>
{#if mcpExpanded}
<ChevronDown class="h-4 w-4 shrink-0" />
{:else}
<ChevronRight class="h-4 w-4 shrink-0" />
{/if}
<McpLogo class="inline h-4 w-4 shrink-0" />
<span class="flex-1">MCP Servers</span>
<span class="text-xs text-muted-foreground">
{visibleMcpServers.length} server{visibleMcpServers.length !== 1 ? 's' : ''}
</span>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="flex flex-col gap-0.5 pl-4">
{#each visibleMcpServers as server (server.id)}
{@const healthState = mcpStore.getHealthCheckState(server.id)}
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
{@const displayName = mcpStore.getServerLabel(server)}
{@const faviconUrl = mcpStore.getServerFavicon(server.id)}
{@const isEnabled = conversationsStore.isMcpServerEnabledForChat(server.id)}
<button
type="button"
class={sheetItemRowClass}
onclick={() => !hasError && conversationsStore.toggleMcpServerForChat(server.id)}
disabled={hasError}
>
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if faviconUrl}
<img
src={faviconUrl}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span class="min-w-0 truncate text-sm">{displayName}</span>
</div>
{#if hasError}
<span
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
>
Error
</span>
{:else}
<Switch
checked={isEnabled}
onCheckedChange={() => conversationsStore.toggleMcpServerForChat(server.id)}
/>
{/if}
</button>
{/each}
{#if visibleMcpServers.length === 0}
<div class="px-3 py-2 text-center text-sm text-muted-foreground">
No MCP servers configured
</div>
{/if}
</div>
</Collapsible.Content>
</Collapsible.Root>
{#if toolsPanel.totalToolCount > 0}
<Collapsible.Root open={toolsExpanded} onOpenChange={(open) => (toolsExpanded = open)}>
<Collapsible.Trigger class={sheetItemClass}>
{#if toolsExpanded}
<ChevronDown class="h-4 w-4 shrink-0" />
{:else}
<ChevronRight class="h-4 w-4 shrink-0" />
{/if}
<PencilRuler class="inline h-4 w-4 shrink-0" />
<span class="flex-1">Tools</span>
<span class="text-xs text-muted-foreground">
{toolsPanel.totalToolCount} tool{toolsPanel.totalToolCount !== 1 ? 's' : ''}
</span>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="flex flex-col gap-0.5 pl-4">
{#each toolsPanel.activeGroups as group (group.label)}
{@const checked = toolsPanel.isGroupChecked(group)}
{@const enabledCount = toolsPanel.getEnabledToolCount(group)}
{@const favicon = toolsPanel.getFavicon(group)}
<button
type="button"
class={sheetItemRowClass}
onclick={() => toolsPanel.toggleGroupByLabel(group.label)}
>
{#if favicon}
<img
src={favicon}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span class="min-w-0 flex-1 truncate text-sm font-medium">{group.label}</span>
<span class="shrink-0 text-xs text-muted-foreground">
{enabledCount}/{group.tools.length}
</span>
<Checkbox
{checked}
class="h-4 w-4 shrink-0"
onclick={(e) => e.stopPropagation()}
onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
/>
</button>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/if}
<button
type="button"
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[AttachmentAction.SYSTEM_PROMPT_CLICK]()}
>
<MessageSquare class="h-4 w-4 shrink-0" />
<span>System Message</span>
</button>
{#if hasMcpPromptsSupport}
<button
type="button"
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[AttachmentAction.MCP_PROMPT_CLICK]()}
>
<Zap class="h-4 w-4 shrink-0" />
<span>MCP Prompt</span>
</button>
{/if}
{#if hasMcpResourcesSupport}
<button
type="button"
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[AttachmentAction.MCP_RESOURCES_CLICK]()}
>
<FolderOpen class="h-4 w-4 shrink-0" />
<span>MCP Resources</span>
</button>
{/if}
</div>
</Sheet.Content>
</Sheet.Root>
</div>
@@ -0,0 +1,157 @@
<script lang="ts">
import { PencilRuler, ChevronDown, ChevronRight, Loader2, Info, Check } from '@lucide/svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { toolsStore } from '$lib/stores/tools.svelte';
import { CLI_FLAGS } from '$lib/constants';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
const toolsPanel = useToolsPanel();
const hasMcpServersAvailable = $derived(mcpStore.getServersSorted().length > 0);
</script>
<DropdownMenu.Sub onOpenChange={(open) => open && toolsPanel.handleOpen()}>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<PencilRuler class="h-4 w-4" />
<span>Tools</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-72 p-0">
{#if toolsPanel.totalToolCount === 0}
{#if toolsStore.loading}
<div class="px-3 py-4 text-center text-sm text-muted-foreground">
<Loader2 class="mx-auto mb-1 h-4 w-4 animate-spin" />
Loading tools...
</div>
{:else if toolsStore.isToolsEndpointUnreachable}
<div class="grid gap-2.5 px-3 py-4 text-sm text-muted-foreground">
<span class="flex gap-2">
<Info class="mt-0.5 h-4 w-4 shrink-0" />
<span>
Run llama-server with <code>{CLI_FLAGS.TOOLS}</code> flag to enable
<strong>Built-in Tools</strong>.
</span>
</span>
<span class="flex gap-2">
<Info class="mt-0.5 h-4 w-4 shrink-0" />
<span>
{hasMcpServersAvailable ? 'Enable' : 'Add'} MCP Server(s) to access
<strong>MCP Tools</strong>.
</span>
</span>
</div>
{:else if toolsStore.error}
<div class="px-3 py-4 text-center text-sm text-muted-foreground">Failed to load tools</div>
{:else if toolsPanel.noToolsInfoMessage}
<div class="flex gap-2 px-3 py-4 text-sm text-muted-foreground">
<Info class="mt-0.5 h-4 w-4 shrink-0" />
<span>{toolsPanel.noToolsInfoMessage}</span>
</div>
{:else}
<div class="px-3 py-4 text-center text-sm text-muted-foreground">No tools available</div>
{/if}
{:else}
<div class="max-h-80 overflow-y-auto p-2 pr-1">
{#each toolsPanel.activeGroups as group (group.label)}
{@const isExpanded = toolsPanel.expandedGroups.has(group.label)}
{@const checked = toolsPanel.isGroupChecked(group)}
{@const favicon = toolsPanel.getFavicon(group)}
<Collapsible.Root
open={isExpanded}
onOpenChange={() => toolsPanel.toggleGroupExpanded(group.label)}
>
<div class="flex items-center gap-1">
<Collapsible.Trigger
class="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5 shrink-0" />
{:else}
<ChevronRight class="h-3.5 w-3.5 shrink-0" />
{/if}
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
{#if favicon}
<img
src={favicon}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span class="truncate">{group.label}</span>
</span>
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
{toolsPanel.getEnabledToolCount(group)}/{group.tools.length}
</span>
</Collapsible.Trigger>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Checkbox
{...props}
{checked}
onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
class="mr-2 h-4 w-4 shrink-0"
/>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>
{checked ? 'Disable' : 'Enable'}
{group.tools.length} tool{group.tools.length !== 1 ? 's' : ''}
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Collapsible.Content>
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
{#each group.tools as entry (entry.key)}
{@const enabled = toolsStore.isToolEnabled(entry.key)}
<button
type="button"
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted/50"
onclick={() => toolsStore.toggleTool(entry.key)}
>
<span
data-slot="checkbox"
data-state={enabled ? 'checked' : 'unchecked'}
class="flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
>
{#if enabled}
<Check class="size-3.5" />
{/if}
</span>
<span class="min-w-0 flex-1 truncate font-mono text-[12px]">
{entry.definition.function.name}
</span>
</button>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/each}
</div>
{/if}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
@@ -0,0 +1,67 @@
<script lang="ts">
import { isMobile } from '$lib/stores/viewport.svelte';
import ChatFormActionAddDropdown from './ChatFormActionAddDropdown.svelte';
import ChatFormActionAddSheet from './ChatFormActionAddSheet.svelte';
import ChatFormActionAddButton from './ChatFormActionAddButton.svelte';
interface Props {
disabled?: boolean;
hasAudioModality?: boolean;
hasVideoModality?: boolean;
hasMcpPromptsSupport?: boolean;
hasMcpResourcesSupport?: boolean;
hasVisionModality?: boolean;
onFileUpload?: () => void;
onMcpPromptClick?: () => void;
onMcpResourcesClick?: () => void;
onMcpSettingsClick?: () => void;
onSystemPromptClick?: () => void;
}
let {
disabled = false,
hasAudioModality = false,
hasVideoModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
hasVisionModality = false,
onFileUpload,
onMcpPromptClick,
onMcpResourcesClick,
onMcpSettingsClick,
onSystemPromptClick
}: Props = $props();
</script>
{#if isMobile.current}
<ChatFormActionAddSheet
{disabled}
{hasAudioModality}
{hasVideoModality}
{hasVisionModality}
{hasMcpPromptsSupport}
{hasMcpResourcesSupport}
{onFileUpload}
{onSystemPromptClick}
{onMcpPromptClick}
{onMcpResourcesClick}
>
{#snippet trigger({ disabled, onclick })}
<ChatFormActionAddButton {disabled} {onclick} />
{/snippet}
</ChatFormActionAddSheet>
{:else}
<ChatFormActionAddDropdown
{disabled}
{hasAudioModality}
{hasVideoModality}
{hasVisionModality}
{hasMcpPromptsSupport}
{hasMcpResourcesSupport}
{onFileUpload}
{onMcpPromptClick}
{onMcpResourcesClick}
{onMcpSettingsClick}
{onSystemPromptClick}
/>
{/if}
@@ -0,0 +1,193 @@
<script lang="ts">
import { chatStore } from '$lib/stores/chat.svelte';
import {
modelsStore,
modelOptions,
selectedModelId,
selectedModelName
} from '$lib/stores/models.svelte';
import { isRouterMode, serverError } from '$lib/stores/server.svelte';
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
import { isMobile } from '$lib/stores/viewport.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte';
interface Props {
disabled?: boolean;
forceForegroundText?: boolean;
hasAudioModality?: boolean;
hasVideoModality?: boolean;
hasVisionModality?: boolean;
hasModelSelected?: boolean;
isSelectedModelInCache?: boolean;
submitTooltip?: string;
useGlobalSelection?: boolean;
}
let {
disabled = false,
forceForegroundText = false,
hasAudioModality = $bindable(false),
hasVideoModality = $bindable(false),
hasVisionModality = $bindable(false),
hasModelSelected = $bindable(false),
isSelectedModelInCache = $bindable(true),
submitTooltip = $bindable(''),
useGlobalSelection = false
}: Props = $props();
let isRouter = $derived(isRouterMode());
let isOffline = $derived(!!serverError());
let conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
let lastSyncedConversationModel: string | null = null;
let selectorModel = $derived.by(() => {
const storeModel = selectedModelName();
if (storeModel && storeModel !== conversationModel) {
return storeModel;
}
if (conversationModel) {
return conversationModel;
}
return null;
});
$effect(() => {
if (conversationModel && conversationModel !== lastSyncedConversationModel) {
if (modelOptions().some((m) => m.model === conversationModel)) {
modelsStore.selectedModelName = conversationModel;
modelsStore.selectModelByName(conversationModel);
} else {
modelsStore.selectedModelName = null;
modelsStore.clearSelection();
}
lastSyncedConversationModel = conversationModel;
} else if (
isRouter &&
!modelsStore.selectedModelId &&
modelsStore.loadedModelIds.length > 0 &&
activeMessages().length > 0 &&
!conversationModel
) {
lastSyncedConversationModel = null;
const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
if (first) modelsStore.selectModelById(first.id);
}
});
let activeModelId = $derived.by(() => {
const options = modelOptions();
if (!isRouter) {
return options.length > 0 ? options[0].model : null;
}
const selectedId = selectedModelId();
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
if (model) return model.model;
}
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
if (model) return model.model;
}
return null;
});
let modelPropsVersion = $state(0); // Used to trigger reactivity after fetch
$effect(() => {
if (activeModelId) {
const cached = modelsStore.getModelProps(activeModelId);
if (!cached) {
modelsStore.fetchModelProps(activeModelId).then(() => {
modelPropsVersion++;
});
}
}
});
$effect(() => {
void modelPropsVersion;
hasAudioModality = activeModelId ? modelsStore.modelSupportsAudio(activeModelId) : false;
});
$effect(() => {
void modelPropsVersion;
hasVideoModality = activeModelId ? modelsStore.modelSupportsVideo(activeModelId) : false;
});
$effect(() => {
void modelPropsVersion;
hasVisionModality = activeModelId ? modelsStore.modelSupportsVision(activeModelId) : false;
});
$effect(() => {
hasModelSelected = !isRouter || !!conversationModel || !!selectedModelId();
});
$effect(() => {
if (!isRouter) {
isSelectedModelInCache = true;
} else if (conversationModel) {
isSelectedModelInCache = modelOptions().some((option) => option.model === conversationModel);
} else {
const currentModelId = selectedModelId();
if (!currentModelId) {
isSelectedModelInCache = false;
} else {
isSelectedModelInCache = modelOptions().some((option) => option.id === currentModelId);
}
}
});
$effect(() => {
if (!hasModelSelected) {
submitTooltip = 'Please select a model first';
} else if (!isSelectedModelInCache) {
submitTooltip = 'Selected model is not available, please select another';
} else {
submitTooltip = '';
}
});
let selectorModelRef: ModelsSelectorDropdown | ModelsSelectorSheet | undefined =
$state(undefined);
export function open() {
selectorModelRef?.open();
}
</script>
{#if isMobile.current}
<ModelsSelectorSheet
disabled={disabled || isOffline}
bind:this={selectorModelRef}
currentModel={selectorModel}
{forceForegroundText}
{useGlobalSelection}
/>
{:else}
<ModelsSelectorDropdown
disabled={disabled || isOffline}
bind:this={selectorModelRef}
currentModel={selectorModel}
{forceForegroundText}
{useGlobalSelection}
/>
{/if}
@@ -0,0 +1,52 @@
<script lang="ts">
import { Mic, Square } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
interface Props {
class?: string;
disabled?: boolean;
hasAudioModality?: boolean;
isLoading?: boolean;
isRecording?: boolean;
onMicClick?: () => void;
}
let {
class: className = '',
disabled = false,
hasAudioModality = false,
isLoading = false,
isRecording = false,
onMicClick
}: Props = $props();
</script>
<div class="flex items-center gap-1 {className}">
<Tooltip.Root>
<Tooltip.Trigger>
<Button
class="h-8 w-8 rounded-full p-0 {isRecording
? 'animate-pulse bg-red-500 text-white hover:bg-red-600'
: ''}"
disabled={disabled || isLoading || !hasAudioModality}
onclick={onMicClick}
type="button"
>
<span class="sr-only">{isRecording ? 'Stop recording' : 'Start recording'}</span>
{#if isRecording}
<Square class="h-4 w-4 animate-pulse fill-white" />
{:else}
<Mic class="h-4 w-4" />
{/if}
</Button>
</Tooltip.Trigger>
{#if !hasAudioModality}
<Tooltip.Content>
<p>Current model does not support audio</p>
</Tooltip.Content>
{/if}
</Tooltip.Root>
</div>
@@ -0,0 +1,46 @@
<script lang="ts">
import { ArrowUp } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
interface Props {
canSend?: boolean;
disabled?: boolean;
showErrorState?: boolean;
tooltipLabel?: string;
}
let { canSend = false, disabled = false, showErrorState = false, tooltipLabel }: Props = $props();
let isDisabled = $derived(!canSend || disabled);
</script>
{#snippet submitButton(props = {})}
<Button
type="submit"
disabled={isDisabled}
class={[
'md:h-8 md:w-8 h-9 w-9 rounded-full p-0',
showErrorState &&
'bg-red-400/10 text-red-400 hover:bg-red-400/20 hover:text-red-400 disabled:opacity-100'
]}
{...props}
>
<span class="sr-only">Send</span>
<ArrowUp class="h-12 w-12" />
</Button>
{/snippet}
{#if tooltipLabel}
<Tooltip.Root>
<Tooltip.Trigger>
{@render submitButton()}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{tooltipLabel}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
{@render submitButton()}
{/if}
@@ -0,0 +1,215 @@
<script lang="ts">
import { Square, SkipForward } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { ChatService } from '$lib/services';
import {
ChatFormActionsAdd,
ChatFormActionModels,
ChatFormActionRecord,
ChatFormActionSubmit,
ChatFormContextGauge
} from '$lib/components/app';
import { FileTypeCategory, MessageRole } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { config } from '$lib/stores/settings.svelte';
import { activeMessages, conversationsStore } from '$lib/stores/conversations.svelte';
import {
activeProcessingState,
isChatStreaming,
isLoading as chatIsLoading
} from '$lib/stores/chat.svelte';
import { getFileTypeCategory } from '$lib/utils';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { ROUTES } from '$lib/constants/routes';
interface Props {
canSend?: boolean;
canSubmit?: boolean;
class?: string;
disabled?: boolean;
isLoading?: boolean;
isReasoning?: boolean;
isRecording?: boolean;
showAddButton?: boolean;
showModelSelector?: boolean;
uploadedFiles?: ChatUploadedFile[];
onFileUpload?: () => void;
onMicClick?: () => void;
onStop?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpResourcesClick?: () => void;
}
let {
canSend = false,
canSubmit = false,
class: className = '',
disabled = false,
isLoading = false,
isReasoning = false,
isRecording = false,
showAddButton = true,
showModelSelector = true,
uploadedFiles = [],
onFileUpload,
onMicClick,
onStop,
onSystemPromptClick,
onMcpPromptClick,
onMcpResourcesClick
}: Props = $props();
let currentConfig = $derived(config());
let hasMcpPromptsSupport = $derived.by(() => {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
return mcpStore.hasPromptsCapability(perChatOverrides);
});
let hasMcpResourcesSupport = $derived.by(() => {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
return mcpStore.hasResourcesCapability(perChatOverrides);
});
let hasAudioModality = $state(false);
let hasVideoModality = $state(false);
let hasVisionModality = $state(false);
let hasModelSelected = $state(false);
let isSelectedModelInCache = $state(true);
let submitTooltip = $state('');
let hasAudioAttachments = $derived(
uploadedFiles.some((file) => getFileTypeCategory(file.type) === FileTypeCategory.AUDIO)
);
let shouldShowRecordButton = $derived(
hasAudioModality && !canSubmit && !hasAudioAttachments && currentConfig.autoMicOnEmpty
);
let selectorModelRef: ChatFormActionModels | undefined = $state(undefined);
export function openModelSelector() {
selectorModelRef?.open();
}
// the streaming assistant message carries both the completion id and the model that
// produced it, targeting reasoning control from the same source keeps them consistent
let activeMessage = $derived(
conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1]
);
let hasProcessedTokens = $derived.by(() => {
if (!page.params.id) return false;
const messages = activeMessages() as DatabaseMessage[];
let totalHistoricalTokens = 0;
for (const m of messages) {
if (m.role !== MessageRole.ASSISTANT) continue;
const timings = m.timings;
if (!timings) continue;
const agenticLlm = timings.agentic?.llm;
if (agenticLlm?.prompt_n != null || agenticLlm?.predicted_n != null) {
totalHistoricalTokens += (agenticLlm?.prompt_n ?? 0) + (agenticLlm?.predicted_n ?? 0);
} else {
totalHistoricalTokens += (timings.prompt_n ?? 0) + (timings.predicted_n ?? 0);
}
}
if (totalHistoricalTokens > 0) return true;
if (!chatIsLoading() && !isChatStreaming()) return false;
const processingState = activeProcessingState();
if (!processingState) return false;
const livePromptTokens = Math.max(
processingState.promptTokens ?? 0,
processingState.promptProgress?.processed ?? 0
);
const liveOutputTokens = processingState.outputTokensUsed ?? 0;
return livePromptTokens > 0 || liveOutputTokens > 0;
});
</script>
<div
class="flex w-full items-center gap-3 {className} {showAddButton ? '' : 'justify-end'}"
style="container-type: inline-size"
>
{#if showAddButton}
<div class="mr-auto flex items-center gap-2">
<ChatFormActionsAdd
{disabled}
{hasAudioModality}
{hasVideoModality}
{hasVisionModality}
{hasMcpPromptsSupport}
{hasMcpResourcesSupport}
{onFileUpload}
{onSystemPromptClick}
{onMcpPromptClick}
{onMcpResourcesClick}
onMcpSettingsClick={() => goto(ROUTES.MCP_SERVERS)}
/>
</div>
{/if}
<div class="flex items-center gap-1.5">
{#if hasProcessedTokens}
<ChatFormContextGauge />
{/if}
{#if showModelSelector}
<ChatFormActionModels
{disabled}
bind:this={selectorModelRef}
bind:hasAudioModality
bind:hasVideoModality
bind:hasVisionModality
bind:hasModelSelected
bind:isSelectedModelInCache
bind:submitTooltip
forceForegroundText
useGlobalSelection
/>
{/if}
</div>
{#if isReasoning}
<Button
type="button"
variant="secondary"
onclick={() =>
ChatService.stopReasoning(activeMessage?.completionId ?? '', activeMessage?.model)}
class="group h-8 w-8 rounded-full p-0"
title="Skip reasoning"
>
<span class="sr-only">Skip reasoning</span>
<SkipForward class="h-4 w-4 stroke-muted-foreground group-hover:stroke-foreground" />
</Button>
{/if}
{#if isLoading && !canSubmit}
<Button
type="button"
variant="secondary"
onclick={onStop}
class="group h-8 w-8 rounded-full p-0 hover:bg-destructive/10!"
>
<span class="sr-only">Stop</span>
<Square
class="h-8 w-8 fill-muted-foreground stroke-muted-foreground group-hover:fill-destructive group-hover:stroke-destructive hover:fill-destructive hover:stroke-destructive"
/>
</Button>
{:else if shouldShowRecordButton}
<ChatFormActionRecord {disabled} {hasAudioModality} {isLoading} {isRecording} {onMicClick} />
{:else}
<ChatFormActionSubmit
canSend={canSend && (showModelSelector ? hasModelSelected && isSelectedModelInCache : true)}
{disabled}
tooltipLabel={submitTooltip}
showErrorState={showModelSelector && hasModelSelected && !isSelectedModelInCache}
/>
{/if}
</div>
@@ -0,0 +1,127 @@
<script lang="ts">
import { Check, Info, Lightbulb, LightbulbOff } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ReasoningEffort, MessageRole } from '$lib/enums';
import { REASONING_EFFORT_TOKENS } from '$lib/constants/reasoning-effort-tokens';
import { REASONING_EFFORT_LEVELS } from '$lib/constants/reasoning-effort';
import type { ReasoningEffortLevel } from '$lib/types';
import { DIALOG_SUBMENU_CONTENT } from '$lib/constants/css-classes';
import {
modelsStore,
checkModelSupportsThinking,
supportsThinking,
propsCacheVersion,
loadedModelIds
} from '$lib/stores/models.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import type { DatabaseMessage } from '$lib/types/database';
let thinkingEnabled = $derived(conversationsStore.getThinkingEnabled());
let currentEffort = $derived(conversationsStore.getReasoningEffort());
let isOff = $derived(!thinkingEnabled);
let subOpen = $state(false);
// Get conversation model from message history
let conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
let modelSupportsThinkingFromMessages = $derived.by(() => {
const modelId = isRouterMode() ? modelsStore.selectedModelName || conversationModel : null;
if (!modelId) return false;
const messages = conversationsStore.activeMessages;
return messages.some(
(m: DatabaseMessage) =>
m.role === MessageRole.ASSISTANT && m.model === modelId && !!m.reasoningContent
);
});
let modelSupportsThinking = $derived.by(() => {
loadedModelIds();
propsCacheVersion();
if (isRouterMode()) {
const modelId = modelsStore.selectedModelName || conversationModel;
return checkModelSupportsThinking(modelId ?? '') || modelSupportsThinkingFromMessages;
}
return supportsThinking() || modelSupportsThinkingFromMessages;
});
function isSelected(item: ReasoningEffortLevel): boolean {
if (item.isOff) return isOff;
return thinkingEnabled && currentEffort === item.value;
}
function handleSelection(item: ReasoningEffortLevel) {
if (item.isOff) {
conversationsStore.setThinkingEnabled(false);
} else {
conversationsStore.setThinkingEnabled(true);
conversationsStore.setReasoningEffort(item.value as ReasoningEffort);
}
subOpen = false;
}
</script>
{#if modelSupportsThinking}
<DropdownMenu.Sub bind:open={subOpen}>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
{#if thinkingEnabled}
<Lightbulb class="h-4 w-4 shrink-0 text-amber-400" />
{:else}
<LightbulbOff class="h-4 w-4 shrink-0 text-muted-foreground" />
{/if}
<span class="flex-1">Thinking</span>
{#if thinkingEnabled}
<span class="text-xs text-muted-foreground">{currentEffort}</span>
{:else}
<span class="text-xs text-muted-foreground">off</span>
{/if}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class={DIALOG_SUBMENU_CONTENT}>
{#each REASONING_EFFORT_LEVELS as level (level.value)}
<button
type="button"
class="flex w-full cursor-pointer items-center gap-2"
class:bg-accent={isSelected(level)}
onclick={() => handleSelection(level)}
>
<span class="flex-1 text-left">{level.label}</span>
{#if !level.isOff}
<span class="text-[11px] text-muted-foreground opacity-60">
{REASONING_EFFORT_TOKENS[level.value] === -1
? 'Unlimited'
: `Max ${REASONING_EFFORT_TOKENS[level.value].toLocaleString()} tokens`}
</span>
{/if}
{#if level.hasInfo}
<Tooltip.Root>
<Tooltip.Trigger>
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="left">
<p>Maximum thinking effort with extended context usage</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if isSelected(level)}
<Check class="h-4 w-4 shrink-0 text-foreground" />
{/if}
</button>
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{/if}
@@ -0,0 +1,108 @@
<script lang="ts">
import { untrack } from 'svelte';
import * as HoverCard from '$lib/components/ui/hover-card';
import { activeConversation, activeMessages } from '$lib/stores/conversations.svelte';
import { chatStore, isChatStreaming, isLoading } from '$lib/stores/chat.svelte';
import { formatParameters } from '$lib/utils/formatters';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import ContextGaugeDial from './ContextGaugeDial.svelte';
import ContextGaugeDetails from './ContextGaugeDetails.svelte';
import ContextGaugeLoadModel from './ContextGaugeLoadModel.svelte';
import { colorLevelBgClass, colorLevelTextClass } from './context-gauge';
const gauge = useContextGauge();
$effect(() => {
const conv = activeConversation();
untrack(() => chatStore.setActiveProcessingConversation(conv?.id ?? null));
});
$effect(() => {
const conv = activeConversation();
const messages = activeMessages() as DatabaseMessage[];
if (!conv) return;
if (isLoading() || isChatStreaming()) return;
if (messages.length === 0) {
untrack(() => chatStore.clearProcessingState(conv.id));
return;
}
untrack(() => chatStore.restoreProcessingStateFromMessages(messages, conv.id));
});
$effect(() => {
gauge.startMonitoring();
});
const showProgressBar = $derived(
gauge.contextTotal !== null &&
gauge.contextTotal > 0 &&
(gauge.activeModelId !== null || gauge.isActiveModelLoaded)
);
</script>
<HoverCard.Root>
<HoverCard.Trigger class="flex h-5 w-5 cursor-default items-center justify-center">
<ContextGaugeDial percent={gauge.contextPercent} level={gauge.colorLevel} />
</HoverCard.Trigger>
<HoverCard.Content
side="bottom"
class="z-50 w-64 rounded-lg border border-border/50 bg-popover p-3 text-popover-foreground shadow-lg"
>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="font-medium">Context</span>
<span class="text-muted-foreground">·</span>
<span class="font-mono text-muted-foreground">
{formatParameters(gauge.contextUsed)}
/ {gauge.contextTotal !== null ? formatParameters(gauge.contextTotal) : '-'}
</span>
</div>
{#if gauge.activeModelId !== null && !gauge.isActiveModelLoaded}
<ContextGaugeLoadModel
modelId={gauge.activeModelId}
isLoading={gauge.isActiveModelLoading}
onLoad={gauge.loadModel}
/>
{:else if showProgressBar}
<div class="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full transition-all duration-300 {colorLevelBgClass(
gauge.colorLevel
)}"
style="width: {gauge.contextPercent}%"
></div>
</div>
<div class="flex justify-between text-xs text-muted-foreground">
<span>
<span class={colorLevelTextClass(gauge.colorLevel)}>{gauge.contextPercent}%</span> used
</span>
<span>
{formatParameters((gauge.contextTotal ?? 0) - gauge.contextUsed)} remaining
</span>
</div>
{:else}
<div class="text-xs text-muted-foreground">No context info available</div>
{/if}
{#if gauge.hasAnyUsage}
<ContextGaugeDetails
currentRead={gauge.currentRead}
currentFresh={gauge.currentFresh}
currentCache={gauge.currentCache}
currentOutput={gauge.currentOutput}
kvTotal={gauge.kvTotal}
cumulativeRead={gauge.cumulativeRead}
cumulativeOutput={gauge.cumulativeOutput}
cumulativeCacheTotal={gauge.cumulativeCacheTotal}
averageTokensPerSecond={gauge.averageTokensPerSecond}
transientDetails={gauge.transientDetails}
/>
{/if}
</div>
</HoverCard.Content>
</HoverCard.Root>
@@ -0,0 +1,20 @@
<script lang="ts">
interface Props {
label: string;
value: string;
subtitle?: string;
}
let { label, value, subtitle }: Props = $props();
</script>
<div class="grid gap-1.5">
<div class="flex items-baseline justify-between">
<span class="text-muted-foreground">{label}</span>
<span class="font-mono text-muted-foreground">{value}</span>
</div>
{#if subtitle}
<div class="text-[10px] leading-tight text-muted-foreground/70">{subtitle}</div>
{/if}
</div>
@@ -0,0 +1,122 @@
<script lang="ts">
import { ChevronDown } from '@lucide/svelte';
import * as Collapsible from '$lib/components/ui/collapsible';
import { STATS_UNITS } from '$lib/constants';
import ContextGaugeDetailRow from './ContextGaugeDetailRow.svelte';
interface Props {
currentRead: number;
currentFresh: number;
currentCache: number;
currentOutput: number;
kvTotal: number;
cumulativeRead: number;
cumulativeOutput: number;
cumulativeCacheTotal: number;
averageTokensPerSecond: number | null;
transientDetails: string[];
}
let {
currentRead,
currentFresh,
currentCache,
currentOutput,
kvTotal,
cumulativeRead,
cumulativeOutput,
cumulativeCacheTotal,
averageTokensPerSecond,
transientDetails
}: Props = $props();
let open = $state(false);
const hasCumulative = $derived(cumulativeRead > 0 || cumulativeOutput > 0);
const hasCurrent = $derived(currentRead > 0 || currentOutput > 0);
</script>
<Collapsible.Root bind:open class="mt-3 border-t border-border/50 pt-4">
<Collapsible.Trigger
class="flex w-full cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<span>Token usage details</span>
<ChevronDown class={'ml-auto h-3 w-3 transition-transform' + (open ? ' rotate-180' : '')} />
</Collapsible.Trigger>
<Collapsible.Content class="flex flex-col gap-4 text-xs pt-4">
{#if hasCumulative}
<div>
<h3 class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground/70 mb-2">
Across all turns
</h3>
<div class="flex flex-col gap-2">
{#if cumulativeRead > 0}
<ContextGaugeDetailRow
label="Prompt tokens evaluated"
value={`${cumulativeRead.toLocaleString()} tok`}
subtitle={cumulativeCacheTotal > 0
? `${cumulativeCacheTotal.toLocaleString()} reused from KV cache`
: undefined}
/>
{/if}
{#if cumulativeOutput > 0}
<ContextGaugeDetailRow
label="Tokens generated"
value={`${cumulativeOutput.toLocaleString()} tok`}
/>
{/if}
</div>
</div>
{/if}
{#if hasCurrent}
<div>
<h3 class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground/70 mb-2">
This turn · KV cache
</h3>
<div class="flex flex-col gap-2">
{#if currentRead > 0}
<ContextGaugeDetailRow
label="Prompt"
value={`${currentRead.toLocaleString()} tok`}
subtitle={currentCache > 0
? `${currentFresh.toLocaleString()} fresh + ${currentCache.toLocaleString()} cached`
: undefined}
/>
{/if}
{#if currentOutput > 0}
<ContextGaugeDetailRow
label="Generated"
value={`${currentOutput.toLocaleString()} tok`}
/>
{/if}
<div class="pt-1 mt-0.5 border-t border-border/30">
<div class="flex justify-between">
<span class="text-muted-foreground">KV cache total</span>
<span class="font-mono font-medium">{kvTotal.toLocaleString()} tok</span>
</div>
</div>
</div>
</div>
{/if}
{#if averageTokensPerSecond !== null}
<div class="pt-1.5 mt-1 border-t border-border/30">
<ContextGaugeDetailRow
label="Avg speed"
value={`${averageTokensPerSecond.toFixed(1)}${STATS_UNITS.TOKENS_PER_SECOND}`}
/>
</div>
{/if}
{#each transientDetails as detail (detail)}
<div class="font-mono text-muted-foreground">{detail}</div>
{/each}
</Collapsible.Content>
</Collapsible.Root>
@@ -0,0 +1,43 @@
<script lang="ts">
import type { ColorLevel } from './context-gauge';
import { colorLevelTextClass } from './context-gauge';
interface Props {
percent: number | null;
level: ColorLevel;
size?: 'sm' | 'md';
}
let { percent, level, size = 'sm' }: Props = $props();
const RADIUS = 11;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
const strokeLevelClass = $derived(colorLevelTextClass(level));
const dimensions = $derived(size === 'md' ? 'h-6 w-6' : 'h-5 w-5');
const strokeWidth = $derived(size === 'md' ? 4 : 3);
</script>
<svg viewBox="0 0 32 32" fill="none" class={dimensions}>
<circle
cx="16"
cy="16"
r={RADIUS}
stroke="currentColor"
stroke-opacity="0.1"
stroke-width={strokeWidth}
/>
<circle
cx="16"
cy="16"
r={RADIUS}
class="transition-colors duration-300 {strokeLevelClass}"
stroke="currentColor"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-dasharray={CIRCUMFERENCE}
stroke-dashoffset={percent !== null ? CIRCUMFERENCE * (1 - percent / 100) : CIRCUMFERENCE}
transform="rotate(-90 16 16)"
/>
</svg>
@@ -0,0 +1,24 @@
<script lang="ts">
import { Loader2 } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
interface Props {
modelId: string | null;
isLoading: boolean;
onLoad: () => void;
}
let { modelId, isLoading, onLoad }: Props = $props();
</script>
{#if modelId !== null && !isLoading}
<div class="flex flex-col gap-2 border-t border-border/50 pt-2 text-xs text-muted-foreground">
<span>Available context size is only visible once the model is loaded.</span>
<Button size="sm" variant="secondary" class="self-start" onclick={onLoad}>Load model</Button>
</div>
{:else if isLoading}
<div class="flex items-center gap-2 border-t border-border/50 pt-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
<span>Loading model...</span>
</div>
{/if}
@@ -0,0 +1,37 @@
export type ColorLevel = 'ok' | 'warning' | 'critical' | 'neutral';
const WARNING_THRESHOLD = 80;
const CRITICAL_THRESHOLD = 95;
export function colorLevelFromPercent(percent: number | null): ColorLevel {
if (percent === null) return 'neutral';
if (percent >= CRITICAL_THRESHOLD) return 'critical';
if (percent >= WARNING_THRESHOLD) return 'warning';
return 'ok';
}
export function colorLevelTextClass(level: ColorLevel): string {
switch (level) {
case 'critical':
return 'text-red-400';
case 'warning':
return 'text-amber-400';
case 'ok':
return 'text-muted-foreground';
default:
return 'text-muted-foreground';
}
}
export function colorLevelBgClass(level: ColorLevel): string {
switch (level) {
case 'critical':
return 'bg-red-500';
case 'warning':
return 'bg-amber-500';
case 'ok':
return 'bg-green-500';
default:
return 'bg-muted';
}
}
@@ -0,0 +1,31 @@
<script lang="ts">
interface Props {
class?: string;
multiple?: boolean;
onFileSelect?: (files: File[]) => void;
}
let { class: className = '', multiple = true, onFileSelect }: Props = $props();
let fileInputElement: HTMLInputElement | undefined;
export function click() {
fileInputElement?.click();
}
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files) {
onFileSelect?.(Array.from(input.files));
}
}
</script>
<input
bind:this={fileInputElement}
type="file"
{multiple}
onchange={handleFileSelect}
class="hidden {className}"
/>
@@ -0,0 +1,44 @@
<script lang="ts">
import { mcpStore } from '$lib/stores/mcp.svelte';
import {
mcpResourceAttachments,
mcpHasResourceAttachments
} from '$lib/stores/mcp-resources.svelte';
import {
ChatAttachmentsListItemMcpResource,
HorizontalScrollCarousel
} from '$lib/components/app';
interface Props {
class?: string;
onResourceClick?: (uri: string) => void;
}
let { class: className, onResourceClick }: Props = $props();
const attachments = $derived(mcpResourceAttachments());
const hasAttachments = $derived(mcpHasResourceAttachments());
function handleRemove(attachmentId: string) {
mcpStore.removeResourceAttachment(attachmentId);
}
function handleResourceClick(uri: string) {
onResourceClick?.(uri);
}
</script>
{#if hasAttachments}
<div class={className}>
<HorizontalScrollCarousel gapSize="2">
{#each attachments as attachment, i (attachment.id)}
<ChatAttachmentsListItemMcpResource
class={i === 0 ? 'ml-3' : ''}
{attachment}
onRemove={handleRemove}
onclick={() => handleResourceClick(attachment.resource.uri)}
/>
{/each}
</HorizontalScrollCarousel>
</div>
{/if}
@@ -0,0 +1,55 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { MCPServerSettingsEntry } from '$lib/types';
import { mcpStore } from '$lib/stores/mcp.svelte';
interface Props {
server: MCPServerSettingsEntry | undefined;
serverLabel: string;
title: string;
description?: string;
titleExtra?: Snippet;
subtitle?: Snippet;
}
let { server, serverLabel, title, description, titleExtra, subtitle }: Props = $props();
let faviconUrl = $derived(server ? mcpStore.getServerFavicon(server.id) : null);
</script>
<div class="min-w-0 flex-1">
<div class="mb-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
{#if faviconUrl}
<img
src={faviconUrl}
alt=""
class="h-3 w-3 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span>{serverLabel}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-medium">
{title}
</span>
{#if titleExtra}
{@render titleExtra()}
{/if}
</div>
{#if description}
<p class="mt-0.5 truncate text-sm text-muted-foreground">
{description}
</p>
{/if}
{#if subtitle}
{@render subtitle()}
{/if}
</div>
@@ -0,0 +1,81 @@
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
import { SearchInput } from '$lib/components/app';
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
import { CHAT_FORM_POPOVER_MAX_HEIGHT } from '$lib/constants';
interface Props {
items: T[];
isLoading: boolean;
selectedIndex: number;
searchQuery: string;
showSearchInput: boolean;
searchPlaceholder?: string;
emptyMessage?: string;
itemKey: (item: T, index: number) => string;
item: Snippet<[T, number, boolean]>;
skeleton?: Snippet;
footer?: Snippet;
}
let {
items,
isLoading,
selectedIndex,
searchQuery = $bindable(),
showSearchInput,
searchPlaceholder = 'Search...',
emptyMessage = 'No items available',
itemKey,
item,
skeleton,
footer
}: Props = $props();
let listContainer = $state<HTMLDivElement | null>(null);
$effect(() => {
if (listContainer && selectedIndex >= 0 && selectedIndex < items.length) {
const selectedElement = listContainer.querySelector(
`[data-picker-index="${selectedIndex}"]`
) as HTMLElement;
if (selectedElement) {
selectedElement.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
});
}
}
});
</script>
<ScrollArea>
{#if showSearchInput}
<div class="absolute top-0 right-0 left-0 z-10 p-2 pb-0">
<SearchInput placeholder={searchPlaceholder} bind:value={searchQuery} />
</div>
{/if}
<div
bind:this={listContainer}
class={[`${CHAT_FORM_POPOVER_MAX_HEIGHT} p-2`, showSearchInput && 'pt-13']}
>
{#if isLoading}
{#if skeleton}
{@render skeleton()}
{/if}
{:else if items.length === 0}
<div class="py-6 text-center text-sm text-muted-foreground">{emptyMessage}</div>
{:else}
{#each items as itemData, index (itemKey(itemData, index))}
{@render item(itemData, index, index === selectedIndex)}
{/each}
{/if}
</div>
{#if footer}
{@render footer()}
{/if}
</ScrollArea>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
isSelected?: boolean;
onclick: () => void;
dataIndex?: number;
children: Snippet;
}
let { isSelected = false, onclick, dataIndex, children }: Props = $props();
</script>
<button
type="button"
data-picker-index={dataIndex}
{onclick}
class="flex w-full cursor-pointer items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent/50 {isSelected
? 'bg-accent/50'
: ''}"
>
{@render children()}
</button>
@@ -0,0 +1,30 @@
<script lang="ts">
interface Props {
titleWidth?: string;
showBadge?: boolean;
}
let { titleWidth = 'w-48', showBadge = false }: Props = $props();
</script>
<div class="flex w-full items-start gap-3 rounded-lg px-3 py-2">
<div class="min-w-0 flex-1 space-y-2">
<!-- Server label skeleton -->
<div class="mb-2 flex items-center gap-1.5">
<div class="h-3 w-3 shrink-0 animate-pulse rounded-sm bg-muted"></div>
<div class="h-3 w-24 animate-pulse rounded bg-muted"></div>
</div>
<!-- Title skeleton -->
<div class="flex items-center gap-2">
<div class="h-4 {titleWidth} animate-pulse rounded bg-muted"></div>
{#if showBadge}
<div class="h-4 w-12 animate-pulse rounded-full bg-muted"></div>
{/if}
</div>
<!-- Description skeleton -->
<div class="h-3 w-full animate-pulse rounded bg-muted"></div>
</div>
</div>
@@ -0,0 +1,50 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import * as Popover from '$lib/components/ui/popover';
interface Props {
class?: string;
isOpen?: boolean;
srLabel?: string;
onClose?: () => void;
onKeydown?: (event: KeyboardEvent) => void;
children: Snippet;
}
let {
class: className = '',
isOpen = $bindable(false),
srLabel = 'Open picker',
onClose,
onKeydown,
children
}: Props = $props();
</script>
<Popover.Root
bind:open={isOpen}
onOpenChange={(open) => {
if (!open) {
onClose?.();
}
}}
>
<Popover.Trigger
class="pointer-events-none absolute inset-0 opacity-0"
tabindex={-1}
aria-hidden="true"
>
<span class="sr-only">{srLabel}</span>
</Popover.Trigger>
<Popover.Content
side="top"
align="start"
sideOffset={12}
class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl {className}"
onkeydown={onKeydown}
onOpenAutoFocus={(event) => event.preventDefault()}
>
{@render children()}
</Popover.Content>
</Popover.Root>
@@ -0,0 +1,435 @@
<script lang="ts">
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { debounce, uuid } from '$lib/utils';
import { KeyboardKey } from '$lib/enums';
import type { MCPPromptInfo, GetPromptResult, MCPServerSettingsEntry } from '$lib/types';
import { SvelteMap } from 'svelte/reactivity';
import {
ChatFormPickerPopover,
ChatFormPickerList,
ChatFormPickerListItem,
ChatFormPickerItemHeader,
ChatFormPickerListItemSkeleton,
ChatFormPromptPickerArgumentForm
} from '$lib/components/app/chat';
import Badge from '$lib/components/ui/badge/badge.svelte';
interface Props {
class?: string;
isOpen?: boolean;
searchQuery?: string;
onClose?: () => void;
onPromptLoadStart?: (
placeholderId: string,
promptInfo: MCPPromptInfo,
args?: Record<string, string>
) => void;
onPromptLoadComplete?: (placeholderId: string, result: GetPromptResult) => void;
onPromptLoadError?: (placeholderId: string, error: string) => void;
}
let {
class: className = '',
isOpen = false,
searchQuery = '',
onClose,
onPromptLoadStart,
onPromptLoadComplete,
onPromptLoadError
}: Props = $props();
let prompts = $state<MCPPromptInfo[]>([]);
let isLoading = $state(false);
let selectedPrompt = $state<MCPPromptInfo | null>(null);
let promptArgs = $state<Record<string, string>>({});
let selectedIndex = $state(0);
let internalSearchQuery = $state('');
let promptError = $state<string | null>(null);
let selectedIndexBeforeArgumentForm = $state<number | null>(null);
let suggestions = $state<Record<string, string[]>>({});
let loadingSuggestions = $state<Record<string, boolean>>({});
let activeAutocomplete = $state<string | null>(null);
let autocompleteIndex = $state(0);
let serverSettingsMap = $derived.by(() => {
const servers = mcpStore.getServers();
const map = new SvelteMap<string, MCPServerSettingsEntry>();
for (const server of servers) {
map.set(server.id, server);
}
return map;
});
$effect(() => {
if (isOpen) {
loadPrompts();
selectedIndex = 0;
} else {
selectedPrompt = null;
promptArgs = {};
promptError = null;
}
});
$effect(() => {
if (filteredPrompts.length > 0 && selectedIndex >= filteredPrompts.length) {
selectedIndex = 0;
}
});
async function loadPrompts() {
isLoading = true;
try {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (!initialized) {
prompts = [];
return;
}
prompts = await mcpStore.getAllPrompts();
} catch (error) {
console.error('[ChatFormPickerMcpPrompts] Failed to load prompts:', error);
prompts = [];
} finally {
isLoading = false;
}
}
function handlePromptClick(prompt: MCPPromptInfo) {
const args = prompt.arguments ?? [];
if (args.length > 0) {
selectedIndexBeforeArgumentForm = selectedIndex;
selectedPrompt = prompt;
promptArgs = {};
promptError = null;
requestAnimationFrame(() => {
const firstInput = document.querySelector(`#arg-${args[0].name}`) as HTMLInputElement;
if (firstInput) {
firstInput.focus();
}
});
} else {
executePrompt(prompt, {});
}
}
async function executePrompt(prompt: MCPPromptInfo, args: Record<string, string>) {
promptError = null;
const placeholderId = uuid();
const nonEmptyArgs = Object.fromEntries(
Object.entries(args).filter(([, value]) => value.trim() !== '')
);
const argsToPass = Object.keys(nonEmptyArgs).length > 0 ? nonEmptyArgs : undefined;
onPromptLoadStart?.(placeholderId, prompt, argsToPass);
onClose?.();
try {
const result = await mcpStore.getPrompt(prompt.serverName, prompt.name, args);
onPromptLoadComplete?.(placeholderId, result);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error executing prompt';
onPromptLoadError?.(placeholderId, errorMessage);
}
}
function handleArgumentSubmit(event: SubmitEvent) {
event.preventDefault();
if (selectedPrompt) {
executePrompt(selectedPrompt, promptArgs);
}
}
const fetchCompletions = debounce(async (argName: string, value: string) => {
if (!selectedPrompt || value.length < 1) {
suggestions[argName] = [];
return;
}
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log('[ChatFormPickerMcpPrompts] Fetching completions for:', {
serverName: selectedPrompt.serverName,
promptName: selectedPrompt.name,
argName,
value
});
}
loadingSuggestions[argName] = true;
try {
const result = await mcpStore.getPromptCompletions(
selectedPrompt.serverName,
selectedPrompt.name,
argName,
value
);
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log('[ChatFormPickerMcpPrompts] Autocomplete result:', {
argName,
value,
result,
suggestionsCount: result?.values.length ?? 0
});
}
if (result && result.values.length > 0) {
// Filter out empty strings from suggestions
const filteredValues = result.values.filter((v) => v.trim() !== '');
if (filteredValues.length > 0) {
suggestions[argName] = filteredValues;
activeAutocomplete = argName;
autocompleteIndex = 0;
} else {
suggestions[argName] = [];
}
} else {
suggestions[argName] = [];
}
} catch (error) {
console.error('[ChatFormPickerMcpPrompts] Failed to fetch completions:', error);
suggestions[argName] = [];
} finally {
loadingSuggestions[argName] = false;
}
}, 200);
function handleArgInput(argName: string, value: string) {
promptArgs[argName] = value;
fetchCompletions(argName, value);
}
function selectSuggestion(argName: string, value: string) {
promptArgs[argName] = value;
suggestions[argName] = [];
activeAutocomplete = null;
}
function handleArgKeydown(event: KeyboardEvent, argName: string) {
const argSuggestions = suggestions[argName] ?? [];
// Handle Escape - return to prompt selection list
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
event.stopPropagation();
handleCancelArgumentForm();
return;
}
if (argSuggestions.length === 0 || activeAutocomplete !== argName) return;
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, argSuggestions.length - 1);
} else if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, 0);
} else if (event.key === KeyboardKey.ENTER && argSuggestions[autocompleteIndex]) {
event.preventDefault();
event.stopPropagation();
selectSuggestion(argName, argSuggestions[autocompleteIndex]);
}
}
function handleArgBlur(argName: string) {
// Delay to allow click on suggestion
setTimeout(() => {
if (activeAutocomplete === argName) {
suggestions[argName] = [];
activeAutocomplete = null;
}
}, 150);
}
function handleArgFocus(argName: string) {
if ((suggestions[argName]?.length ?? 0) > 0) {
activeAutocomplete = argName;
}
}
function handleCancelArgumentForm() {
// Restore the previously selected prompt index
if (selectedIndexBeforeArgumentForm !== null) {
selectedIndex = selectedIndexBeforeArgumentForm;
selectedIndexBeforeArgumentForm = null;
}
selectedPrompt = null;
promptArgs = {};
promptError = null;
}
export function handleKeydown(event: KeyboardEvent): boolean {
if (!isOpen) return false;
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
if (selectedPrompt) {
// Return to prompt selection list, keeping the selected prompt active
handleCancelArgumentForm();
} else {
onClose?.();
}
return true;
}
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
if (filteredPrompts.length > 0) {
selectedIndex = (selectedIndex + 1) % filteredPrompts.length;
}
return true;
}
if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
if (filteredPrompts.length > 0) {
selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1;
}
return true;
}
if (event.key === KeyboardKey.ENTER && !selectedPrompt) {
event.preventDefault();
if (filteredPrompts[selectedIndex]) {
handlePromptClick(filteredPrompts[selectedIndex]);
}
return true;
}
return false;
}
let filteredPrompts = $derived.by(() => {
const sortedServers = mcpStore.getServersSorted();
const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
const sortedPrompts = [...prompts].sort((a, b) => {
const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
const query = (searchQuery || internalSearchQuery).toLowerCase();
if (!query) return sortedPrompts;
return sortedPrompts.filter(
(prompt) =>
prompt.name.toLowerCase().includes(query) ||
prompt.title?.toLowerCase().includes(query) ||
prompt.description?.toLowerCase().includes(query)
);
});
let showSearchInput = $derived(prompts.length > 3);
</script>
<ChatFormPickerPopover
bind:isOpen
class={className}
srLabel="Open prompt picker"
{onClose}
onKeydown={handleKeydown}
>
{#if selectedPrompt}
{@const prompt = selectedPrompt}
{@const server = serverSettingsMap.get(prompt.serverName)}
{@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName}
<div class="p-4">
<ChatFormPickerItemHeader
{server}
{serverLabel}
title={prompt.title || prompt.name}
description={prompt.description}
>
{#snippet titleExtra()}
{#if prompt.arguments?.length}
<Badge variant="secondary">
{prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
</Badge>
{/if}
{/snippet}
</ChatFormPickerItemHeader>
<ChatFormPromptPickerArgumentForm
prompt={selectedPrompt}
{promptArgs}
{suggestions}
{loadingSuggestions}
{activeAutocomplete}
{autocompleteIndex}
{promptError}
onArgInput={handleArgInput}
onArgKeydown={handleArgKeydown}
onArgBlur={handleArgBlur}
onArgFocus={handleArgFocus}
onSelectSuggestion={selectSuggestion}
onSubmit={handleArgumentSubmit}
onCancel={handleCancelArgumentForm}
/>
</div>
{:else}
<ChatFormPickerList
items={filteredPrompts}
{isLoading}
{selectedIndex}
bind:searchQuery={internalSearchQuery}
{showSearchInput}
searchPlaceholder="Search prompts..."
emptyMessage="No MCP prompts available"
itemKey={(prompt) => prompt.serverName + ':' + prompt.name}
>
{#snippet item(prompt, index, isSelected)}
{@const server = serverSettingsMap.get(prompt.serverName)}
{@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName}
<ChatFormPickerListItem
dataIndex={index}
{isSelected}
onclick={() => handlePromptClick(prompt)}
>
<ChatFormPickerItemHeader
{server}
{serverLabel}
title={prompt.title || prompt.name}
description={prompt.description}
>
{#snippet titleExtra()}
{#if prompt.arguments?.length}
<Badge variant="secondary">
{prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
</Badge>
{/if}
{/snippet}
</ChatFormPickerItemHeader>
</ChatFormPickerListItem>
{/snippet}
{#snippet skeleton()}
<ChatFormPickerListItemSkeleton titleWidth="w-32" showBadge />
{/snippet}
</ChatFormPickerList>
{/if}
</ChatFormPickerPopover>
@@ -0,0 +1,74 @@
<script lang="ts">
import type { MCPPromptInfo } from '$lib/types';
import ChatFormPromptPickerArgumentInput from './ChatFormPromptPickerArgumentInput.svelte';
import { Button } from '$lib/components/ui/button';
interface Props {
prompt: MCPPromptInfo;
promptArgs: Record<string, string>;
suggestions: Record<string, string[]>;
loadingSuggestions: Record<string, boolean>;
activeAutocomplete: string | null;
autocompleteIndex: number;
promptError: string | null;
onArgInput: (argName: string, value: string) => void;
onArgKeydown: (event: KeyboardEvent, argName: string) => void;
onArgBlur: (argName: string) => void;
onArgFocus: (argName: string) => void;
onSelectSuggestion: (argName: string, value: string) => void;
onSubmit: (event: SubmitEvent) => void;
onCancel: () => void;
}
let {
prompt,
promptArgs,
suggestions,
loadingSuggestions,
activeAutocomplete,
autocompleteIndex,
promptError,
onArgInput,
onArgKeydown,
onArgBlur,
onArgFocus,
onSelectSuggestion,
onSubmit,
onCancel
}: Props = $props();
</script>
<form onsubmit={onSubmit} class="space-y-3 pt-4">
{#each prompt.arguments ?? [] as arg (arg.name)}
<ChatFormPromptPickerArgumentInput
argument={arg}
value={promptArgs[arg.name] ?? ''}
suggestions={suggestions[arg.name] ?? []}
isLoadingSuggestions={loadingSuggestions[arg.name] ?? false}
isAutocompleteActive={activeAutocomplete === arg.name}
autocompleteIndex={activeAutocomplete === arg.name ? autocompleteIndex : 0}
onInput={(value) => onArgInput(arg.name, value)}
onKeydown={(e) => onArgKeydown(e, arg.name)}
onBlur={() => onArgBlur(arg.name)}
onFocus={() => onArgFocus(arg.name)}
onSelectSuggestion={(value) => onSelectSuggestion(arg.name, value)}
/>
{/each}
{#if promptError}
<div
class="flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive"
role="alert"
>
<span class="shrink-0"></span>
<span>{promptError}</span>
</div>
{/if}
<div class="mt-8 flex justify-end gap-2">
<Button type="button" size="sm" onclick={onCancel} variant="secondary">Cancel</Button>
<Button size="sm" type="submit">Use Prompt</Button>
</div>
</form>
@@ -0,0 +1,84 @@
<script lang="ts">
import type { MCPPromptInfo } from '$lib/types';
import { fly } from 'svelte/transition';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
type PromptArgument = NonNullable<MCPPromptInfo['arguments']>[number];
interface Props {
argument: PromptArgument;
value: string;
suggestions?: string[];
isLoadingSuggestions?: boolean;
isAutocompleteActive?: boolean;
autocompleteIndex?: number;
onInput: (value: string) => void;
onKeydown: (event: KeyboardEvent) => void;
onBlur: () => void;
onFocus: () => void;
onSelectSuggestion: (value: string) => void;
}
let {
argument,
value = '',
suggestions = [],
isLoadingSuggestions = false,
isAutocompleteActive = false,
autocompleteIndex = 0,
onInput,
onKeydown,
onBlur,
onFocus,
onSelectSuggestion
}: Props = $props();
</script>
<div class="relative grid gap-1">
<Label for="arg-{argument.name}" class="mb-1 text-muted-foreground">
<span>
{argument.name}
{#if argument.required}
<span class="text-destructive">*</span>
{/if}
</span>
{#if isLoadingSuggestions}
<span class="text-xs text-muted-foreground/50">...</span>
{/if}
</Label>
<Input
id="arg-{argument.name}"
type="text"
{value}
oninput={(e) => onInput(e.currentTarget.value)}
onkeydown={onKeydown}
onblur={onBlur}
onfocus={onFocus}
placeholder={argument.description || argument.name}
required={argument.required}
autocomplete="off"
/>
{#if isAutocompleteActive && suggestions.length > 0}
<div
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
transition:fly={{ y: -5, duration: 100 }}
>
{#each suggestions as suggestion, i (suggestion)}
<button
type="button"
onmousedown={() => onSelectSuggestion(suggestion)}
class="w-full px-3 py-1.5 text-left text-sm hover:bg-accent {i === autocompleteIndex
? 'bg-accent'
: ''}"
>
{suggestion}
</button>
{/each}
</div>
{/if}
</div>

Some files were not shown because too many files have changed in this diff Show More