include llama.cpp since there were some edits
This commit is contained in:
1 parent
db7f28d087
commit
8c66990853
3918 files changed
+1640170
-1
No files matched your search
@@ -0,0 +1,3 @@
|
||||
VITE_PUBLIC_APP_NAME='llama-ui'
|
||||
VITE_DEBUG='true'
|
||||
VITE_PUBLIC_SERVER_ORIGIN='http://localhost:8033'
|
||||
@@ -0,0 +1,42 @@
|
||||
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
|
||||
|
||||
# Vitest browser mode failure artifacts
|
||||
.vitest-attachments/
|
||||
tests/**/__screenshots__/
|
||||
@@ -0,0 +1,3 @@
|
||||
engine-strict=true
|
||||
ignore-scripts=true
|
||||
min-release-age=7
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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
|
||||
|
||||
# Vendored third party sources, kept byte identical to upstream
|
||||
src/lib/vendors/
|
||||
|
||||
# Agent skills installed from upstream, kept as installed
|
||||
.agents/
|
||||
skills-lock.json
|
||||
@@ -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>
|
||||
@@ -0,0 +1,25 @@
|
||||
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',
|
||||
'@storybook/addon-mcp'
|
||||
],
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,73 @@
|
||||
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")
|
||||
|
||||
# Provision assets and generate ui.cpp/ui.h natively in CMake at build time.
|
||||
# The generated sources are compiled by the regular target toolchain; no
|
||||
# build-time host executable is needed (works in any cross-compile setup).
|
||||
# The script uses copy_if_different semantics, so the library below only
|
||||
# recompiles when the generated 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_GZIP=${LLAMA_UI_GZIP}"
|
||||
-P "${PROJECT_SOURCE_DIR}/scripts/ui-assets.cmake"
|
||||
COMMENT "Provisioning UI assets"
|
||||
VERBATIM
|
||||
)
|
||||
|
||||
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}
|
||||
)
|
||||
@@ -0,0 +1,756 @@
|
||||
# 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)
|
||||
|
||||
- **Models manager** - one table for every model the server can serve, split into loaded, downloading, favorites, local and hidden sections, with search, context/capability/modality filters, sorting, load/unload, delete from disk and hide. It opens from the sidebar, from a model row or from the selector.
|
||||
- **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
|
||||
- **Downloads** - In-flight downloads stay visible above the selector and can be paused, resumed or cancelled from the manager
|
||||
- **Hub metadata** - Avatars, context length and chat template come from the Hugging Face Hub only while the `Use Hugging Face Hub API for models metadata` setting is on. It is off by default, and the UI then shows what the server reports for `/v1/models`
|
||||
|
||||
### 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 ci
|
||||
```
|
||||
|
||||
### 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 `9931` 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 |
|
||||
| **Drawers** | vaul-svelte | Draggable drawer overlays (mobile pickers, model pane) |
|
||||
| **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
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Routes["📍 Routes"]
|
||||
R1["/ (Welcome)"]
|
||||
R2["/chat/[id]"]
|
||||
R3["/mcp-servers"]
|
||||
R4["/search"]
|
||||
R5["/settings"]
|
||||
RL["+layout.svelte"]
|
||||
end
|
||||
|
||||
subgraph Components["🧩 Components"]
|
||||
C_Screen["ChatScreen"]
|
||||
C_Form["ChatForm"]
|
||||
C_Messages["ChatMessages"]
|
||||
C_Sidebar["ChatSidebar"]
|
||||
C_Models["ModelsSelector"]
|
||||
C_Settings["ChatSettings"]
|
||||
C_Mcp["McpServers"]
|
||||
end
|
||||
|
||||
subgraph Hooks["🔌 Hooks"]
|
||||
H1["use-chat-screen-active-model"]
|
||||
H2["use-processing-state"]
|
||||
H3["use-context-gauge"]
|
||||
H4["use-models-selector"]
|
||||
H5["use-tools-panel"]
|
||||
end
|
||||
|
||||
subgraph Stores["🗄️ Stores"]
|
||||
S1["chatStore"]
|
||||
S2["conversationsStore"]
|
||||
S3["modelsStore"]
|
||||
S4["mcpStore"]
|
||||
S5["agenticStore"]
|
||||
S6["serverStore"]
|
||||
S7["settingsStore"]
|
||||
S8["toolsStore"]
|
||||
end
|
||||
|
||||
subgraph Services["⚙️ Services"]
|
||||
SV1["ChatService"]
|
||||
SV2["ModelsService"]
|
||||
SV3["PropsService"]
|
||||
SV4["DatabaseService"]
|
||||
SV5["MCPService"]
|
||||
SV6["ToolsService"]
|
||||
SV7["SandboxService"]
|
||||
end
|
||||
|
||||
subgraph Storage["💾 Storage"]
|
||||
ST1["IndexedDB"]
|
||||
ST2["LocalStorage"]
|
||||
end
|
||||
|
||||
subgraph APIs["🌐 llama-server"]
|
||||
API1["/v1/chat/completions"]
|
||||
API2["/props"]
|
||||
API3["/models/*"]
|
||||
API4["/tools"]
|
||||
end
|
||||
|
||||
R1 & R2 --> C_Screen
|
||||
RL --> C_Sidebar
|
||||
C_Screen --> C_Form & C_Messages & C_Settings
|
||||
C_Screen --> H1 & H2 & H3
|
||||
C_Models --> H4
|
||||
C_Mcp --> S4
|
||||
C_Screen --> S1 & S2 & S3
|
||||
C_Models --> S3
|
||||
H1 --> S3
|
||||
S1 --> SV1 & SV4
|
||||
S2 --> SV4
|
||||
S3 --> SV2 & SV3
|
||||
S4 --> SV5
|
||||
S5 --> SV1 & SV5 & SV6 & SV7
|
||||
SV4 --> ST1
|
||||
SV1 --> API1
|
||||
SV2 --> API3
|
||||
SV3 --> API2
|
||||
SV6 --> API4
|
||||
```
|
||||
|
||||
### Layer Breakdown
|
||||
|
||||
#### Routes (`src/routes/`)
|
||||
|
||||
- **`/`** - Welcome screen, creates new conversation
|
||||
- **`/chat/[id]`** - Active chat interface
|
||||
- **`/mcp-servers`** - MCP server management
|
||||
- **`/search`** - Conversation search
|
||||
- **`/settings`** - Settings (optional `[[section]]`)
|
||||
- **`+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 |
|
||||
| `DialogManageModels` | Models manager: browse, load, download and delete models |
|
||||
| `DialogModelInformation` | Model details (context size, modalities, parallel slots) |
|
||||
| `DialogChatAttachmentsPreview` | Full preview for images, PDFs (text or page view), code |
|
||||
| `DialogConfirmation` | Generic confirmation for destructive actions |
|
||||
| `DialogConversationRename` | Edit conversation title |
|
||||
|
||||
**Server/Model Components** (`app/server/`, `app/models/`):
|
||||
|
||||
| Component | Responsibility |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `ServerErrorSplash` | Error display when server is unreachable |
|
||||
| `ModelsSelector/` | Model dropdown and mobile sheet: favorites, loaded and local sections, rows windowed and folded into families |
|
||||
| `ModelsManager/` | Models manager table and model pane: sections, filters, sorting, quants folded per repo, downloads |
|
||||
| `ModelAvatar` | Org avatar of a model, with the quantizer badge; follows the family grouping of the list |
|
||||
| `ModelId`, `ModelBadge` | Model name, aliases, tags, quantization and draft sidecar badges |
|
||||
| `ModelContext`, `ModelCapabilities` | Context window and capability icons, filled from the Hub when it is enabled |
|
||||
|
||||
**Shared UI Components** (`app/misc/`):
|
||||
|
||||
| Component | Responsibility |
|
||||
| ----------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `MarkdownContent` | Markdown rendering with KaTeX, syntax highlighting, copy buttons |
|
||||
| `SyntaxHighlightedCode` | Code blocks with language detection and highlighting |
|
||||
| `ActionIcon`, `DropdownMenuActions` | Reusable action button and action menu |
|
||||
| `BadgesModality`, `BadgeInfo` | Status and capability badges |
|
||||
| `CollapsibleSection`, `CollapsibleRegion` | Section header with a trigger that expands in place |
|
||||
| `GroupedList` | Grouped rows with foldable groups, per-group windows and show-more rows |
|
||||
| `ScrollCarousel`, `TruncatedText` | Horizontal scroll strip and single-line truncation with tooltip |
|
||||
|
||||
#### Hooks (`src/lib/hooks/`)
|
||||
|
||||
Hooks are the thin view-layer between components and stores: they own UI concerns (scroll, drag-and-drop, keyboard shortcuts, pickers, selection) and translate store state into view state.
|
||||
|
||||
| Hook | Responsibility |
|
||||
| ------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `use-chat-screen-active-model` | Active model resolution + modality capability detection |
|
||||
| `use-processing-state` | View over `chatStore.processing` for streaming progress/tokens |
|
||||
| `use-context-gauge` | View over `contextStatsStore` for the context usage gauge |
|
||||
| `use-models-selector` | Model selector state: hidden models, family grouping, windowed rows, selection and load on pick |
|
||||
| `use-tools-panel` | Tools panel state |
|
||||
| `use-reasoning-menu` | Reasoning-effort menu state |
|
||||
| `use-attachment-menu` | Attachment menu + modality flags |
|
||||
| `use-draft-messages` | Per-chat draft message/files persistence |
|
||||
| `use-chat-form-pickers` | Chat form pickers (commands, mentions) |
|
||||
| `use-debounced-search` | Shared debounced async search for pickers |
|
||||
| `use-picker-navigation` | Picker keyboard navigation |
|
||||
| `use-chat-message-edit-context` | Message edit context (content + extras) |
|
||||
| `use-chat-screen-drag-and-drop` | Drag-and-drop state machine |
|
||||
| `use-chat-screen-file-upload` | File upload queue + capability validation |
|
||||
| `use-chat-screen-scroll` | Scroll container binding + navigation guard |
|
||||
| `use-auto-scroll` | Auto-scroll controller for streaming |
|
||||
| `use-marquee-selection` | Shift+click / marquee range selection |
|
||||
| `use-keyboard-shortcuts` | Global keyboard shortcuts |
|
||||
| `use-settings-navigation` | Settings section navigation |
|
||||
| `use-pwa` | PWA install/update + version mismatch detection |
|
||||
|
||||
#### Stores (`src/lib/stores/`)
|
||||
|
||||
Stores own reactive application state as Svelte 5 runes. Larger stores are split into directories and compose focused sub-stores behind a narrow host interface (see Architectural Patterns).
|
||||
|
||||
| Store | Responsibility |
|
||||
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `chatStore` | Chat lifecycle, streaming, abort control, error handling; composes `processing`, `activity`, `streams`, `flows` |
|
||||
| `conversationsStore` | Conversation CRUD, message branching, navigation, import/export; composes `preferences` |
|
||||
| `modelsStore` | Model list, selection, loading/unloading (ROUTER), recent picks, favorites, hidden models and list open state; composes `props`, `status` |
|
||||
| `mcpStore` | MCP host role: multi-server lifecycle, tool routing, server icons; composes `health`, `resources` |
|
||||
| `agenticStore` | Multi-turn agentic loop orchestration, tool execution; composes `gates` |
|
||||
| `serverStore` | Server connection state, `/props`, role detection, modalities |
|
||||
| `settingsStore` | User preferences, theme, parameter sync with server defaults |
|
||||
| `toolsStore` | Tool registry: server + MCP tools, enabled set for the LLM |
|
||||
| `permissionsStore` | Persisted tool permission grants |
|
||||
| `contextStatsStore` | Context window usage for the active conversation |
|
||||
| `draftMessagesStore` | Per-chat draft message/files |
|
||||
| `deviceStore` | Browser environment signals (mobile, OS, theme) |
|
||||
| `versionStore` | Build version information |
|
||||
|
||||
#### Services (`src/lib/services/`)
|
||||
|
||||
Services are a stateless protocol layer: static methods, pure I/O, no reactive state. Stores consume them for all API and storage access.
|
||||
|
||||
| Service | Responsibility |
|
||||
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ChatService` | `/v1/chat/completions` streaming + SSE parsing, message format conversion |
|
||||
| `ModelsService` | `/models`, `/models/load`, `/models/unload` |
|
||||
| `PropsService` | `/props`, `/props?model=` |
|
||||
| `HuggingFaceService` | Hugging Face Hub metadata: model details, avatars, README and file tree, cached per repo and off unless the Hub setting is on |
|
||||
| `DatabaseService` | IndexedDB operations via Dexie |
|
||||
| `MCPService` | MCP protocol: transports, connect, list/execute tools, prompts, resources |
|
||||
| `ToolsService` | Server tool list/execute/stream (`/tools`) |
|
||||
| `SandboxService` | Browser JS execution in a sandboxed worker |
|
||||
| `ParameterSyncService` | Syncs settings with server defaults |
|
||||
| `ConversationTransferService` | Conversation import/export JSONL + ZIP format |
|
||||
| `MigrationService` | Non-destructive localStorage/IndexedDB migrations |
|
||||
| `RouterService` | Dynamic route URL construction |
|
||||
|
||||
---
|
||||
|
||||
## Data Flows
|
||||
|
||||
### MODEL Mode (Single Model)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant UI
|
||||
participant Stores
|
||||
participant DB as IndexedDB
|
||||
participant API as llama-server
|
||||
|
||||
Note over User,API: Initialization
|
||||
UI->>Stores: initStores() (awaited by route loads)
|
||||
Stores->>Stores: run migrations
|
||||
Stores->>DB: load conversations (background)
|
||||
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)
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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. Loading is tracked
|
||||
per conversation by the activity ledger (`chatStore.activity`), while streaming
|
||||
state and abort controllers live in per-conversation maps:
|
||||
|
||||
```typescript
|
||||
class ChatStore {
|
||||
chatStreamingStates = new SvelteMap<string, { response: string; messageId: string }>();
|
||||
abortControllers = new SvelteMap<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. The
|
||||
`use-chat-screen-active-model` hook derives the active model's capabilities
|
||||
from `modelsStore.props`:
|
||||
|
||||
```typescript
|
||||
// use-chat-screen-active-model hook
|
||||
const hasVisionModality = $derived.by(() => modelsStore.props.modelSupportsVision(activeModelId));
|
||||
const hasAudioModality = $derived.by(() => modelsStore.props.modelSupportsAudio(activeModelId));
|
||||
```
|
||||
|
||||
### 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, model favorites, recent picks, hidden models and model list open state (small key-value data)
|
||||
- **Memory only**: Server props, model list (fetched fresh on each session), Hugging Face Hub details (cached per repo for the session)
|
||||
|
||||
### 9. Remote Assets Under Cross-Origin Isolation
|
||||
|
||||
llama-server serves the UI with `Cross-Origin-Embedder-Policy: require-corp` and `Cross-Origin-Opener-Policy: same-origin`, so a cross-origin subresource has to be allowed by the target origin. The UI handles that per asset kind:
|
||||
|
||||
- **Hub avatars** are requested in CORS mode (`crossorigin="anonymous"`), which the Hub allows
|
||||
- **Hub metadata** is fetched with `fetch()`, which is a CORS request by default, and only while the Hub setting is on
|
||||
- **MCP server icons and web-search result favicons** have no CORS guarantee, so they go through the server's `/cors-proxy` when the user started llama-server with `--ui-mcp-proxy`; without it they fall back to the bundled glyph
|
||||
- **Per-row Hub lookups** wait until the row is near the viewport (the `nearViewport` action), so a long model list does not fire one request per row on mount
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
└── .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
|
||||
@@ -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,336 @@
|
||||
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
|
||||
import svelteConfig from './svelte.config.js';
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
import js from '@eslint/js';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import perfectionist from 'eslint-plugin-perfectionist';
|
||||
import simpleImportSort from 'eslint-plugin-simple-import-sort';
|
||||
import storybook from 'eslint-plugin-storybook';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import globals from 'globals';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript-eslint';
|
||||
|
||||
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||
// Require a blank line between sibling element-like nodes in a Svelte template
|
||||
// (elements, components, and the {#if} / {#each} / {#await} / {#snippet} /
|
||||
// {@render} blocks) that sit on separate lines at the same nesting level.
|
||||
// Whitespace between siblings is a whitespace-only SvelteText node; when it
|
||||
// holds a single newline (no blank line) the fix adds one, keeping the
|
||||
// indentation of the second sibling. Real text content (e.g. `foo\n\nbar`)
|
||||
// is left alone.
|
||||
const ELEMENT_LIKE_TYPES = new Set([
|
||||
'SvelteAwaitBlock',
|
||||
'SvelteComponent',
|
||||
'SvelteEachBlock',
|
||||
'SvelteElement',
|
||||
'SvelteIfBlock',
|
||||
'SvelteKeyBlock',
|
||||
'SvelteRenderTag',
|
||||
'SvelteSelf',
|
||||
'SvelteSnippetBlock'
|
||||
]);
|
||||
const paddingLineBetweenElements = {
|
||||
create(context) {
|
||||
// Check one list of template children. Each children array holds the
|
||||
// element-like nodes plus the whitespace/comment text between them.
|
||||
function checkChildren(children) {
|
||||
if (!Array.isArray(children)) return;
|
||||
|
||||
let lastElement = null;
|
||||
let lastWhitespace = null;
|
||||
|
||||
for (const child of children) {
|
||||
if (child.type === 'SvelteText' && /^\s*$/.test(child.value)) {
|
||||
lastWhitespace = child;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ELEMENT_LIKE_TYPES.has(child.type)) continue;
|
||||
|
||||
if (
|
||||
lastElement &&
|
||||
lastWhitespace &&
|
||||
child.loc.start.line - lastElement.loc.end.line === 1
|
||||
) {
|
||||
const textNode = lastWhitespace;
|
||||
|
||||
context.report({
|
||||
fix(fixer) {
|
||||
// Add a second newline so the two siblings are separated by a
|
||||
// blank line, keeping the trailing indentation.
|
||||
return fixer.replaceText(textNode, textNode.value.replace(/\n/, '\n\n'));
|
||||
},
|
||||
message: 'Expected a blank line between sibling elements.',
|
||||
node: child
|
||||
});
|
||||
}
|
||||
|
||||
lastElement = child;
|
||||
lastWhitespace = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
SvelteAwaitBlock(node) {
|
||||
checkChildren(node.children);
|
||||
checkChildren(node.then?.children);
|
||||
checkChildren(node.else?.children);
|
||||
},
|
||||
SvelteComponent(node) {
|
||||
checkChildren(node.children);
|
||||
},
|
||||
SvelteEachBlock(node) {
|
||||
checkChildren(node.children);
|
||||
checkChildren(node.else?.children);
|
||||
},
|
||||
SvelteElement(node) {
|
||||
checkChildren(node.children);
|
||||
},
|
||||
SvelteFragment(node) {
|
||||
checkChildren(node.children);
|
||||
},
|
||||
SvelteIfBlock(node) {
|
||||
checkChildren(node.children);
|
||||
checkChildren(node.else?.children);
|
||||
},
|
||||
SvelteKeyBlock(node) {
|
||||
checkChildren(node.children);
|
||||
},
|
||||
SvelteProgram(node) {
|
||||
checkChildren(node.children);
|
||||
},
|
||||
SvelteSnippetBlock(node) {
|
||||
checkChildren(node.children);
|
||||
}
|
||||
};
|
||||
},
|
||||
meta: {
|
||||
docs: { description: 'Require a blank line between sibling elements in a Svelte template.' },
|
||||
fixable: 'whitespace',
|
||||
schema: [],
|
||||
type: 'layout'
|
||||
}
|
||||
};
|
||||
// Require a blank line between consecutive class accessors (get/set). The core
|
||||
// `padding-line-between-statements` rule only handles statements, not class
|
||||
// members, so this is enforced with a small custom rule.
|
||||
const blankLineBetweenAccessors = {
|
||||
create(context) {
|
||||
return {
|
||||
MethodDefinition(node) {
|
||||
if (node.kind !== 'get' && node.kind !== 'set') return;
|
||||
|
||||
const body = node.parent;
|
||||
|
||||
if (!body || body.type !== 'ClassBody') return;
|
||||
|
||||
const index = body.body.indexOf(node);
|
||||
|
||||
if (index <= 0) return;
|
||||
|
||||
const prev = body.body[index - 1];
|
||||
|
||||
if (prev.type !== 'MethodDefinition' || (prev.kind !== 'get' && prev.kind !== 'set'))
|
||||
return;
|
||||
|
||||
if (node.loc.start.line - prev.loc.end.line <= 1) {
|
||||
context.report({
|
||||
fix(fixer) {
|
||||
// Insert after the previous accessor's closing brace so the blank
|
||||
// line keeps the current accessor's indentation.
|
||||
return fixer.insertTextAfter(prev, '\n');
|
||||
},
|
||||
message: 'Expected a blank line between class accessors (get/set).',
|
||||
node
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
meta: {
|
||||
docs: { description: 'Require a blank line between consecutive class accessors (get/set).' },
|
||||
fixable: 'whitespace',
|
||||
schema: [],
|
||||
type: 'layout'
|
||||
}
|
||||
};
|
||||
|
||||
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 } },
|
||||
plugins: {
|
||||
local: {
|
||||
rules: {
|
||||
'blank-line-between-accessors': blankLineBetweenAccessors,
|
||||
'padding-line-between-elements': paddingLineBetweenElements
|
||||
}
|
||||
},
|
||||
perfectionist,
|
||||
'simple-import-sort': simpleImportSort
|
||||
},
|
||||
rules: {
|
||||
// Snippet bodies often ignore one or more of the parent's params
|
||||
// (e.g. `{#snippet children(_meta, ctx)}` when only ctx is read).
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
|
||||
],
|
||||
|
||||
// Enforce empty line at end of file
|
||||
'eol-last': 'error',
|
||||
// Enforce a blank line between consecutive get/set accessors
|
||||
'local/blank-line-between-accessors': 'error',
|
||||
// Require a blank line between sibling elements in a Svelte template
|
||||
'local/padding-line-between-elements': 'error',
|
||||
// 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',
|
||||
|
||||
'padding-line-between-statements': [
|
||||
'error',
|
||||
// Blank line between function/class declarations.
|
||||
{ blankLine: 'always', next: ['function', 'class'], prev: ['function', 'class'] },
|
||||
// Blank line around if blocks (if/else and else if stay one statement).
|
||||
{ blankLine: 'always', next: '*', prev: 'if' },
|
||||
{ blankLine: 'always', next: 'if', prev: '*' },
|
||||
// Blank line after the last declaration in a group. Because the 'never'
|
||||
// rules below are scoped per declaration kind, a const group and a let
|
||||
// group get separated by a blank line, while same-kind declarations stay
|
||||
// together.
|
||||
{ blankLine: 'always', next: '*', prev: ['const', 'let', 'var'] },
|
||||
// No blank line between consecutive declarations of the same kind (kept
|
||||
// last so each takes precedence over the always rule above for matching
|
||||
// declaration pairs).
|
||||
{ blankLine: 'never', next: 'const', prev: 'const' },
|
||||
{ blankLine: 'never', next: 'let', prev: 'let' },
|
||||
{ blankLine: 'never', next: 'var', prev: 'var' },
|
||||
// Blank line before a statement that follows another statement in the block
|
||||
// (works for return/throw/break/continue). A blank line for a terminal
|
||||
// statement that opens a block body can't be enforced here: Prettier removes
|
||||
// the leading blank line of a block, so the two formatters would fight.
|
||||
{ blankLine: 'always', next: ['return', 'throw', 'break', 'continue'], prev: '*' }
|
||||
],
|
||||
|
||||
// Class member order: public fields -> private fields -> constructor -> getters
|
||||
// -> setters -> public methods -> private methods, alphabetical within each.
|
||||
// Svelte $derived fields must stay in dependency order (forward references are
|
||||
// rejected), so the two stores that rely on that are exempted below.
|
||||
'perfectionist/sort-classes': [
|
||||
'error',
|
||||
{
|
||||
customGroups: [
|
||||
{ groupName: 'public-field', modifiers: ['public'], selector: 'property' },
|
||||
{ groupName: 'private-field', modifiers: ['private'], selector: 'property' },
|
||||
{ groupName: 'get-method', selector: 'get-method' },
|
||||
{ groupName: 'set-method', selector: 'set-method' },
|
||||
{ groupName: 'public-method', modifiers: ['public'], selector: 'method' },
|
||||
{ groupName: 'private-method', modifiers: ['private'], selector: 'method' }
|
||||
],
|
||||
groups: [
|
||||
'public-field',
|
||||
'private-field',
|
||||
'constructor',
|
||||
'get-method',
|
||||
'set-method',
|
||||
'public-method',
|
||||
'private-method',
|
||||
'unknown'
|
||||
],
|
||||
type: 'natural',
|
||||
// Keep members in dependency order (Svelte rejects forward references in
|
||||
// $derived fields), while still sorting the rest alphabetically.
|
||||
useExperimentalDependencyDetection: true
|
||||
}
|
||||
],
|
||||
|
||||
// Alphabetical order for enum members
|
||||
'perfectionist/sort-enums': ['error', { type: 'natural' }],
|
||||
|
||||
'perfectionist/sort-objects': ['error', { type: 'natural' }],
|
||||
|
||||
// Alphabetical order for variable declarations and object keys
|
||||
'perfectionist/sort-variable-declarations': ['error', { type: 'natural' }],
|
||||
|
||||
// Sort imports alphabetically by module path, and sort named members within
|
||||
// each statement. A single catch-all group keeps the list flat (no blank-line
|
||||
// grouping); Prettier normalizes comma spacing afterwards.
|
||||
'simple-import-sort/imports': ['error', { groups: [['.*']] }],
|
||||
'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',
|
||||
|
||||
// Sort HTML attributes alphabetically in the markup. The Svelte directives
|
||||
// (bind:/use:/animate:/style:/in:/out:/transition:/class:) sort first,
|
||||
// alphabetically among themselves, then all remaining attributes sort
|
||||
// alphabetically. The rule keeps spread attributes in place and does not cross
|
||||
// them. `this` stays first on <svelte:element> because Prettier forces it there
|
||||
// - reordering it alphabetically would fight the formatter.
|
||||
'svelte/sort-attributes': [
|
||||
'error',
|
||||
{
|
||||
order: [
|
||||
'this',
|
||||
{
|
||||
match: [
|
||||
'/^bind:/u',
|
||||
'/^use:/u',
|
||||
'/^animate:/u',
|
||||
'/^style:/u',
|
||||
'/^in:/u',
|
||||
'/^out:/u',
|
||||
'/^transition:/u',
|
||||
'/^class:/u'
|
||||
],
|
||||
sort: 'alphabetical'
|
||||
},
|
||||
{
|
||||
match: [
|
||||
'!/^bind:/u',
|
||||
'!/^use:/u',
|
||||
'!/^animate:/u',
|
||||
'!/^style:/u',
|
||||
'!/^in:/u',
|
||||
'!/^out:/u',
|
||||
'!/^transition:/u',
|
||||
'!/^class:/u'
|
||||
],
|
||||
sort: 'alphabetical'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
extraFileExtensions: ['.svelte'],
|
||||
parser: ts.parser,
|
||||
projectService: true,
|
||||
svelteConfig
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
// Exclude generated build output and Storybook files from ESLint
|
||||
ignores: [
|
||||
'dist/**',
|
||||
'build/**',
|
||||
'.svelte-kit/**',
|
||||
'test-results/**',
|
||||
'.storybook/**/*',
|
||||
'src/lib/services/sandbox-worker.js',
|
||||
'src/lib/vendors/**'
|
||||
]
|
||||
},
|
||||
storybook.configs['flat/recommended']
|
||||
);
|
||||
Generated
+18165
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"name": "llama-ui",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "npm run build-pwa-assets && vite build",
|
||||
"build-pwa-assets": "pwa-assets-generator --root . --config pwa-assets.config.ts && 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": "eslint --fix . && prettier --write .",
|
||||
"format:files": "sh -c 'eslint --fix \"$@\" && prettier --write \"$@\"' sh",
|
||||
"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.5.1",
|
||||
"@eslint/compat": "1.4.1",
|
||||
"@eslint/js": "9.39.2",
|
||||
"@internationalized/date": "3.12.2",
|
||||
"@lucide/svelte": "1.46.0",
|
||||
"@modelcontextprotocol/sdk": "1.32.1",
|
||||
"@playwright/test": "1.56.1",
|
||||
"@storybook/addon-a11y": "10.6.2",
|
||||
"@storybook/addon-docs": "10.6.2",
|
||||
"@storybook/addon-mcp": "10.6.2",
|
||||
"@storybook/addon-svelte-csf": "5.1.5",
|
||||
"@storybook/addon-vitest": "10.6.2",
|
||||
"@storybook/sveltekit": "10.6.2",
|
||||
"@sveltejs/adapter-static": "3.0.10",
|
||||
"@sveltejs/kit": "2.70.2",
|
||||
"@sveltejs/vite-plugin-svelte": "6.2.1",
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"@types/node": "24.13.0",
|
||||
"@vite-pwa/assets-generator": "1.0.4",
|
||||
"@vite-pwa/sveltekit": "1.1.0",
|
||||
"@vitest/browser": "4.1.12",
|
||||
"@vitest/browser-playwright": "4.1.12",
|
||||
"@vitest/coverage-v8": "4.1.12",
|
||||
"bits-ui": "2.18.1",
|
||||
"clsx": "2.1.1",
|
||||
"dexie": "4.4.3",
|
||||
"dompurify": "3.4.17",
|
||||
"eslint": "9.39.4",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-perfectionist": "^5.10.1",
|
||||
"eslint-plugin-simple-import-sort": "^14.0.0",
|
||||
"eslint-plugin-storybook": "10.6.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.17.2",
|
||||
"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.6.2",
|
||||
"svelte": "5.56.1",
|
||||
"svelte-check": "4.6.0",
|
||||
"svelte-sonner": "1.1.1",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"tailwind-variants": "3.3.1",
|
||||
"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",
|
||||
"vaul-svelte": "1.0.0-next.7",
|
||||
"vite": "7.3.7",
|
||||
"vite-plugin-devtools-json": "0.2.1",
|
||||
"vitest": "4.1.12",
|
||||
"vitest-browser-svelte": "2.1.1",
|
||||
"workbox-window": "7.4.1"
|
||||
},
|
||||
"overrides": {
|
||||
"@storybook/svelte": "10.6.2",
|
||||
"cookie": "1.1.1",
|
||||
"katex": "0.18.11",
|
||||
"postcss-selector-parser": "7.1.6",
|
||||
"sharp": "0.35.5",
|
||||
"valibot": "1.4.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
expect: {
|
||||
timeout: 5000
|
||||
},
|
||||
forbidOnly: !!process.env.CI,
|
||||
fullyParallel: true,
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] }
|
||||
}
|
||||
],
|
||||
reporter: 'line',
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
testDir: 'tests/e2e',
|
||||
testMatch: ['**/*.e2e.ts'],
|
||||
timeout: 30000,
|
||||
use: {
|
||||
baseURL: 'http://localhost:8181',
|
||||
trace: 'on-first-retry'
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run build && npx http-server ./dist -p 8181',
|
||||
port: 8181,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000
|
||||
},
|
||||
workers: process.env.CI ? 1 : undefined
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { writeThemeFavicons } from './scripts/favicon-colorize';
|
||||
import { FAVICON_COLORS, PWA_ASSET_GENERATOR } from './src/lib/constants/pwa.constants';
|
||||
import { defineConfig } from '@vite-pwa/assets-generator/config';
|
||||
|
||||
writeThemeFavicons(FAVICON_COLORS.LIGHT, FAVICON_COLORS.DARK, {
|
||||
padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
|
||||
});
|
||||
|
||||
export default defineConfig({
|
||||
headLinkOptions: {
|
||||
preset: '2023'
|
||||
},
|
||||
images: ['static/favicon-dark.svg'],
|
||||
preset: {
|
||||
apple: {
|
||||
sizes: []
|
||||
},
|
||||
maskable: {
|
||||
sizes: []
|
||||
},
|
||||
transparent: {
|
||||
favicons: [[48, 'favicon-dark.ico']],
|
||||
padding: PWA_ASSET_GENERATOR.FAVICON_PADDING,
|
||||
sizes: []
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { writeThemeFavicons } from './scripts/favicon-colorize';
|
||||
import {
|
||||
FAVICON_COLORS,
|
||||
PWA_ASSET_GENERATOR,
|
||||
PWA_GENERATOR_DEVICES,
|
||||
THEME_COLORS
|
||||
} from './src/lib/constants/pwa.constants';
|
||||
import { SplashOrientation } from './src/lib/enums/splash.enums';
|
||||
import {
|
||||
combinePresetAndAppleSplashScreens,
|
||||
defineConfig,
|
||||
minimal2023Preset
|
||||
} from '@vite-pwa/assets-generator/config';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
writeThemeFavicons(FAVICON_COLORS.LIGHT, FAVICON_COLORS.DARK, {
|
||||
padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
|
||||
});
|
||||
|
||||
export default defineConfig({
|
||||
headLinkOptions: {
|
||||
preset: PWA_ASSET_GENERATOR.LINK_PRESET
|
||||
},
|
||||
images: ['static/favicon.svg'],
|
||||
preset: combinePresetAndAppleSplashScreens(
|
||||
{
|
||||
...minimal2023Preset,
|
||||
// tiny margin so favicon.ico / pwa-*.png breathe inside the canvas
|
||||
transparent: {
|
||||
...minimal2023Preset.transparent,
|
||||
padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
|
||||
}
|
||||
},
|
||||
{
|
||||
darkImageResolver: async (imageName: string) => {
|
||||
if (imageName.endsWith('favicon.svg')) {
|
||||
return readFileSync(resolve('static/favicon-dark.svg'));
|
||||
}
|
||||
},
|
||||
darkResizeOptions: {
|
||||
background: THEME_COLORS.BACKGROUND_DARK,
|
||||
fit: PWA_ASSET_GENERATOR.FIT_MODE
|
||||
},
|
||||
linkMediaOptions: {
|
||||
addMediaScreen: PWA_ASSET_GENERATOR.ADD_MEDIA_SCREEN,
|
||||
basePath: PWA_ASSET_GENERATOR.BASE_PATH,
|
||||
log: true,
|
||||
xhtml: PWA_ASSET_GENERATOR.XHTML
|
||||
},
|
||||
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`;
|
||||
},
|
||||
padding: PWA_ASSET_GENERATOR.SPLASH_PADDING,
|
||||
png: {
|
||||
compressionLevel: PWA_ASSET_GENERATOR.PNG_COMPRESSION_LEVEL,
|
||||
quality: PWA_ASSET_GENERATOR.PNG_QUALITY
|
||||
},
|
||||
resizeOptions: {
|
||||
background: THEME_COLORS.BACKGROUND_LIGHT,
|
||||
fit: PWA_ASSET_GENERATOR.FIT_MODE
|
||||
}
|
||||
},
|
||||
PWA_GENERATOR_DEVICES
|
||||
)
|
||||
});
|
||||
@@ -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 ci && 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
|
||||
@@ -0,0 +1,113 @@
|
||||
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 {
|
||||
dark: svg.replaceAll(CURRENT_COLOR, darkColor),
|
||||
light: svg.replaceAll(CURRENT_COLOR, lightColor)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
{
|
||||
darkOutPath = DEFAULT_OUT_DARK,
|
||||
lightOutPath = DEFAULT_OUT_LIGHT,
|
||||
padding = 0,
|
||||
sourcePath = DEFAULT_LOGO
|
||||
}: WriteThemeFaviconsOptions = {}
|
||||
): void {
|
||||
const source = readFileSync(sourcePath, 'utf-8');
|
||||
const { dark, light } = colorizeFaviconSvg(source, lightColor, darkColor);
|
||||
|
||||
mkdirSync(dirname(lightOutPath), { recursive: true });
|
||||
writeFileSync(lightOutPath, padFaviconSvg(light, padding));
|
||||
writeFileSync(darkOutPath, padFaviconSvg(dark, padding));
|
||||
}
|
||||
+35
@@ -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
@@ -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 ci' 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
@@ -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 ci' 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
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/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 fs from 'fs';
|
||||
import path from 'path';
|
||||
import sharp from 'sharp';
|
||||
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: { channels: 4, height: size, width: size }
|
||||
})
|
||||
.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, {
|
||||
background: { alpha: 1, b: 255, g: 255, r: 255 },
|
||||
fit: 'cover'
|
||||
})
|
||||
.ensureAlpha()
|
||||
.png()
|
||||
.toBuffer();
|
||||
// Step 2: Composite scaled image onto white background, then apply circular mask
|
||||
const output = await sharp({
|
||||
create: {
|
||||
background: { alpha: 1, b: 255, g: 255, r: 255 },
|
||||
channels: 4,
|
||||
height: size,
|
||||
width: size
|
||||
}
|
||||
})
|
||||
.composite([
|
||||
{ input: scaledBuf, left: offset, top: offset },
|
||||
{ blend: 'dest-in', input: tmpMask, left: 0, top: 0 }
|
||||
])
|
||||
.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,43 @@
|
||||
import { BUILD_CONFIG } from '../src/lib/constants/pwa.constants';
|
||||
import { existsSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'path';
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
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 {
|
||||
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);
|
||||
},
|
||||
name: 'llamacpp:build-info'
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { build } from 'esbuild';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const VENDORS_DIR = resolve(__dirname, '../src/lib/vendors');
|
||||
const VIRTUAL_ID = 'virtual:nerdamer';
|
||||
const RESOLVED_ID = '\0' + VIRTUAL_ID;
|
||||
|
||||
/**
|
||||
* Bundle the vendored nerdamer-prime source into a minified IIFE string,
|
||||
* exposed as the `virtual:nerdamer` module. Flags mirror the upstream
|
||||
* build (esbuild --bundle --minify --format=iife --global-name=nerdamer),
|
||||
* so only human readable source lives in the repo and minification is a
|
||||
* build artifact. Vendored under src/lib/vendors/, upstream snapshot:
|
||||
* https://github.com/together-science/nerdamer-prime/commit/1936145f8af306ec0d883b9bfd7730aedd175c24
|
||||
*/
|
||||
export function nerdamerPlugin(): Plugin {
|
||||
let bundled: string | null = null;
|
||||
|
||||
return {
|
||||
async load(id) {
|
||||
if (id !== RESOLVED_ID) return undefined;
|
||||
|
||||
if (bundled === null) {
|
||||
const result = await build({
|
||||
alias: {
|
||||
'big-integer': resolve(VENDORS_DIR, 'big-integer/BigInteger.js'),
|
||||
'decimal.js': resolve(VENDORS_DIR, 'decimal.js/decimal.js')
|
||||
},
|
||||
bundle: true,
|
||||
entryPoints: [resolve(VENDORS_DIR, 'nerdamer-prime/all.js')],
|
||||
format: 'iife',
|
||||
globalName: 'nerdamer',
|
||||
logLevel: 'silent',
|
||||
minify: true,
|
||||
write: false
|
||||
});
|
||||
|
||||
bundled = result.outputFiles[0].text;
|
||||
}
|
||||
|
||||
return `export default ${JSON.stringify(bundled)};`;
|
||||
},
|
||||
name: 'llamacpp:nerdamer',
|
||||
resolveId(id) {
|
||||
return id === VIRTUAL_ID ? RESOLVED_ID : undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { BUILD_CONFIG } from '../src/lib/constants/pwa.constants';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'path';
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
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 {
|
||||
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);
|
||||
},
|
||||
name: 'llamacpp:relativize-base'
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
APPLE_DEVICES,
|
||||
BUILD_CONFIG,
|
||||
REGEX_PATTERNS,
|
||||
SPLASH_LINK
|
||||
} from '../src/lib/constants/pwa.constants';
|
||||
import { NEWLINE, TAB } from '../src/lib/constants/special-characters.constants';
|
||||
import { SplashOrientation } from '../src/lib/enums/splash.enums';
|
||||
import type { SplashDimensions } from '../src/lib/types';
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'path';
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
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}`, { deviceH: spec.height, deviceW: spec.width, dpr: spec.dpr });
|
||||
dimMap.set(`${h}x${w}`, { deviceH: spec.height, deviceW: spec.width, dpr: spec.dpr });
|
||||
// pixel dimensions (used by actual generated splash files)
|
||||
dimMap.set(`${w * spec.dpr}x${h * spec.dpr}`, {
|
||||
deviceH: spec.height,
|
||||
deviceW: spec.width,
|
||||
dpr: spec.dpr
|
||||
});
|
||||
dimMap.set(`${h * spec.dpr}x${w * spec.dpr}`, {
|
||||
deviceH: spec.height,
|
||||
deviceW: spec.width,
|
||||
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 { deviceH, deviceW, 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 {
|
||||
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);
|
||||
},
|
||||
name: 'llamacpp:splash-screen'
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
!*
|
||||
@@ -0,0 +1,284 @@
|
||||
@import 'tailwindcss';
|
||||
@source '.';
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* shadcn components key their orientation styles off these, toggle-group among them */
|
||||
@custom-variant data-horizontal (&[data-orientation='horizontal']);
|
||||
@custom-variant data-vertical (&[data-orientation='vertical']);
|
||||
|
||||
/*
|
||||
* Tailwind v4 dropped the pointer cursor from buttons. Every control a user can
|
||||
* press gets it back, and anything disabled keeps the cursor its own class asks for.
|
||||
*/
|
||||
@layer base {
|
||||
button:not(:disabled),
|
||||
[role='button']:not([data-disabled]),
|
||||
[role='checkbox']:not([data-disabled]),
|
||||
[role='combobox']:not([data-disabled]),
|
||||
[role='menuitem']:not([data-disabled]),
|
||||
[role='menuitemcheckbox']:not([data-disabled]),
|
||||
[role='menuitemradio']:not([data-disabled]),
|
||||
[role='option']:not([data-disabled]),
|
||||
[role='radio']:not([data-disabled]),
|
||||
[role='switch']:not([data-disabled]),
|
||||
[role='tab']:not([data-disabled]),
|
||||
label[for],
|
||||
select,
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
/* expanded desktop sidebar width, overridden once the user drags its edge */
|
||||
--sidebar-width: 18rem;
|
||||
--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 antialiased;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* no elastic bounce past the ends of the page: it drags the sticky and
|
||||
fixed bars (tab bar, chat form) along and they jump back on release */
|
||||
html,
|
||||
body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* same for dialogs, whose panels and lists scroll on their own with sticky
|
||||
headers and footers. Scroll containers only: Firefox treats an
|
||||
overflow-hidden box (truncated text) as one, and with `none` it would
|
||||
keep the wheel from reaching the list around it */
|
||||
:is([role='dialog'], [role='alertdialog']),
|
||||
:is([role='dialog'], [role='alertdialog'])
|
||||
:is(.overflow-auto, .overflow-y-auto, [data-slot='scroll-area-viewport']) {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Fade at the top edge of the chat, under the tab bar or a sticky header */
|
||||
.chat-top-fade {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
color-mix(in srgb, var(--background) 100%, transparent) 25%,
|
||||
color-mix(in srgb, var(--background) 80%, transparent) 50%,
|
||||
color-mix(in srgb, var(--background) 40%, transparent) 75%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.shimmer-text {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--muted-foreground),
|
||||
var(--foreground),
|
||||
var(--muted-foreground)
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
font-weight: 500;
|
||||
animation: shimmer 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.shimmer-text {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mermaidTooltip {
|
||||
display: none !important;
|
||||
}
|
||||
Vendored
+143
@@ -0,0 +1,143 @@
|
||||
// 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 { ModelModality, ServerModelStatus, ServerRole } from '$lib/enums';
|
||||
// Import chat types from dedicated module
|
||||
import type {
|
||||
// API types
|
||||
ApiChatCompletionRequest,
|
||||
ApiChatCompletionResponse,
|
||||
ApiChatCompletionStreamChunk,
|
||||
ApiChatCompletionToolCall,
|
||||
ApiChatCompletionToolCallDelta,
|
||||
ApiChatMessageContentPart,
|
||||
ApiChatMessageData,
|
||||
ApiContextSizeError,
|
||||
ApiErrorResponse,
|
||||
ApiLlamaCppServerProps,
|
||||
ApiModelDataEntry,
|
||||
ApiModelLoadStage,
|
||||
ApiModelsDownloadRequest,
|
||||
ApiModelsDownloadResponse,
|
||||
ApiModelsListResponse,
|
||||
ApiModelsLoadResponse,
|
||||
ApiModelsSseData,
|
||||
ApiModelsSseEvent,
|
||||
ApiModelsSseProgress,
|
||||
ApiModelsUnloadResponse,
|
||||
ApiProcessingState,
|
||||
ChatAttachmentDisplayItem,
|
||||
// Chat types
|
||||
ChatMessagePromptProgress,
|
||||
ChatMessageSiblingInfo,
|
||||
ChatMessageTimings,
|
||||
ChatMessageType,
|
||||
ChatRole,
|
||||
ChatUploadedFile,
|
||||
// Database types
|
||||
DatabaseConversation,
|
||||
DatabaseMessage,
|
||||
DatabaseMessageExtra,
|
||||
DatabaseMessageExtraAudioFile,
|
||||
DatabaseMessageExtraImageFile,
|
||||
DatabaseMessageExtraLegacyContext,
|
||||
DatabaseMessageExtraPdfFile,
|
||||
DatabaseMessageExtraTextFile,
|
||||
DatabaseMessageExtraVideoFile,
|
||||
ExportedConversation,
|
||||
ExportedConversations,
|
||||
ModelLoadProgress,
|
||||
// Model types
|
||||
ModelModalities,
|
||||
ModelOption,
|
||||
// Settings types
|
||||
SettingsChatServiceOptions,
|
||||
SettingsConfigType,
|
||||
SettingsConfigValue,
|
||||
SettingsFieldConfig
|
||||
} from '$lib/types';
|
||||
|
||||
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,
|
||||
ModelDownloadProgress,
|
||||
ApiModelsSseProgress,
|
||||
ApiModelsSseData,
|
||||
ApiModelsSseDownloadProgressData,
|
||||
ApiModelsSseEvent,
|
||||
ApiModelsListResponse,
|
||||
ApiModelsLoadResponse,
|
||||
ApiModelsDownloadRequest,
|
||||
ApiModelsDownloadResponse,
|
||||
ApiModelsUnloadResponse,
|
||||
ApiProcessingState,
|
||||
// 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 {
|
||||
idxCodeBlock?: number;
|
||||
|
||||
// File System Access API - not in the DOM lib and unavailable in some browsers
|
||||
showDirectoryPicker?: (options?: {
|
||||
id?: string;
|
||||
mode?: 'read' | 'readwrite';
|
||||
startIn?: FileSystemHandle | string;
|
||||
}) => Promise<FileSystemDirectoryHandle>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<!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,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 |
@@ -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,91 @@
|
||||
<script lang="ts">
|
||||
import { Button, type ButtonSize, type ButtonVariant } from '$lib/components/ui/button';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { TooltipSide } from '$lib/enums';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
ariaLabel?: string;
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
href?: string;
|
||||
icon: Component;
|
||||
iconSize?: string;
|
||||
onclick?: (e?: MouseEvent) => void;
|
||||
size?: ButtonSize;
|
||||
stopPropagationOnClick?: boolean;
|
||||
tooltip?: string;
|
||||
tooltipAsTitle?: boolean;
|
||||
tooltipSide?: TooltipSide;
|
||||
variant?: ButtonVariant;
|
||||
}
|
||||
|
||||
let {
|
||||
ariaLabel,
|
||||
class: className = '',
|
||||
disabled = false,
|
||||
href = '',
|
||||
icon,
|
||||
iconSize = 'h-3 w-3',
|
||||
onclick,
|
||||
size = 'sm',
|
||||
stopPropagationOnClick = false,
|
||||
tooltip,
|
||||
tooltipAsTitle = false,
|
||||
tooltipSide = TooltipSide.TOP,
|
||||
variant = 'ghost'
|
||||
}: Props = $props();
|
||||
|
||||
let innerWidth = $state(0);
|
||||
const showTooltip = $derived(!!tooltip && !tooltipAsTitle && innerWidth > 768);
|
||||
</script>
|
||||
|
||||
{#snippet button(props = {})}
|
||||
<Button
|
||||
{...props}
|
||||
aria-label={ariaLabel || tooltip}
|
||||
class="h-6 w-6 p-0 {className} flex hover:bg-transparent data-[state=open]:bg-transparent!"
|
||||
{disabled}
|
||||
{href}
|
||||
onclick={(e: MouseEvent) => {
|
||||
if (stopPropagationOnClick) e.stopPropagation();
|
||||
|
||||
onclick?.(e);
|
||||
}}
|
||||
{size}
|
||||
title={tooltipAsTitle ? tooltip : undefined}
|
||||
{variant}
|
||||
>
|
||||
{#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 })}
|
||||
{#if disabled}
|
||||
<!-- disabled buttons have pointer-events:none; wrap in a span so the tooltip hover surface stays alive -->
|
||||
<span {...props}>
|
||||
{@render button({})}
|
||||
</span>
|
||||
{:else}
|
||||
{@render button(props)}
|
||||
{/if}
|
||||
{/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,18 @@
|
||||
<script lang="ts">
|
||||
import ActionIcon from './ActionIcon.svelte';
|
||||
import { Copy } from '@lucide/svelte';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
|
||||
export let ariaLabel: string = 'Copy to clipboard';
|
||||
export let canCopy: boolean = true;
|
||||
export let text: string;
|
||||
</script>
|
||||
|
||||
<ActionIcon
|
||||
disabled={!canCopy}
|
||||
icon={Copy}
|
||||
iconSize={ICON_CLASS_DEFAULT}
|
||||
onclick={() => canCopy && copyToClipboard(text)}
|
||||
tooltip={ariaLabel}
|
||||
/>
|
||||
@@ -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,29 @@
|
||||
<script lang="ts">
|
||||
import { MODALITY_ICONS, MODALITY_LABELS } from '$lib/constants';
|
||||
import { ModelModality } from '$lib/enums';
|
||||
|
||||
interface Props {
|
||||
modalities: ModelModality[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className = '', modalities }: Props = $props();
|
||||
|
||||
const shownModalities = [ModelModality.VISION, ModelModality.AUDIO, ModelModality.VIDEO] as const;
|
||||
|
||||
let visible = $derived(shownModalities.filter((modality) => modalities.includes(modality)));
|
||||
</script>
|
||||
|
||||
{#each visible as modality (modality)}
|
||||
{@const ModalityIcon = MODALITY_ICONS[modality]}
|
||||
<span
|
||||
class={[
|
||||
'inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium',
|
||||
className
|
||||
]}
|
||||
>
|
||||
<ModalityIcon class="h-3 w-3" />
|
||||
|
||||
{MODALITY_LABELS[modality]}
|
||||
</span>
|
||||
{/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';
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ChatAttachmentsListItem,
|
||||
DialogChatAttachmentsPreview,
|
||||
DialogMcpResourcePreview,
|
||||
ScrollCarousel
|
||||
} from '$lib/components/app';
|
||||
import { ScrollCarouselVariant } from '$lib/enums';
|
||||
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 {
|
||||
activeModelId,
|
||||
attachments = [],
|
||||
class: className = '',
|
||||
// Default to small size for form previews
|
||||
imageClass = '',
|
||||
imageHeight = 'h-24',
|
||||
imageWidth = 'w-auto',
|
||||
limitToSingleRow = false,
|
||||
onFileRemove,
|
||||
readonly = false,
|
||||
style = '',
|
||||
uploadedFiles = $bindable([])
|
||||
}: Props = $props();
|
||||
|
||||
let carouselRef: ScrollCarousel | undefined = $state();
|
||||
let mcpResourcePreviewOpen = $state(false);
|
||||
let mcpResourcePreviewExtra = $state<DatabaseMessageExtraMcpResource | null>(null);
|
||||
let previewFocusIndex = $state(0);
|
||||
let viewAllDialogOpen = $state(false);
|
||||
|
||||
let displayItems = $derived(getAttachmentDisplayItems({ attachments, uploadedFiles }));
|
||||
|
||||
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}
|
||||
<ScrollCarousel bind:this={carouselRef} variant={ScrollCarouselVariant.CENTER}>
|
||||
{#each displayItems as item (item.id)}
|
||||
{@render attachmentitem(item)}
|
||||
{/each}
|
||||
</ScrollCarousel>
|
||||
{: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
|
||||
bind:open={viewAllDialogOpen}
|
||||
{activeModelId}
|
||||
{attachments}
|
||||
{previewFocusIndex}
|
||||
{uploadedFiles}
|
||||
/>
|
||||
|
||||
{#if mcpResourcePreviewExtra}
|
||||
<DialogMcpResourcePreview bind:open={mcpResourcePreviewOpen} extra={mcpResourcePreviewExtra} />
|
||||
{/if}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ChatAttachmentsListItemMcpPrompt,
|
||||
ChatAttachmentsListItemMcpResource,
|
||||
ChatAttachmentsListItemThumbnailFile,
|
||||
ChatAttachmentsListItemThumbnailImage
|
||||
} 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' : '');
|
||||
// Carousel items must keep their width; wrapped attachments (message bubbles)
|
||||
// shrink so wide images fit the bubble instead of overflowing it
|
||||
const layoutClasses = $derived(limitToSingleRow ? 'flex-shrink-0' : 'min-w-0');
|
||||
|
||||
function toMcpResourceAttachment(
|
||||
extra: DatabaseMessageExtraMcpResource,
|
||||
id: string
|
||||
): MCPResourceAttachment {
|
||||
return {
|
||||
id,
|
||||
resource: {
|
||||
name: extra.name,
|
||||
serverName: extra.serverName,
|
||||
title: extra.name,
|
||||
uri: extra.uri
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isMcpPrompt(item)}
|
||||
{@const mcpPrompt =
|
||||
item.attachment?.type === AttachmentType.MCP_PROMPT
|
||||
? (item.attachment as DatabaseMessageExtraMcpPrompt)
|
||||
: item.uploadedFile?.mcpPrompt
|
||||
? {
|
||||
arguments: item.uploadedFile.mcpPrompt.arguments,
|
||||
content: item.textContent ?? '',
|
||||
name: item.name,
|
||||
promptName: item.uploadedFile.mcpPrompt.promptName,
|
||||
serverName: item.uploadedFile.mcpPrompt.serverName,
|
||||
type: AttachmentType.MCP_PROMPT as const
|
||||
}
|
||||
: null}
|
||||
{#if mcpPrompt}
|
||||
<ChatAttachmentsListItemMcpPrompt
|
||||
class="max-w-[300px] min-w-[200px] flex-shrink-0 {className} {scrollClasses}"
|
||||
isLoading={item.isLoading}
|
||||
loadError={item.loadError}
|
||||
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
|
||||
prompt={mcpPrompt}
|
||||
{readonly}
|
||||
/>
|
||||
{/if}
|
||||
{:else if isMcpResource(item)}
|
||||
{@const mcpResource = item.attachment as DatabaseMessageExtraMcpResource}
|
||||
|
||||
<ChatAttachmentsListItemMcpResource
|
||||
attachment={toMcpResourceAttachment(mcpResource, item.id)}
|
||||
class="flex-shrink-0 {className} {scrollClasses}"
|
||||
onclick={() => onMcpResourcePreview?.(mcpResource)}
|
||||
/>
|
||||
{:else if item.isImage && item.preview}
|
||||
<ChatAttachmentsListItemThumbnailImage
|
||||
class="{layoutClasses} cursor-pointer {className} {scrollClasses}"
|
||||
height={imageHeight}
|
||||
id={item.id}
|
||||
{imageClass}
|
||||
name={item.name}
|
||||
onRemove={onFileRemove}
|
||||
onclick={() => onPreview?.(item)}
|
||||
preview={item.preview}
|
||||
{readonly}
|
||||
width={imageWidth}
|
||||
/>
|
||||
{:else if isPdfFile(item.attachment, item.uploadedFile)}
|
||||
<ChatAttachmentsListItemThumbnailFile
|
||||
attachment={item.attachment}
|
||||
class="flex-shrink-0 cursor-pointer {className} {scrollClasses}"
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
onRemove={onFileRemove}
|
||||
onclick={() => onPreview?.(item)}
|
||||
{readonly}
|
||||
size={item.size}
|
||||
textContent={item.textContent}
|
||||
uploadedFile={item.uploadedFile}
|
||||
/>
|
||||
{:else}
|
||||
<ChatAttachmentsListItemThumbnailFile
|
||||
attachment={item.attachment}
|
||||
class="flex-shrink-0 cursor-pointer {className} {scrollClasses}"
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
onRemove={onFileRemove}
|
||||
onclick={() => onPreview?.(item)}
|
||||
{readonly}
|
||||
size={item.size}
|
||||
textContent={item.textContent}
|
||||
uploadedFile={item.uploadedFile}
|
||||
/>
|
||||
{/if}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { X } from '@lucide/svelte';
|
||||
import { ActionIcon, ChatMessageMcpPromptContent } from '$lib/components/app';
|
||||
import { McpPromptVariant } from '$lib/enums';
|
||||
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
|
||||
|
||||
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} onclick={() => onRemove?.()} stopPropagationOnClick tooltip="Remove" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { AlertCircle, Loader2 } from '@lucide/svelte';
|
||||
import { X } from '@lucide/svelte';
|
||||
import { ActionIcon } from '$lib/components/app';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { mcpStore } from '$lib/stores';
|
||||
import type { MCPResourceAttachment } from '$lib/types';
|
||||
import { getResourceDisplayName, getResourceIcon } 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>
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
<script lang="ts">
|
||||
import { Music, Video, X } from '@lucide/svelte';
|
||||
import { ActionIcon } from '$lib/components/app';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { AttachmentType } from '$lib/enums';
|
||||
import {
|
||||
formatFileSize,
|
||||
getFileTypeLabel,
|
||||
getPreviewText,
|
||||
isAudioFile,
|
||||
isPdfFile,
|
||||
isTextFile,
|
||||
isVideoFile
|
||||
} from '$lib/utils';
|
||||
|
||||
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,
|
||||
name,
|
||||
onclick,
|
||||
onRemove,
|
||||
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} onclick={() => onRemove?.(id)} stopPropagationOnClick tooltip="Remove" />
|
||||
</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="{ICON_CLASS_DEFAULT} text-white/70" />
|
||||
{:else if isVideo}
|
||||
<Video class="{ICON_CLASS_DEFAULT} 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}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { X } from '@lucide/svelte';
|
||||
import { ActionIcon } from '$lib/components/app';
|
||||
|
||||
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 = '',
|
||||
name,
|
||||
onclick,
|
||||
onRemove,
|
||||
preview,
|
||||
readonly = false,
|
||||
width = 'w-auto'
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#snippet image()}
|
||||
<img alt={name} class="{height} {width} cursor-pointer object-cover {imageClass}" src={preview} />
|
||||
{/snippet}
|
||||
|
||||
<div
|
||||
class="group relative min-w-0 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>
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ChatAttachmentsPreviewCurrentItem,
|
||||
ChatAttachmentsPreviewFileInfo,
|
||||
ChatAttachmentsPreviewNavButtons,
|
||||
ChatAttachmentsPreviewThumbnailStrip
|
||||
} from '$lib/components/app';
|
||||
import { UI_DATA_ATTRS } from '$lib/constants';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import {
|
||||
createBase64DataUrl,
|
||||
formatFileSize,
|
||||
getAttachmentDisplayItems,
|
||||
getLanguageFromFilename,
|
||||
isAudioFile,
|
||||
isImageFile,
|
||||
isMcpPrompt,
|
||||
isMcpResource,
|
||||
isPdfFile,
|
||||
isTextFile,
|
||||
isVideoFile
|
||||
} 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 {
|
||||
activeModelId,
|
||||
attachments = [],
|
||||
class: className = '',
|
||||
previewFocusIndex = 0,
|
||||
uploadedFiles = []
|
||||
}: Props = $props();
|
||||
|
||||
let allItems = $derived(
|
||||
getAttachmentDisplayItems({ attachments, uploadedFiles })
|
||||
.filter((item) => !isMcpPrompt(item) && !isMcpResource(item))
|
||||
.map(
|
||||
(item): PreviewItem => ({
|
||||
...item,
|
||||
isAudio: isAudioFile(item.attachment, item.uploadedFile),
|
||||
isImage: isImageFile(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(`[${UI_DATA_ATTRS.THUMBNAIL_INDEX}="${index}"]`);
|
||||
|
||||
thumbnail?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||||
}, 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.props.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 onNext={next} onPrev={prev} 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
|
||||
{activeModelId}
|
||||
{audioSrc}
|
||||
{currentItem}
|
||||
{displayPreview}
|
||||
{displayTextContent}
|
||||
{hasVisionModality}
|
||||
{isAudio}
|
||||
{isImage}
|
||||
{isPdf}
|
||||
{isText}
|
||||
{isVideo}
|
||||
{language}
|
||||
{videoSrc}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ChatAttachmentsPreviewThumbnailStrip {currentIndex} items={allItems} {onNavigate} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import ChatAttachmentsPreviewCurrentItemAudio from './ChatAttachmentsPreviewCurrentItemAudio.svelte';
|
||||
import ChatAttachmentsPreviewCurrentItemImage from './ChatAttachmentsPreviewCurrentItemImage.svelte';
|
||||
import ChatAttachmentsPreviewCurrentItemPdf from './ChatAttachmentsPreviewCurrentItemPdf.svelte';
|
||||
import ChatAttachmentsPreviewCurrentItemText from './ChatAttachmentsPreviewCurrentItemText.svelte';
|
||||
import ChatAttachmentsPreviewCurrentItemUnavailable from './ChatAttachmentsPreviewCurrentItemUnavailable.svelte';
|
||||
import ChatAttachmentsPreviewCurrentItemVideo from './ChatAttachmentsPreviewCurrentItemVideo.svelte';
|
||||
import { FileIcon, FileText, Image, Music, Video } from '@lucide/svelte';
|
||||
import type { ChatAttachmentDisplayItem } from '$lib/types';
|
||||
|
||||
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 {
|
||||
activeModelId,
|
||||
audioSrc,
|
||||
currentItem,
|
||||
displayPreview,
|
||||
displayTextContent,
|
||||
hasVisionModality,
|
||||
isAudio,
|
||||
isImage,
|
||||
isPdf,
|
||||
isText,
|
||||
isVideo,
|
||||
language,
|
||||
videoSrc
|
||||
}: 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
|
||||
{activeModelId}
|
||||
{currentItem}
|
||||
displayName={currentItem.name}
|
||||
{displayTextContent}
|
||||
{hasVisionModality}
|
||||
/>
|
||||
{:else if isImage}
|
||||
<ChatAttachmentsPreviewCurrentItemImage {currentItem} {displayPreview} />
|
||||
{:else if isText && displayTextContent}
|
||||
<ChatAttachmentsPreviewCurrentItemText {displayTextContent} {language} />
|
||||
{:else if isAudio}
|
||||
<ChatAttachmentsPreviewCurrentItemAudio {audioSrc} {currentItem} />
|
||||
{:else if isVideo}
|
||||
<ChatAttachmentsPreviewCurrentItemVideo {currentItem} {videoSrc} />
|
||||
{:else if isUnavailable}
|
||||
<ChatAttachmentsPreviewCurrentItemUnavailable {IconComponent} />
|
||||
{/if}
|
||||
{/key}
|
||||
{/if}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { Music } from '@lucide/svelte';
|
||||
|
||||
interface Props {
|
||||
currentItem: { name?: string } | null;
|
||||
audioSrc: string | null;
|
||||
}
|
||||
|
||||
let { audioSrc, currentItem }: 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 class="mb-4 w-full" controls 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>
|
||||
+18
@@ -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
|
||||
alt={currentItem?.name || 'preview'}
|
||||
class="max-h-[80vh] max-w-[80vw] rounded-lg object-contain shadow-lg"
|
||||
src={displayPreview}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
<script lang="ts">
|
||||
import { Eye, FileText, Info } from '@lucide/svelte';
|
||||
import { SyntaxHighlightedCode } from '$lib/components/app';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { PdfViewMode } from '$lib/enums';
|
||||
import type { ChatAttachmentDisplayItem } from '$lib/types';
|
||||
import { getLanguageFromFilename } from '$lib/utils';
|
||||
import { convertPDFToImage } from '$lib/utils/browser-only';
|
||||
|
||||
interface Props {
|
||||
currentItem: ChatAttachmentDisplayItem | null;
|
||||
displayName: string;
|
||||
displayTextContent: string | undefined;
|
||||
hasVisionModality: boolean;
|
||||
activeModelId?: string;
|
||||
}
|
||||
|
||||
let { activeModelId, currentItem, displayName, displayTextContent, hasVisionModality }: 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
|
||||
disabled={pdfImagesLoading}
|
||||
onclick={() => (pdfViewMode = PdfViewMode.TEXT)}
|
||||
size="sm"
|
||||
variant={pdfViewMode === PdfViewMode.TEXT ? 'default' : 'outline'}
|
||||
>
|
||||
<FileText class="mr-1 {ICON_CLASS_DEFAULT}" />
|
||||
Text
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
disabled={pdfImagesLoading}
|
||||
onclick={() => (pdfViewMode = PdfViewMode.PAGES)}
|
||||
size="sm"
|
||||
variant={pdfViewMode === PdfViewMode.PAGES ? 'default' : 'outline'}
|
||||
>
|
||||
{#if pdfImagesLoading}
|
||||
<div
|
||||
class="mr-1 {ICON_CLASS_DEFAULT} animate-spin rounded-full border-2 border-current border-t-transparent"
|
||||
></div>
|
||||
{:else}
|
||||
<Eye class="mr-1 {ICON_CLASS_DEFAULT}" />
|
||||
{/if}
|
||||
Pages
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if !hasVisionModality && activeModelId && currentItem}
|
||||
<Alert.Root class="mb-4 max-w-4xl">
|
||||
<Info class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<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 alt="PDF Page {index + 1}" class="mx-auto max-w-[85vw] rounded-lg shadow-lg" src={image} />
|
||||
|
||||
<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}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<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 min-h-0 min-w-0 max-w-full md:max-w-[80vw]">
|
||||
<SyntaxHighlightedCode code={displayTextContent} {language} maxHeight="80dvh" />
|
||||
</div>
|
||||
{/if}
|
||||
+17
@@ -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>
|
||||
+27
@@ -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 class="mb-4 w-full" controls 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>
|
||||
+16
@@ -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>
|
||||
+34
@@ -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 { onNext, onPrev, show }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
<Button
|
||||
aria-label="Previous"
|
||||
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}
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
>
|
||||
<ChevronLeft class="size-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
aria-label="Next"
|
||||
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}
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
>
|
||||
<ChevronRight class="size-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { FileText, Music, Video } from '@lucide/svelte';
|
||||
import { ScrollCarousel } from '$lib/components/app';
|
||||
import { ICON_CLASS_DEFAULT, UI_DATA_ATTRS } from '$lib/constants';
|
||||
import { ScrollCarouselVariant } from '$lib/enums';
|
||||
|
||||
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 { currentIndex, items, 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">
|
||||
<ScrollCarousel class="max-w-full" variant={ScrollCarouselVariant.CENTER}>
|
||||
{#each items as item, index (item.id)}
|
||||
<button
|
||||
{...{ [UI_DATA_ATTRS.THUMBNAIL_INDEX]: index }}
|
||||
aria-label={`Go to ${item.name}`}
|
||||
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)}
|
||||
>
|
||||
{#if item.isImage && item.preview}
|
||||
<img alt={item.name} class="h-12 w-12 object-cover" src={item.preview} />
|
||||
{: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="{ICON_CLASS_DEFAULT} text-white/70" />
|
||||
{:else if item.isVideo}
|
||||
<Video class="{ICON_CLASS_DEFAULT} text-white/70" />
|
||||
{:else}
|
||||
<FileText class="{ICON_CLASS_DEFAULT} text-white/70" />
|
||||
{/if}
|
||||
|
||||
<span class="font-mono text-[9px] text-white/60">{getFileExtension(item.name)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</ScrollCarousel>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,677 @@
|
||||
<script lang="ts">
|
||||
import ContextGaugePopup from './ChatFormContextGauge/ContextGaugePopup.svelte';
|
||||
import {
|
||||
ChatAttachmentsList,
|
||||
ChatFormActions,
|
||||
ChatFormCurrentWorkingDirectory,
|
||||
ChatFormInput,
|
||||
ChatFormInputFileInputInvisible,
|
||||
ChatFormMcpResourcesList,
|
||||
ChatFormPickers,
|
||||
DialogMcpResourcesBrowser
|
||||
} from '$lib/components/app';
|
||||
import {
|
||||
CLIPBOARD_CONTENT_QUOTE_PREFIX,
|
||||
INITIAL_FILE_SIZE,
|
||||
INPUT_CLASSES,
|
||||
PROMPT_CONTENT_SEPARATOR,
|
||||
SETTING_CONFIG_DEFAULT
|
||||
} from '$lib/constants';
|
||||
import {
|
||||
ContentPartType,
|
||||
FileExtensionText,
|
||||
KeyboardKey,
|
||||
MimeTypeText,
|
||||
SpecialFileType,
|
||||
ToolSource
|
||||
} from '$lib/enums';
|
||||
import { useChatFormPickers } from '$lib/hooks/use-chat-form-pickers.svelte';
|
||||
import {
|
||||
chatStore,
|
||||
conversationsStore,
|
||||
mcpStore,
|
||||
modelsStore,
|
||||
serverStore,
|
||||
settingsStore,
|
||||
toolsStore,
|
||||
uiStore
|
||||
} from '$lib/stores';
|
||||
import type {
|
||||
FileMentionEntry,
|
||||
GetPromptResult,
|
||||
MCPPromptInfo,
|
||||
MCPResourceInfo,
|
||||
PromptMessage
|
||||
} from '$lib/types';
|
||||
import {
|
||||
buildMentionInsertion,
|
||||
containsCodeSpan,
|
||||
containsFileMentionLink,
|
||||
findCommandToken,
|
||||
findMentionToken,
|
||||
getConversationModel,
|
||||
isIMEComposing,
|
||||
isOffsetInCodeBlock,
|
||||
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;
|
||||
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,
|
||||
onAttachmentRemove,
|
||||
onFilesAdd,
|
||||
onStop,
|
||||
onSubmit,
|
||||
onSystemPromptClick,
|
||||
onUploadedFileRemove,
|
||||
onUploadedFilesChange,
|
||||
onValueChange,
|
||||
placeholder = 'Type a message...',
|
||||
showAddButton = true,
|
||||
showModelSelector = true,
|
||||
uploadedFiles = $bindable([]),
|
||||
value = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
// Component References
|
||||
// Shared handle of the two input renderers (plain textarea + rich chat form input).
|
||||
type ChatInputHandle = {
|
||||
focus(): void;
|
||||
resetHeight(): void;
|
||||
getElement(): HTMLElement | undefined;
|
||||
getCaretOffset(): number;
|
||||
setCaretOffset(offset: number): void;
|
||||
};
|
||||
|
||||
let audioRecorder: AudioRecorder | undefined;
|
||||
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
|
||||
let fileInputRef: ChatFormInputFileInputInvisible | undefined = $state(undefined);
|
||||
let pickersRef: { handleKeydown: (event: KeyboardEvent) => boolean } | undefined =
|
||||
$state(undefined);
|
||||
let inputRef: ChatInputHandle | undefined = $state(undefined);
|
||||
|
||||
// Render-mode gate: the plain textarea by default, the rich chat form input
|
||||
// while the buffer carries a `file://` mention link or a complete code
|
||||
// span (badges and code chips need a DOM the textarea cannot provide).
|
||||
// Demotes back once neither remains.
|
||||
let useRichInput = $state(false);
|
||||
|
||||
// Audio Recording State
|
||||
let isRecording = $state(false);
|
||||
let recordingSupported = $state(false);
|
||||
|
||||
// Invisible anchor at the form's top edge so the mention/WD popovers
|
||||
// float above the box.
|
||||
let mentionAnchor: HTMLDivElement | null = $state(null);
|
||||
|
||||
let cwd = $derived(
|
||||
conversationsStore.activeConversation?.cwd ?? conversationsStore.preferences.pendingCwd
|
||||
);
|
||||
|
||||
const pickers = useChatFormPickers({
|
||||
focusInput: refocusInput,
|
||||
getCaretOffset: () => inputRef?.getCaretOffset(),
|
||||
getCwd: () => cwd,
|
||||
getPickersRef: () => pickersRef,
|
||||
getServerHome: () => toolsStore.serverHome ?? null,
|
||||
getShowModelSelector: () => showModelSelector,
|
||||
getValue: () => value,
|
||||
hasCwdTools: () => conversationsStore.preferences.hasEnabledCwdTools(),
|
||||
// policy-aware, same rule as the agentic flow: MCP category on and at
|
||||
// least one globally-enabled server whose group key is not disabled
|
||||
hasPrompts: () => {
|
||||
const prefs = conversationsStore.preferences;
|
||||
|
||||
if (!prefs.isCategoryEnabled(ToolSource.MCP)) return false;
|
||||
|
||||
return mcpStore
|
||||
.getServers()
|
||||
.some((s) => s.enabled && prefs.isServerToolsEnabled(s.id) && s.url.trim());
|
||||
},
|
||||
openModelSelector: () => chatFormActionsRef?.openModelSelector(),
|
||||
setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
|
||||
setValue: (v) => {
|
||||
value = v;
|
||||
onValueChange?.(v);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleWorkingDirectoryChange(newDir: string | null) {
|
||||
// Committing a directory consumes the `/cwd` token; the chip's
|
||||
// clear-X path has no token to consume.
|
||||
const token = findCommandToken(value);
|
||||
|
||||
if (token && token.name === 'cwd') {
|
||||
value = '';
|
||||
onValueChange?.('');
|
||||
}
|
||||
|
||||
await conversationsStore.preferences.setCwd(newDir);
|
||||
|
||||
if (conversationsStore.activeConversation) {
|
||||
await chatStore.recordCwdChange(newDir?.trim() || null);
|
||||
}
|
||||
}
|
||||
|
||||
// Resource Dialog State
|
||||
let isResourceDialogOpen = $state(false);
|
||||
let preSelectedResourceUri = $state<string | undefined>(undefined);
|
||||
|
||||
// MCP Servers Dialog State
|
||||
|
||||
let currentConfig = $derived(settingsStore.config);
|
||||
|
||||
let pasteLongTextToFileLength = $derived.by(() => {
|
||||
const n = Number(currentConfig.pasteLongTextToFileLen);
|
||||
|
||||
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
|
||||
});
|
||||
|
||||
let isRouter = $derived(serverStore.isRouterMode);
|
||||
let conversationModel = $derived(
|
||||
getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
|
||||
);
|
||||
let activeModelId = $derived(modelsStore.activeModelId);
|
||||
|
||||
let hasModelSelected = $derived(
|
||||
!isRouter || !!conversationModel || !!modelsStore.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);
|
||||
|
||||
// Caret offset restored after a renderer swap. Callers that mutate
|
||||
// `value` themselves (e.g. the mention picker) pin the target offset
|
||||
// BEFORE the assignment; otherwise the swap effect snapshots the
|
||||
// current caret.
|
||||
let pendingCaretOffset = 0;
|
||||
let caretOffsetPinned = false;
|
||||
|
||||
function queueCaretRestore() {
|
||||
queueMicrotask(() => {
|
||||
inputRef?.focus();
|
||||
inputRef?.setCaretOffset(pendingCaretOffset);
|
||||
caretOffsetPinned = false;
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const wantRichInput = containsFileMentionLink(value ?? '') || containsCodeSpan(value ?? '');
|
||||
|
||||
if (useRichInput === wantRichInput) return;
|
||||
|
||||
if (!caretOffsetPinned) {
|
||||
pendingCaretOffset = inputRef?.getCaretOffset() ?? (value ?? '').length;
|
||||
}
|
||||
|
||||
useRichInput = wantRichInput;
|
||||
queueCaretRestore();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
recordingSupported = isAudioRecordingSupported();
|
||||
audioRecorder = new AudioRecorder();
|
||||
});
|
||||
|
||||
export function focus() {
|
||||
inputRef?.focus();
|
||||
}
|
||||
|
||||
export function resetTextareaHeight() {
|
||||
inputRef?.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 handleKeydown(event: KeyboardEvent) {
|
||||
// Pickers consume navigation/escape keys first; when consumed, skip
|
||||
// the enter-to-submit logic below.
|
||||
if (pickers.handleKeydown(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
|
||||
const isModifier = event.ctrlKey || event.metaKey;
|
||||
const sendOnEnter = currentConfig.sendOnEnter !== false;
|
||||
|
||||
// Caret inside a fenced code block (closed, or still open
|
||||
// while being typed): Enter adds a line, never submits. The
|
||||
// rich chat form input consumes this case locally; this gate
|
||||
// covers the plain textarea, where skipping submit lets the
|
||||
// native newline through.
|
||||
if (!isModifier && isOffsetInCodeBlock(value ?? '', inputRef?.getCaretOffset() ?? 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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) => ({
|
||||
file: new File([att.content], `${att.name}${FileExtensionText.TXT}`, {
|
||||
type: MimeTypeText.PLAIN
|
||||
}),
|
||||
id: uuid(),
|
||||
isLoading: false,
|
||||
mcpPrompt: {
|
||||
arguments: att.arguments,
|
||||
promptName: att.promptName,
|
||||
serverName: att.serverName
|
||||
},
|
||||
name: att.name,
|
||||
size: att.content.length,
|
||||
textContent: att.content,
|
||||
type: SpecialFileType.MCP_PROMPT
|
||||
}));
|
||||
|
||||
uploadedFiles = [...uploadedFiles, ...mcpPromptFiles];
|
||||
onUploadedFilesChange?.(uploadedFiles);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
inputRef?.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>
|
||||
) {
|
||||
pickers.closePromptPicker();
|
||||
|
||||
const promptName = promptInfo.title || promptInfo.name;
|
||||
const placeholder: ChatUploadedFile = {
|
||||
file: new File([], 'loading'),
|
||||
id: placeholderId,
|
||||
isLoading: true,
|
||||
mcpPrompt: {
|
||||
arguments: args ? { ...args } : undefined,
|
||||
promptName: promptInfo.name,
|
||||
serverName: promptInfo.serverName
|
||||
},
|
||||
name: promptName,
|
||||
size: INITIAL_FILE_SIZE,
|
||||
type: SpecialFileType.MCP_PROMPT
|
||||
};
|
||||
|
||||
uploadedFiles = [...uploadedFiles, placeholder];
|
||||
onUploadedFilesChange?.(uploadedFiles);
|
||||
inputRef?.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,
|
||||
file: new File([promptText], `${f.name}${FileExtensionText.TXT}`, {
|
||||
type: MimeTypeText.PLAIN
|
||||
}),
|
||||
isLoading: false,
|
||||
size: promptText.length,
|
||||
textContent: promptText
|
||||
}
|
||||
: f
|
||||
);
|
||||
onUploadedFilesChange?.(uploadedFiles);
|
||||
}
|
||||
|
||||
function handlePromptLoadError(placeholderId: string, error: string) {
|
||||
uploadedFiles = uploadedFiles.map((f) =>
|
||||
f.id === placeholderId ? { ...f, isLoading: false, loadError: error } : f
|
||||
);
|
||||
onUploadedFilesChange?.(uploadedFiles);
|
||||
}
|
||||
|
||||
// Deferred so the closing popover's focus scope tears down first -
|
||||
// bits-ui yanks a synchronous focus() back into the still-mounted popover.
|
||||
function refocusInput() {
|
||||
queueMicrotask(() => inputRef?.focus());
|
||||
}
|
||||
|
||||
// A flow outside the chat, e.g. the models page returning to it, asks for the
|
||||
// composer back. Focus on a frame of its own, once the route has settled.
|
||||
$effect(() => {
|
||||
if (!uiStore.composerFocusRequested) return;
|
||||
|
||||
uiStore.composerFocusRequested = false;
|
||||
|
||||
setTimeout(() => inputRef?.focus(), 0);
|
||||
});
|
||||
|
||||
// Splice the mention link in place of the `@<query>` token. Uses the
|
||||
// live cursor, not a stale snapshot - the token may have been edited.
|
||||
function handleMentionSelect(entry: FileMentionEntry) {
|
||||
const cursor = inputRef?.getCaretOffset() ?? value.length;
|
||||
const token = findMentionToken(value, cursor);
|
||||
|
||||
if (!token) return;
|
||||
|
||||
const built = buildMentionInsertion(entry, value, token);
|
||||
|
||||
if (!built) return;
|
||||
|
||||
// Pin the post-insertion caret BEFORE the swap effect runs;
|
||||
// otherwise the effect clobbers it with the textarea's selection
|
||||
// at promotion time (browser-dependent: usually reset to 0).
|
||||
pendingCaretOffset = built.caretOffset;
|
||||
caretOffsetPinned = true;
|
||||
|
||||
value = built.newValue;
|
||||
onValueChange?.(built.newValue);
|
||||
|
||||
// Already in rich chat form input mode: no renderer flip, so the swap
|
||||
// effect's caret restore never runs.
|
||||
if (useRichInput) {
|
||||
queueCaretRestore();
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<ChatFormInputFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
|
||||
|
||||
<form
|
||||
class="relative grid {className}"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!canSubmit || disabled || hasLoadingAttachments) return;
|
||||
|
||||
onSubmit?.();
|
||||
}}
|
||||
>
|
||||
<ChatFormPickers
|
||||
bind:this={pickersRef}
|
||||
commandQuery={pickers.commandQuery}
|
||||
commands={pickers.availableCommands}
|
||||
isCommandPickerOpen={pickers.isCommandPickerOpen}
|
||||
isMentionPickerOpen={pickers.isMentionPickerOpen}
|
||||
isPromptPickerOpen={pickers.isPromptPickerOpen}
|
||||
{mentionAnchor}
|
||||
mentionQuery={pickers.mentionQuery}
|
||||
onCommandPickerClose={pickers.handleCommandPickerClose}
|
||||
onCommandSelect={pickers.handleCommandSelect}
|
||||
onMentionOpened={() => inputRef?.focus()}
|
||||
onMentionPickerClose={pickers.handleMentionPickerClose}
|
||||
onMentionSelect={handleMentionSelect}
|
||||
onPromptLoadComplete={handlePromptLoadComplete}
|
||||
onPromptLoadError={handlePromptLoadError}
|
||||
onPromptLoadStart={handlePromptLoadStart}
|
||||
onPromptPickerClose={pickers.handlePromptPickerClose}
|
||||
promptSearchQuery={pickers.promptSearchQuery}
|
||||
scopePath={pickers.mentionScopePath}
|
||||
/>
|
||||
|
||||
<div
|
||||
bind:this={mentionAnchor}
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute top-0 right-0 left-0 h-px"
|
||||
></div>
|
||||
|
||||
<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
|
||||
bind:uploadedFiles
|
||||
activeModelId={activeModelId ?? undefined}
|
||||
{attachments}
|
||||
class="py-5"
|
||||
limitToSingleRow
|
||||
onFileRemove={handleFileRemove}
|
||||
style="scroll-padding: 1rem;"
|
||||
/>
|
||||
|
||||
<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!"
|
||||
>
|
||||
<ChatFormInput
|
||||
bind:this={inputRef}
|
||||
bind:value
|
||||
class="px-5 py-1.5 md:pt-0"
|
||||
{disabled}
|
||||
onInput={() => {
|
||||
pickers.handleInput();
|
||||
onValueChange?.(value);
|
||||
}}
|
||||
onKeydown={handleKeydown}
|
||||
onPaste={handlePaste}
|
||||
{placeholder}
|
||||
{useRichInput}
|
||||
/>
|
||||
|
||||
{#if mcpStore.resources.hasAttachments}
|
||||
<ChatFormMcpResourcesList
|
||||
class="mb-3"
|
||||
onResourceClick={(uri) => {
|
||||
preSelectedResourceUri = uri;
|
||||
isResourceDialogOpen = true;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ChatFormActions
|
||||
bind:this={chatFormActionsRef}
|
||||
canSend={canSubmit}
|
||||
class="px-3"
|
||||
{disabled}
|
||||
{isLoading}
|
||||
isReasoning={chatStore.isReasoning}
|
||||
{isRecording}
|
||||
onFileUpload={handleFileUpload}
|
||||
onMicClick={handleMicClick}
|
||||
{onStop}
|
||||
onSystemPromptClick={() => onSystemPromptClick?.({ files: uploadedFiles, message: value })}
|
||||
{showAddButton}
|
||||
{showModelSelector}
|
||||
{uploadedFiles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContextGaugePopup />
|
||||
|
||||
{#if conversationsStore.preferences.hasEnabledCwdTools()}
|
||||
<ChatFormCurrentWorkingDirectory
|
||||
bind:query={pickers.workingDirectoryQuery}
|
||||
customAnchor={mentionAnchor}
|
||||
directory={cwd}
|
||||
{disabled}
|
||||
isOpen={pickers.isWorkingDirectoryPickerOpen}
|
||||
onChange={handleWorkingDirectoryChange}
|
||||
onClose={pickers.handleWorkingDirectoryClose}
|
||||
onOpen={pickers.handleWorkingDirectoryOpen}
|
||||
/>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<DialogMcpResourcesBrowser
|
||||
bind:open={isResourceDialogOpen}
|
||||
onAttach={(resource: MCPResourceInfo) => {
|
||||
mcpStore.attachResource(resource.uri);
|
||||
}}
|
||||
onOpenChange={(newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
preSelectedResourceUri = undefined;
|
||||
}
|
||||
}}
|
||||
preSelectedUri={preSelectedResourceUri}
|
||||
/>
|
||||
+33
@@ -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, ICON_CLASS_DEFAULT } 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 bg-background h-9 w-9 rounded-full p-0 shadow-sm md:h-8 md:w-8 dark:bg-muted-foreground/15"
|
||||
{disabled}
|
||||
{onclick}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
|
||||
|
||||
<Plus class={ICON_CLASS_DEFAULT} />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>{ATTACHMENT_TOOLTIP_TEXT}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
File,
|
||||
Image,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
PencilRuler,
|
||||
Video
|
||||
} from '@lucide/svelte';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import * as Drawer from '$lib/components/ui/drawer';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import {
|
||||
ATTACHMENT_FILE_ITEMS,
|
||||
ATTACHMENT_MENU_TEXT,
|
||||
ATTACHMENT_MODALITY_TEXT,
|
||||
ICON_CLASS_DEFAULT
|
||||
} from '$lib/constants';
|
||||
import { getChatFormActionsContext } from '$lib/contexts';
|
||||
import { AttachmentAction, AttachmentItemEnabledWhen } from '$lib/enums/attachment.enums';
|
||||
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
|
||||
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
|
||||
import type { ToolGroup } from '$lib/types';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
trigger: Snippet<[{ disabled: boolean; onclick?: () => void }]>;
|
||||
}
|
||||
|
||||
let { class: className = '', trigger }: Props = $props();
|
||||
|
||||
const chatFormActions = getChatFormActionsContext();
|
||||
|
||||
let drawerOpen = $state(false);
|
||||
let toolsExpanded = $state(false);
|
||||
|
||||
const attachmentMenu = useAttachmentMenu(
|
||||
() => ({
|
||||
hasAudioModality: chatFormActions.hasAudioModality,
|
||||
hasVideoModality: chatFormActions.hasVideoModality,
|
||||
hasVisionModality: chatFormActions.hasVisionModality
|
||||
}),
|
||||
() => ({
|
||||
onFileUpload: chatFormActions.onFileUpload,
|
||||
onSystemPromptClick: chatFormActions.onSystemPromptClick
|
||||
}),
|
||||
() => {
|
||||
drawerOpen = false;
|
||||
}
|
||||
);
|
||||
|
||||
const FILE_MODALITY_ICONS: Record<string, { icon: typeof Image; label: string }> = {
|
||||
[AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY]: {
|
||||
icon: Mic,
|
||||
label: ATTACHMENT_MODALITY_TEXT[AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY]
|
||||
},
|
||||
[AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY]: {
|
||||
icon: Video,
|
||||
label: ATTACHMENT_MODALITY_TEXT[AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY]
|
||||
},
|
||||
[AttachmentItemEnabledWhen.HAS_VISION_MODALITY]: {
|
||||
icon: Image,
|
||||
label: ATTACHMENT_MODALITY_TEXT[AttachmentItemEnabledWhen.HAS_VISION_MODALITY]
|
||||
}
|
||||
};
|
||||
|
||||
const supportedModalities = $derived.by(() =>
|
||||
ATTACHMENT_FILE_ITEMS.filter((item) => attachmentMenu.isItemEnabled(item.enabledWhen))
|
||||
.map((item) => FILE_MODALITY_ICONS[item.enabledWhen ?? ''])
|
||||
.filter((modality) => modality !== undefined)
|
||||
);
|
||||
|
||||
const toolsPanel = useToolsPanel();
|
||||
|
||||
const itemClass =
|
||||
'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 itemRowClass =
|
||||
'flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent';
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1 {className}">
|
||||
<Drawer.Root bind:open={drawerOpen}>
|
||||
{@render trigger({ disabled: chatFormActions.disabled, onclick: () => (drawerOpen = true) })}
|
||||
|
||||
<Drawer.Content class="gap-0 overflow-y-auto">
|
||||
<Drawer.Header>
|
||||
<Drawer.Title>{ATTACHMENT_MENU_TEXT.ADD_TO_CHAT}</Drawer.Title>
|
||||
|
||||
<Drawer.Description class="sr-only">
|
||||
{ATTACHMENT_MENU_TEXT.DESCRIPTION}
|
||||
</Drawer.Description>
|
||||
</Drawer.Header>
|
||||
|
||||
<div class="flex flex-col gap-1 px-1.5 pb-2">
|
||||
<button
|
||||
class={itemClass}
|
||||
onclick={() => attachmentMenu.callbacks[AttachmentAction.FILE_UPLOAD]()}
|
||||
type="button"
|
||||
>
|
||||
<File class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<span>{ATTACHMENT_MENU_TEXT.ADD_FILES}</span>
|
||||
|
||||
{#if supportedModalities.length > 0}
|
||||
<span class="flex items-center gap-0.75 text-muted-foreground">
|
||||
{#each supportedModalities as modality (modality.label)}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<modality.icon class="size-2.75" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>{modality.label}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={itemClass}
|
||||
onclick={() => attachmentMenu.callbacks[AttachmentAction.SYSTEM_PROMPT_CLICK]()}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquare class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
|
||||
<span>{ATTACHMENT_MENU_TEXT.SYSTEM_MESSAGE}</span>
|
||||
</button>
|
||||
|
||||
{#if toolsPanel.totalToolCount > 0}
|
||||
<Collapsible.Root onOpenChange={(open) => (toolsExpanded = open)} open={toolsExpanded}>
|
||||
<Collapsible.Trigger class={itemClass}>
|
||||
{#if toolsExpanded}
|
||||
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
{:else}
|
||||
<ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
{/if}
|
||||
|
||||
<PencilRuler class="inline {ICON_CLASS_DEFAULT} shrink-0" />
|
||||
|
||||
<span class="flex-1">{ATTACHMENT_MENU_TEXT.TOOLS}</span>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content>
|
||||
<div class="flex flex-col gap-0.5 pl-4">
|
||||
{#each toolsPanel.categoryGroups as group (group.key)}
|
||||
{@render groupRow(group)}
|
||||
{/each}
|
||||
|
||||
{#each toolsPanel.mcpGroups as group (group.key)}
|
||||
{@render groupRow(group)}
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root>
|
||||
</div>
|
||||
|
||||
{#snippet groupRow(group: ToolGroup)}
|
||||
{@const checkState = toolsPanel.getGroupCheckState(group)}
|
||||
{@const enabledCount = toolsPanel.getEnabledToolCount(group)}
|
||||
{@const favicon = toolsPanel.getFavicon(group)}
|
||||
{@const groupDisabled = toolsPanel.isGroupDisabled(group)}
|
||||
|
||||
<button
|
||||
class="{itemRowClass} {groupDisabled ? 'pointer-events-none opacity-50' : ''}"
|
||||
onclick={() => toolsPanel.toggleGroupByKey(group.key)}
|
||||
type="button"
|
||||
>
|
||||
{#if favicon}
|
||||
<img
|
||||
alt=""
|
||||
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
src={favicon}
|
||||
/>
|
||||
{/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={checkState.checked}
|
||||
class="{ICON_CLASS_DEFAULT} shrink-0"
|
||||
indeterminate={checkState.indeterminate}
|
||||
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</button>
|
||||
{/snippet}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import { File, Image, MessageSquare, Mic, Plus, Video } from '@lucide/svelte';
|
||||
import { ChatFormActionAddToolsSubmenu } from '$lib/components/app';
|
||||
import { buttonVariants } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import {
|
||||
ATTACHMENT_FILE_ITEMS,
|
||||
ATTACHMENT_MENU_TEXT,
|
||||
ATTACHMENT_MODALITY_TEXT,
|
||||
ATTACHMENT_TOOLTIP_TEXT,
|
||||
ICON_CLASS_DEFAULT
|
||||
} from '$lib/constants';
|
||||
import { getChatFormActionsContext } from '$lib/contexts';
|
||||
import { AttachmentAction, AttachmentItemEnabledWhen } from '$lib/enums';
|
||||
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className = '' }: Props = $props();
|
||||
|
||||
const chatFormActions = getChatFormActionsContext();
|
||||
|
||||
let dropdownOpen = $state(false);
|
||||
// The system message action moves focus to the message editor, so the menu
|
||||
// must not restore focus to the trigger on close
|
||||
let suppressCloseAutoFocus = false;
|
||||
|
||||
const attachmentMenu = useAttachmentMenu(
|
||||
() => ({
|
||||
hasAudioModality: chatFormActions.hasAudioModality,
|
||||
hasVideoModality: chatFormActions.hasVideoModality,
|
||||
hasVisionModality: chatFormActions.hasVisionModality
|
||||
}),
|
||||
() => ({
|
||||
onFileUpload: chatFormActions.onFileUpload,
|
||||
onSystemPromptClick: chatFormActions.onSystemPromptClick
|
||||
}),
|
||||
() => {
|
||||
dropdownOpen = false;
|
||||
}
|
||||
);
|
||||
|
||||
const FILE_MODALITY_ICONS: Record<string, { icon: typeof Image; label: string }> = {
|
||||
[AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY]: {
|
||||
icon: Mic,
|
||||
label: ATTACHMENT_MODALITY_TEXT[AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY]
|
||||
},
|
||||
[AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY]: {
|
||||
icon: Video,
|
||||
label: ATTACHMENT_MODALITY_TEXT[AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY]
|
||||
},
|
||||
[AttachmentItemEnabledWhen.HAS_VISION_MODALITY]: {
|
||||
icon: Image,
|
||||
label: ATTACHMENT_MODALITY_TEXT[AttachmentItemEnabledWhen.HAS_VISION_MODALITY]
|
||||
}
|
||||
};
|
||||
|
||||
const supportedModalities = $derived.by(() =>
|
||||
ATTACHMENT_FILE_ITEMS.filter((item) => attachmentMenu.isItemEnabled(item.enabledWhen))
|
||||
.map((item) => FILE_MODALITY_ICONS[item.enabledWhen ?? ''])
|
||||
.filter((modality) => modality !== undefined)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1 {className}">
|
||||
<DropdownMenu.Root bind:open={dropdownOpen}>
|
||||
<!-- ignoreNonKeyboardFocus prevents the tooltip from flashing when the
|
||||
menu closes and focus returns to the trigger -->
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<DropdownMenu.Trigger
|
||||
{...props}
|
||||
class={cn(
|
||||
// ghost brings no fill of its own for the muted composer surface to fight over
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'file-upload-button h-8 w-8 cursor-pointer rounded-full bg-background p-0 shadow-sm dark:bg-muted-foreground/15'
|
||||
)}
|
||||
disabled={chatFormActions.disabled}
|
||||
>
|
||||
<span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
|
||||
|
||||
<Plus class={ICON_CLASS_DEFAULT} />
|
||||
</DropdownMenu.Trigger>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>{ATTACHMENT_TOOLTIP_TEXT}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<DropdownMenu.Content
|
||||
align="start"
|
||||
class="w-52"
|
||||
onCloseAutoFocus={(e) => {
|
||||
if (suppressCloseAutoFocus) {
|
||||
suppressCloseAutoFocus = false;
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={() => attachmentMenu.callbacks[AttachmentAction.FILE_UPLOAD]()}
|
||||
>
|
||||
<File class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<span>{ATTACHMENT_MENU_TEXT.ADD_FILES}</span>
|
||||
|
||||
{#if supportedModalities.length > 0}
|
||||
<span class="flex items-center gap-0.75 text-muted-foreground">
|
||||
{#each supportedModalities as modality (modality.label)}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<modality.icon class="size-2.75" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>{modality.label}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={() => {
|
||||
suppressCloseAutoFocus = true;
|
||||
chatFormActions.onSystemPromptClick?.();
|
||||
}}
|
||||
>
|
||||
<MessageSquare class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>{ATTACHMENT_MENU_TEXT.SYSTEM_MESSAGE}</span>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<ChatFormActionAddToolsSubmenu />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import { Check, ChevronDown, ChevronRight, Info, Loader2, PencilRuler } 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 { ATTACHMENT_MENU_TEXT, CLI_FLAGS, ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
|
||||
import { mcpStore, toolsStore } from '$lib/stores';
|
||||
import type { ToolGroup } from '$lib/types';
|
||||
|
||||
const toolsPanel = useToolsPanel();
|
||||
const hasMcpServersAvailable = $derived(mcpStore.getServers().length > 0);
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Sub onOpenChange={(open) => open && toolsPanel.handleOpen()}>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
<PencilRuler class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>{ATTACHMENT_MENU_TEXT.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 {ICON_CLASS_DEFAULT} 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 {ICON_CLASS_DEFAULT} shrink-0" />
|
||||
|
||||
<span>
|
||||
Run llama-server with <code>{CLI_FLAGS.TOOLS}</code> flag to enable
|
||||
|
||||
<strong>Server Tools</strong>.
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="flex gap-2">
|
||||
<Info class="mt-0.5 {ICON_CLASS_DEFAULT} 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 {ICON_CLASS_DEFAULT} 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.categoryGroups as group (group.key)}
|
||||
{@render groupRow(group)}
|
||||
{/each}
|
||||
|
||||
{#each toolsPanel.mcpGroups as group (group.key)}
|
||||
{@render groupRow(group)}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
{#snippet groupRow(group: ToolGroup)}
|
||||
{@const isExpanded = toolsPanel.expandedGroups.has(group.key)}
|
||||
{@const checkState = toolsPanel.getGroupCheckState(group)}
|
||||
{@const favicon = toolsPanel.getFavicon(group)}
|
||||
{@const groupDisabled = toolsPanel.isGroupDisabled(group)}
|
||||
|
||||
<Collapsible.Root
|
||||
onOpenChange={() => toolsPanel.toggleGroupExpanded(group.key)}
|
||||
open={isExpanded}
|
||||
>
|
||||
<div class="flex items-center gap-1 {groupDisabled ? 'pointer-events-none opacity-50' : ''}">
|
||||
<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
|
||||
alt=""
|
||||
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
src={favicon}
|
||||
/>
|
||||
{/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={checkState.checked}
|
||||
class="mr-2 {ICON_CLASS_DEFAULT} shrink-0"
|
||||
indeterminate={checkState.indeterminate}
|
||||
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
|
||||
/>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content side="right">
|
||||
<p>
|
||||
{checkState.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 = toolsPanel.isToolEnabled(entry)}
|
||||
{@const parentDisabled = toolsPanel.isToolParentDisabled(entry)}
|
||||
<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 {parentDisabled
|
||||
? 'opacity-50'
|
||||
: ''}"
|
||||
onclick={() => toolsPanel.toggleTool(entry)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
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"
|
||||
data-slot="checkbox"
|
||||
data-state={enabled ? 'checked' : 'unchecked'}
|
||||
>
|
||||
{#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>
|
||||
{/snippet}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import ChatFormActionAddButton from './ChatFormActionAddButton.svelte';
|
||||
import ChatFormActionAddDrawer from './ChatFormActionAddDrawer.svelte';
|
||||
import ChatFormActionAddDropdown from './ChatFormActionAddDropdown.svelte';
|
||||
import { deviceStore } from '$lib/stores';
|
||||
</script>
|
||||
|
||||
{#if deviceStore.isMobile}
|
||||
<ChatFormActionAddDrawer>
|
||||
{#snippet trigger({ disabled, onclick })}
|
||||
<ChatFormActionAddButton {disabled} {onclick} />
|
||||
{/snippet}
|
||||
</ChatFormActionAddDrawer>
|
||||
{:else}
|
||||
<ChatFormActionAddDropdown />
|
||||
{/if}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import { Check, Info, Lightbulb, LightbulbOff } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Drawer from '$lib/components/ui/drawer';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { ReasoningEffort } from '$lib/enums';
|
||||
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
|
||||
import { deviceStore } from '$lib/stores';
|
||||
import type { ReasoningEffortLevel } from '$lib/types';
|
||||
|
||||
const reasoning = useReasoningMenu();
|
||||
|
||||
let isOpen = $state(false);
|
||||
|
||||
// "Default" is the resting state, where the bulb alone carries the meaning
|
||||
let isDefault = $derived(reasoning.currentEffort === ReasoningEffort.DEFAULT);
|
||||
// a phone picks the level in a drawer, where a thumb reaches it
|
||||
let isMobile = $derived(deviceStore.isMobile);
|
||||
|
||||
const MAX_EFFORT_HINT = 'Maximum reasoning effort with extended context usage';
|
||||
|
||||
function select(level: ReasoningEffortLevel): void {
|
||||
reasoning.select(level);
|
||||
isOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet triggerLabel()}
|
||||
<span class="flex items-center gap-0.75 {reasoning.isOff ? 'text-muted-foreground' : ''}">
|
||||
{#if reasoning.isOff}
|
||||
<LightbulbOff class="size-3 max-md:size-4 shrink-0" />
|
||||
{:else}
|
||||
<Lightbulb class="size-3 max-md:size-4 shrink-0" />
|
||||
{/if}
|
||||
|
||||
{#if !isDefault}
|
||||
<span class="capitalize">{reasoning.currentEffort}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet levelRow(level: ReasoningEffortLevel)}
|
||||
{@const tokenLabel = reasoning.tokenLabel(level)}
|
||||
|
||||
<button
|
||||
aria-pressed={reasoning.isSelected(level)}
|
||||
class="flex min-h-11 w-full cursor-pointer items-center gap-3 rounded-md px-3 text-left text-sm transition-colors hover:bg-accent"
|
||||
onclick={() => select(level)}
|
||||
type="button"
|
||||
>
|
||||
{#if reasoning.isSelected(level)}
|
||||
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
|
||||
{:else}
|
||||
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
|
||||
{/if}
|
||||
|
||||
<span class="min-w-0 flex-1 truncate">{level.label}</span>
|
||||
|
||||
{#if tokenLabel}
|
||||
<span class="shrink-0 text-[11px] text-muted-foreground opacity-60">{tokenLabel}</span>
|
||||
{/if}
|
||||
|
||||
{#if level.hasInfo}
|
||||
<!-- no hover on a phone: the hint rides on the icon's own title there -->
|
||||
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" title={MAX_EFFORT_HINT} />
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
<!-- the level belongs to the chat, not to the model list -->
|
||||
{#if isMobile}
|
||||
<Button
|
||||
aria-label="Reasoning effort"
|
||||
class="h-auto gap-1 rounded-sm bg-transparent! backdrop-blur-none! {isDefault
|
||||
? 'px-1'
|
||||
: 'px-1.75!'} py-1 text-xs max-md:h-8"
|
||||
onclick={() => (isOpen = true)}
|
||||
variant="ghost"
|
||||
>
|
||||
{@render triggerLabel()}
|
||||
</Button>
|
||||
|
||||
<Drawer.Root bind:open={isOpen}>
|
||||
<Drawer.Content>
|
||||
<Drawer.Header>
|
||||
<Drawer.Title>Reasoning effort</Drawer.Title>
|
||||
</Drawer.Header>
|
||||
|
||||
<div class="flex flex-col px-2 pb-4">
|
||||
{#each reasoning.levels as level (level.value)}
|
||||
{@render levelRow(level)}
|
||||
{/each}
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root>
|
||||
{:else}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
aria-label="Reasoning effort"
|
||||
class="h-auto gap-1 rounded-sm bg-transparent! backdrop-blur-none! {isDefault
|
||||
? 'px-1'
|
||||
: 'px-1.75!'} py-1 text-xs hover:bg-transparent!"
|
||||
variant="ghost"
|
||||
>
|
||||
{@render triggerLabel()}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content align="end" class="min-w-56">
|
||||
{#each reasoning.levels as level (level.value)}
|
||||
{@const tokenLabel = reasoning.tokenLabel(level)}
|
||||
|
||||
<DropdownMenu.Item class="gap-3" onSelect={() => reasoning.select(level)}>
|
||||
{#if reasoning.isSelected(level)}
|
||||
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
|
||||
{:else}
|
||||
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
|
||||
{/if}
|
||||
|
||||
<span class="min-w-0 flex-1 truncate">{level.label}</span>
|
||||
|
||||
{#if tokenLabel}
|
||||
<span class="shrink-0 text-[11px] text-muted-foreground opacity-60">{tokenLabel}</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="right">
|
||||
<p>{MAX_EFFORT_HINT}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { Mic, Square } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
|
||||
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 max-md:h-9 max-md:w-9 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="{ICON_CLASS_DEFAULT} animate-pulse fill-white" />
|
||||
{:else}
|
||||
<Mic class={ICON_CLASS_DEFAULT} />
|
||||
{/if}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
{#if !hasAudioModality}
|
||||
<Tooltip.Content>
|
||||
<p>Current model does not support audio</p>
|
||||
</Tooltip.Content>
|
||||
{/if}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<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
|
||||
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'
|
||||
]}
|
||||
disabled={isDisabled}
|
||||
type="submit"
|
||||
{...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}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
<script lang="ts">
|
||||
import { SkipForward, Square } from '@lucide/svelte';
|
||||
import { page } from '$app/state';
|
||||
import {
|
||||
ChatFormActionReasoning,
|
||||
ChatFormActionRecord,
|
||||
ChatFormActionsAdd,
|
||||
ChatFormActionSubmit,
|
||||
ChatFormContextGauge,
|
||||
ModelsSelector
|
||||
} from '$lib/components/app';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { setChatFormActionsContext } from '$lib/contexts';
|
||||
import { FileTypeCategory, MessageRole } from '$lib/enums';
|
||||
import { useChatFormModel } from '$lib/hooks/use-chat-form-model.svelte';
|
||||
import { ChatService } from '$lib/services';
|
||||
import { chatStore, conversationsStore, settingsStore } from '$lib/stores';
|
||||
import { getFileTypeCategory } from '$lib/utils';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
let {
|
||||
canSend = false,
|
||||
canSubmit = false,
|
||||
class: className = '',
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
isReasoning = false,
|
||||
isRecording = false,
|
||||
onFileUpload,
|
||||
onMicClick,
|
||||
onStop,
|
||||
onSystemPromptClick,
|
||||
showAddButton = true,
|
||||
showModelSelector = true,
|
||||
uploadedFiles = []
|
||||
}: Props = $props();
|
||||
|
||||
let currentConfig = $derived(settingsStore.config);
|
||||
|
||||
const formModel = useChatFormModel();
|
||||
|
||||
let hasAudioModality = $derived(formModel.hasAudioModality);
|
||||
let hasVideoModality = $derived(formModel.hasVideoModality);
|
||||
let hasVisionModality = $derived(formModel.hasVisionModality);
|
||||
let hasModelSelected = $derived(formModel.hasModelSelected);
|
||||
let isSelectedModelInCache = $derived(formModel.isSelectedModelInCache);
|
||||
let submitTooltip = $derived.by(() => {
|
||||
if (!hasModelSelected) return 'Please select a model first';
|
||||
|
||||
if (!isSelectedModelInCache) return 'Selected model is not available, please select another';
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
let hasAudioAttachments = $derived(
|
||||
uploadedFiles.some((file) => getFileTypeCategory(file.type) === FileTypeCategory.AUDIO)
|
||||
);
|
||||
let shouldShowRecordButton = $derived(
|
||||
hasAudioModality && !canSubmit && !hasAudioAttachments && currentConfig.autoMicOnEmpty
|
||||
);
|
||||
|
||||
let selectorModelRef: ModelsSelector | 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 = conversationsStore.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 (!chatStore.isLoading && !chatStore.isStreaming()) return false;
|
||||
|
||||
const processingState = chatStore.processing.activeState;
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
setChatFormActionsContext({
|
||||
get disabled() {
|
||||
return disabled;
|
||||
},
|
||||
get hasAudioModality() {
|
||||
return hasAudioModality;
|
||||
},
|
||||
get hasVideoModality() {
|
||||
return hasVideoModality;
|
||||
},
|
||||
get hasVisionModality() {
|
||||
return hasVisionModality;
|
||||
},
|
||||
get onFileUpload() {
|
||||
return onFileUpload;
|
||||
},
|
||||
get onSystemPromptClick() {
|
||||
return onSystemPromptClick;
|
||||
}
|
||||
});
|
||||
</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 />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if hasProcessedTokens}
|
||||
<ChatFormContextGauge />
|
||||
{/if}
|
||||
|
||||
{#if showModelSelector}
|
||||
<ChatFormActionReasoning />
|
||||
|
||||
<ModelsSelector
|
||||
bind:this={selectorModelRef}
|
||||
{disabled}
|
||||
forceForegroundText
|
||||
useGlobalSelection
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isReasoning}
|
||||
<Button
|
||||
class="group h-8 w-8 rounded-full p-0 max-md:h-9 max-md:w-9"
|
||||
onclick={() =>
|
||||
ChatService.stopReasoning(activeMessage?.completionId ?? '', activeMessage?.model)}
|
||||
title="Skip reasoning"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<span class="sr-only">Skip reasoning</span>
|
||||
|
||||
<SkipForward
|
||||
class="{ICON_CLASS_DEFAULT} stroke-muted-foreground group-hover:stroke-foreground"
|
||||
/>
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if isLoading && !canSubmit}
|
||||
<Button
|
||||
class="group h-8 w-8 rounded-full p-0 max-md:h-9 max-md:w-9 hover:bg-destructive/10!"
|
||||
onclick={onStop}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<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}
|
||||
showErrorState={showModelSelector && hasModelSelected && !isSelectedModelInCache}
|
||||
tooltipLabel={submitTooltip}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import ContextGaugeDial from './ContextGaugeDial.svelte';
|
||||
import {
|
||||
gaugeTriggerClick,
|
||||
gaugeTriggerEnter,
|
||||
gaugeTriggerKeydown,
|
||||
gaugeTriggerLeave,
|
||||
gaugeTriggerPointerDown
|
||||
} from './gauge-popup.svelte';
|
||||
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
|
||||
import { chatStore, conversationsStore, deviceStore } from '$lib/stores';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
const gauge = useContextGauge();
|
||||
|
||||
$effect(() => {
|
||||
const conv = conversationsStore.activeConversation;
|
||||
|
||||
untrack(() => chatStore.processing.setActiveConversation(conv?.id ?? null));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const conv = conversationsStore.activeConversation;
|
||||
const messages = conversationsStore.activeMessages as DatabaseMessage[];
|
||||
|
||||
if (!conv) return;
|
||||
|
||||
if (chatStore.isLoading || chatStore.isStreaming()) return;
|
||||
|
||||
if (messages.length === 0) {
|
||||
untrack(() => chatStore.processing.setState(conv.id, null));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(() => chatStore.processing.restoreFromMessages(messages, conv.id));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
gauge.startMonitoring();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
aria-label="Context usage"
|
||||
class="flex h-5 w-5 cursor-default items-center justify-center max-md:h-6 max-md:w-6"
|
||||
data-context-gauge-trigger
|
||||
onclick={gaugeTriggerClick}
|
||||
onkeydown={gaugeTriggerKeydown}
|
||||
onpointerdown={gaugeTriggerPointerDown}
|
||||
onpointerenter={gaugeTriggerEnter}
|
||||
onpointerleave={gaugeTriggerLeave}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- a phone gets the larger dial: the trigger is a touch target there -->
|
||||
<ContextGaugeDial
|
||||
level={gauge.colorLevel}
|
||||
percent={gauge.contextPercent}
|
||||
size={deviceStore.isMobile ? 'md' : 'sm'}
|
||||
/>
|
||||
</div>
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
label: string;
|
||||
value: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
let { label, subtitle, value }: 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 max-md:text-[11px]">
|
||||
{subtitle}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import ContextGaugeDetailRow from './ContextGaugeDetailRow.svelte';
|
||||
import { STATS_UNITS } from '$lib/constants';
|
||||
|
||||
interface Props {
|
||||
currentRead: number;
|
||||
currentFresh: number;
|
||||
currentCache: number;
|
||||
currentOutput: number;
|
||||
kvTotal: number;
|
||||
cumulativeRead: number;
|
||||
cumulativeOutput: number;
|
||||
cumulativeCacheTotal: number;
|
||||
averageTokensPerSecond: number | null;
|
||||
transientDetails: string[];
|
||||
}
|
||||
|
||||
let {
|
||||
averageTokensPerSecond,
|
||||
cumulativeCacheTotal,
|
||||
cumulativeOutput,
|
||||
cumulativeRead,
|
||||
currentCache,
|
||||
currentFresh,
|
||||
currentOutput,
|
||||
currentRead,
|
||||
kvTotal,
|
||||
transientDetails
|
||||
}: Props = $props();
|
||||
|
||||
const hasCumulative = $derived(cumulativeRead > 0 || cumulativeOutput > 0);
|
||||
const hasCurrent = $derived(currentRead > 0 || currentOutput > 0);
|
||||
</script>
|
||||
|
||||
<div class="mt-3 border-t border-border/50 pt-4">
|
||||
<p class="text-xs text-muted-foreground max-md:text-[13px]">Token usage details</p>
|
||||
|
||||
<div class="flex flex-col gap-4 pt-4 text-xs max-md:gap-5 max-md:text-[13px]">
|
||||
{#if hasCumulative}
|
||||
<div>
|
||||
<h3
|
||||
class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground/70 mb-2 max-md:text-xs"
|
||||
>
|
||||
Across all turns
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if cumulativeRead > 0}
|
||||
<ContextGaugeDetailRow
|
||||
label="Prompt tokens evaluated"
|
||||
subtitle={cumulativeCacheTotal > 0
|
||||
? `${cumulativeCacheTotal.toLocaleString()} reused from KV cache`
|
||||
: undefined}
|
||||
value={`${cumulativeRead.toLocaleString()} tok`}
|
||||
/>
|
||||
{/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 max-md:text-xs"
|
||||
>
|
||||
This turn · KV cache
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if currentRead > 0}
|
||||
<ContextGaugeDetailRow
|
||||
label="Prompt"
|
||||
subtitle={currentCache > 0
|
||||
? `${currentFresh.toLocaleString()} fresh + ${currentCache.toLocaleString()} cached`
|
||||
: undefined}
|
||||
value={`${currentRead.toLocaleString()} tok`}
|
||||
/>
|
||||
{/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}
|
||||
</div>
|
||||
</div>
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { colorLevelTextClass } from './context-gauge';
|
||||
import type { ColorLevel } from '$lib/enums';
|
||||
|
||||
interface Props {
|
||||
percent: number | null;
|
||||
level: ColorLevel;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
let { level, percent, size = 'sm' }: Props = $props();
|
||||
|
||||
// the md dial fills its box: the ring would sit inside a padding ring otherwise
|
||||
const RADIUS = $derived(size === 'md' ? 13 : 11);
|
||||
const CIRCUMFERENCE = $derived(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 class={dimensions} fill="none" viewBox="0 0 32 32">
|
||||
<circle
|
||||
cx="16"
|
||||
cy="16"
|
||||
r={RADIUS}
|
||||
stroke="currentColor"
|
||||
stroke-opacity="0.1"
|
||||
stroke-width={strokeWidth}
|
||||
/>
|
||||
|
||||
<circle
|
||||
class="transition-colors duration-300 {strokeLevelClass}"
|
||||
cx="16"
|
||||
cy="16"
|
||||
r={RADIUS}
|
||||
stroke="currentColor"
|
||||
stroke-dasharray={CIRCUMFERENCE}
|
||||
stroke-dashoffset={percent !== null ? CIRCUMFERENCE * (1 - percent / 100) : CIRCUMFERENCE}
|
||||
stroke-linecap="round"
|
||||
stroke-width={strokeWidth}
|
||||
transform="rotate(-90 16 16)"
|
||||
/>
|
||||
</svg>
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<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 { isLoading, modelId, 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 class="self-start" onclick={onLoad} size="sm" variant="secondary">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}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import { colorLevelBgClass, colorLevelTextClass } from './context-gauge';
|
||||
import ContextGaugeDetails from './ContextGaugeDetails.svelte';
|
||||
import ContextGaugeLoadModel from './ContextGaugeLoadModel.svelte';
|
||||
import {
|
||||
gaugeCardEnter,
|
||||
gaugeCardLeave,
|
||||
gaugePopup,
|
||||
gaugePopupClose
|
||||
} from './gauge-popup.svelte';
|
||||
import * as Drawer from '$lib/components/ui/drawer';
|
||||
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
|
||||
import { deviceStore } from '$lib/stores';
|
||||
import { formatParameters } from '$lib/utils/formatters';
|
||||
|
||||
const gauge = useContextGauge();
|
||||
|
||||
// The gauge hook wraps a processing state instance that only follows the
|
||||
// live stream while its own monitoring flag is set, so the card instance
|
||||
// starts monitoring like the dial does.
|
||||
$effect(() => {
|
||||
gauge.startMonitoring();
|
||||
});
|
||||
|
||||
let cardEl = $state<HTMLElement | null>(null);
|
||||
|
||||
// Any press outside the card and outside the dial closes the card.
|
||||
// Presses on the dial are excluded because the dial handles its own
|
||||
// toggle; the listener only exists while the card is open. A phone shows
|
||||
// the panel in a drawer instead, which brings its own dismissal.
|
||||
$effect(() => {
|
||||
if (!gaugePopup.open || deviceStore.isMobile) return;
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
|
||||
if (!(target instanceof Node)) return;
|
||||
|
||||
if (cardEl?.contains(target)) return;
|
||||
|
||||
if (target instanceof Element && target.closest('[data-context-gauge-trigger]')) return;
|
||||
|
||||
gaugePopupClose();
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', onPointerDown, true);
|
||||
|
||||
return () => document.removeEventListener('pointerdown', onPointerDown, true);
|
||||
});
|
||||
|
||||
const showProgressBar = $derived(
|
||||
gauge.contextTotal !== null &&
|
||||
gauge.contextTotal > 0 &&
|
||||
(gauge.activeModelId !== null || gauge.isActiveModelLoaded)
|
||||
);
|
||||
</script>
|
||||
|
||||
{#snippet usage()}
|
||||
<span class="font-mono text-muted-foreground">
|
||||
{formatParameters(gauge.contextUsed)}
|
||||
/ {gauge.contextTotal !== null ? formatParameters(gauge.contextTotal) : '-'}
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet body()}
|
||||
{#if gauge.activeModelId !== null && !gauge.isActiveModelLoaded}
|
||||
<ContextGaugeLoadModel
|
||||
isLoading={gauge.isActiveModelLoading}
|
||||
modelId={gauge.activeModelId}
|
||||
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 max-md:text-[13px]">
|
||||
<span>
|
||||
<span class={colorLevelTextClass(gauge.colorLevel)}>{gauge.contextPercent}%</span> used
|
||||
</span>
|
||||
|
||||
<span>
|
||||
{formatParameters(gauge.contextAvailable ?? 0)} remaining
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-muted-foreground">No context info available</div>
|
||||
{/if}
|
||||
|
||||
{#if gauge.hasAnyUsage}
|
||||
<ContextGaugeDetails
|
||||
averageTokensPerSecond={gauge.averageTokensPerSecond}
|
||||
cumulativeCacheTotal={gauge.cumulativeCacheTotal}
|
||||
cumulativeOutput={gauge.cumulativeOutput}
|
||||
cumulativeRead={gauge.cumulativeRead}
|
||||
currentCache={gauge.currentCache}
|
||||
currentFresh={gauge.currentFresh}
|
||||
currentOutput={gauge.currentOutput}
|
||||
currentRead={gauge.currentRead}
|
||||
kvTotal={gauge.kvTotal}
|
||||
transientDetails={gauge.transientDetails}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if deviceStore.isMobile}
|
||||
<Drawer.Root bind:open={gaugePopup.open}>
|
||||
<Drawer.Content>
|
||||
<Drawer.Header>
|
||||
<Drawer.Title>Context</Drawer.Title>
|
||||
|
||||
<Drawer.Description class="sr-only">
|
||||
Context window usage of the model the chat runs
|
||||
</Drawer.Description>
|
||||
</Drawer.Header>
|
||||
|
||||
<div class="flex flex-col gap-2 px-4 pb-4 max-md:gap-3.5">
|
||||
{@render usage()}
|
||||
|
||||
{@render body()}
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root>
|
||||
{:else if gaugePopup.open}
|
||||
<div
|
||||
bind:this={cardEl}
|
||||
class="absolute z-50 w-64 -translate-x-1/2 rounded-xl border border-border bg-popover p-1.5 text-sm text-popover-foreground shadow-md dark:border-border/20"
|
||||
onpointerenter={gaugeCardEnter}
|
||||
onpointerleave={gaugeCardLeave}
|
||||
role="status"
|
||||
style="left: {gaugePopup.centerX}px; bottom: {gaugePopup.bottom}px"
|
||||
>
|
||||
<div class="flex flex-col gap-2 px-2 py-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">Context</span>
|
||||
|
||||
<span class="text-muted-foreground">·</span>
|
||||
|
||||
{@render usage()}
|
||||
</div>
|
||||
|
||||
{@render body()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { ColorLevel } from '$lib/enums';
|
||||
|
||||
const WARNING_THRESHOLD = 80;
|
||||
const CRITICAL_THRESHOLD = 95;
|
||||
|
||||
export function colorLevelFromPercent(percent: number | null): ColorLevel {
|
||||
if (percent === null) return ColorLevel.NEUTRAL;
|
||||
|
||||
if (percent >= CRITICAL_THRESHOLD) return ColorLevel.CRITICAL;
|
||||
|
||||
if (percent >= WARNING_THRESHOLD) return ColorLevel.WARNING;
|
||||
|
||||
return ColorLevel.OK;
|
||||
}
|
||||
|
||||
export function colorLevelTextClass(level: ColorLevel): string {
|
||||
switch (level) {
|
||||
case ColorLevel.CRITICAL:
|
||||
return 'text-red-400';
|
||||
case ColorLevel.WARNING:
|
||||
return 'text-amber-400';
|
||||
case ColorLevel.OK:
|
||||
return 'text-muted-foreground';
|
||||
default:
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
export function colorLevelBgClass(level: ColorLevel): string {
|
||||
switch (level) {
|
||||
case ColorLevel.CRITICAL:
|
||||
return 'bg-red-500';
|
||||
case ColorLevel.WARNING:
|
||||
return 'bg-amber-500';
|
||||
case ColorLevel.OK:
|
||||
return 'bg-green-500';
|
||||
default:
|
||||
return 'bg-muted';
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
// Shared state for the context gauge popup. The dial and the card live in
|
||||
// different DOM subtrees, so open state and placement are coordinated here.
|
||||
// centerX and bottom place the card just above the dial: both are measured
|
||||
// once at open time, relative to the closest form ancestor; the dial and
|
||||
// the card share that positioning frame, so the values stay exact for the
|
||||
// whole time the card is open.
|
||||
// Mouse pointers open on hover with a short grace delay to travel from
|
||||
// dial to card; touch pointers toggle on tap.
|
||||
import {
|
||||
CONTEXT_GAUGE_CARD_HALF_WIDTH_PX,
|
||||
CONTEXT_GAUGE_CLOSE_GRACE_MS,
|
||||
CONTEXT_GAUGE_DIAL_GAP_PX,
|
||||
CONTEXT_GAUGE_EDGE_MARGIN_PX
|
||||
} from '$lib/constants';
|
||||
|
||||
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let lastPointerType = '';
|
||||
|
||||
export const gaugePopup = $state({ bottom: 0, centerX: 0, open: false });
|
||||
|
||||
function openFrom(trigger: HTMLElement): void {
|
||||
clearTimeout(closeTimer);
|
||||
const frame = trigger.closest('form');
|
||||
|
||||
if (frame) {
|
||||
const frameRect = frame.getBoundingClientRect();
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const centerX = triggerRect.left + triggerRect.width / 2 - frameRect.left;
|
||||
const min = CONTEXT_GAUGE_CARD_HALF_WIDTH_PX + CONTEXT_GAUGE_EDGE_MARGIN_PX;
|
||||
const max = frameRect.width - CONTEXT_GAUGE_CARD_HALF_WIDTH_PX - CONTEXT_GAUGE_EDGE_MARGIN_PX;
|
||||
|
||||
gaugePopup.centerX = Math.min(Math.max(centerX, min), Math.max(min, max));
|
||||
gaugePopup.bottom = frameRect.bottom - triggerRect.top + CONTEXT_GAUGE_DIAL_GAP_PX;
|
||||
}
|
||||
|
||||
gaugePopup.open = true;
|
||||
}
|
||||
|
||||
function toggleFrom(trigger: HTMLElement): void {
|
||||
if (gaugePopup.open) {
|
||||
clearTimeout(closeTimer);
|
||||
gaugePopup.open = false;
|
||||
} else {
|
||||
openFrom(trigger);
|
||||
}
|
||||
}
|
||||
|
||||
export function gaugePopupClose(): void {
|
||||
clearTimeout(closeTimer);
|
||||
gaugePopup.open = false;
|
||||
}
|
||||
|
||||
export function gaugeTriggerPointerDown(event: PointerEvent): void {
|
||||
lastPointerType = event.pointerType;
|
||||
}
|
||||
|
||||
export function gaugeTriggerClick(event: MouseEvent): void {
|
||||
if (lastPointerType !== 'touch') return;
|
||||
|
||||
toggleFrom(event.currentTarget as HTMLElement);
|
||||
}
|
||||
|
||||
export function gaugeTriggerKeydown(event: KeyboardEvent): void {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
|
||||
event.preventDefault();
|
||||
toggleFrom(event.currentTarget as HTMLElement);
|
||||
}
|
||||
|
||||
export function gaugeTriggerEnter(event: PointerEvent): void {
|
||||
if (event.pointerType !== 'mouse') return;
|
||||
|
||||
openFrom(event.currentTarget as HTMLElement);
|
||||
}
|
||||
|
||||
export function gaugeTriggerLeave(event: PointerEvent): void {
|
||||
if (event.pointerType !== 'mouse') return;
|
||||
|
||||
scheduleClose();
|
||||
}
|
||||
|
||||
export function gaugeCardEnter(event: PointerEvent): void {
|
||||
if (event.pointerType !== 'mouse') return;
|
||||
|
||||
clearTimeout(closeTimer);
|
||||
}
|
||||
|
||||
export function gaugeCardLeave(event: PointerEvent): void {
|
||||
if (event.pointerType !== 'mouse') return;
|
||||
|
||||
scheduleClose();
|
||||
}
|
||||
|
||||
function scheduleClose(): void {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = setTimeout(() => {
|
||||
gaugePopup.open = false;
|
||||
}, CONTEXT_GAUGE_CLOSE_GRACE_MS);
|
||||
}
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
<script lang="ts">
|
||||
import ChatFormCurrentWorkingDirectoryChip from './ChatFormCurrentWorkingDirectoryChip.svelte';
|
||||
import ChatFormCurrentWorkingDirectoryResultsList from './ChatFormCurrentWorkingDirectoryResultsList.svelte';
|
||||
import { FolderOpen } from '@lucide/svelte';
|
||||
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { DEFAULT_MOBILE_BREAKPOINT, HOME_TILDE, SEARCH, UI_DATA_ATTRS } from '$lib/constants';
|
||||
import { BuiltInTool, GlobSearchType, KeyboardKey, ToolSource } from '$lib/enums';
|
||||
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
|
||||
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
|
||||
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
|
||||
import { ToolsService } from '$lib/services/tools.service';
|
||||
import { conversationsStore, toolsStore } from '$lib/stores';
|
||||
import type { GlobEntry } from '$lib/types';
|
||||
import {
|
||||
abbreviateHome,
|
||||
buildCaseInsensitiveGlob,
|
||||
joinPath,
|
||||
lastPathSegment,
|
||||
runGlobSearchWithChildren
|
||||
} from '$lib/utils';
|
||||
|
||||
// Microtask delay so the popover's focus scope tears down first.
|
||||
const FOCUS_DELAY_MS = 0;
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
directory?: string | null;
|
||||
/** Controlled open state; the host owns it so the chip click and the
|
||||
* `/cwd` slash command open the picker through the same path. */
|
||||
isOpen: boolean;
|
||||
/** Two-way bound query, kept in sync with the text after `/cwd `. */
|
||||
query: string;
|
||||
/** Anchor at the form's top edge so the popover floats above the box. */
|
||||
customAnchor?: HTMLElement | null;
|
||||
onChange?: (directory: string | null) => void;
|
||||
/** Lets the host refocus the chat input after the popover closes. */
|
||||
onClose?: () => void;
|
||||
/** Fired when the chip is clicked so the host can open the picker. */
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
customAnchor = null,
|
||||
directory = null,
|
||||
disabled = false,
|
||||
isOpen,
|
||||
onChange,
|
||||
onClose,
|
||||
onOpen,
|
||||
query = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
// File System Access API is opt-in (Chrome / Edge / Opera): the popover
|
||||
// exposes a "Browse" button only when available.
|
||||
const pickerSupported =
|
||||
typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function';
|
||||
|
||||
// When the server does not serve file_glob_search or the user disabled
|
||||
// it, the picker still opens for manual entry but explains why search is
|
||||
// unavailable instead of firing searches that would only fail. Browse is
|
||||
// hidden too: it resolves the picked folder name through the same tool.
|
||||
const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.SERVER_FILE_GLOB_SEARCH));
|
||||
// effective policy: the active conversation's tool policy, or global defaults
|
||||
const fileSearchEnabled = $derived(
|
||||
fileSearchKey !== null &&
|
||||
conversationsStore.preferences.isToolEnabled(fileSearchKey) &&
|
||||
conversationsStore.preferences.isCategoryEnabled(ToolSource.SERVER)
|
||||
);
|
||||
const searchUnavailableMessage = $derived(
|
||||
fileSearchKey === null
|
||||
? 'File search is unavailable on this server - type a full path and press Enter'
|
||||
: 'File search is disabled - type a full path and press Enter, or enable "Search files" in Settings > Tools'
|
||||
);
|
||||
|
||||
let searchInputRef: HTMLInputElement | null = $state(null);
|
||||
|
||||
let queryResults = $state<string[]>([]);
|
||||
let searchError = $state<string | null>(null);
|
||||
let listContainer = $state<HTMLDivElement | null>(null);
|
||||
|
||||
const nav = usePickerNavigation({
|
||||
count: () => queryResults.length,
|
||||
isOpen: () => isOpen,
|
||||
onClose: closePicker,
|
||||
onSelect: (index) => commit(queryResults[index])
|
||||
});
|
||||
|
||||
let homeBase = $derived(toolsStore.serverHome);
|
||||
|
||||
// Resolve home eagerly so the chip can abbreviate before the picker opens.
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
void toolsStore.resolveServerHome();
|
||||
});
|
||||
|
||||
// HTML `autofocus` is unreliable on dynamically shown elements.
|
||||
$effect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
setTimeout(() => searchInputRef?.focus(), FOCUS_DELAY_MS);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const q = query.trim();
|
||||
|
||||
nav.reset(-1);
|
||||
|
||||
if (q && fileSearchEnabled) {
|
||||
search.run(q);
|
||||
} else {
|
||||
search.cancel();
|
||||
queryResults = [];
|
||||
searchError = null;
|
||||
nav.reset(-1);
|
||||
searchScope = homeBase ?? HOME_TILDE;
|
||||
}
|
||||
});
|
||||
|
||||
useScrollActiveRow({
|
||||
dataAttr: UI_DATA_ATTRS.RESULT_INDEX,
|
||||
getContainer: () => listContainer,
|
||||
getCount: () => queryResults.length,
|
||||
getIndex: () => nav.hoveredIndex,
|
||||
getTrigger: () => nav.scrollTrigger
|
||||
});
|
||||
|
||||
let searchScope = $state(HOME_TILDE);
|
||||
|
||||
// An exactly-typed directory is "entered": the shared search lists its
|
||||
// children too, so path navigation does not require a trailing slash.
|
||||
const search = useDebouncedSearch({
|
||||
canRun: () => isOpen && fileSearchEnabled,
|
||||
debounceMs: SEARCH.DEBOUNCE_MS,
|
||||
getQuery: () => query.trim(),
|
||||
run: async (q, signal, isCurrent) => {
|
||||
const trimmed = q.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
queryResults = [];
|
||||
searchError = null;
|
||||
nav.reset(-1);
|
||||
searchScope = homeBase ?? HOME_TILDE;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Generous limit: ranking is client-side, only the top
|
||||
// MAX_RESULTS_SHOWN are shown.
|
||||
const res = await runGlobSearchWithChildren(
|
||||
trimmed,
|
||||
homeBase ?? HOME_TILDE,
|
||||
SEARCH.MAX_DEPTH,
|
||||
SEARCH.LIMIT,
|
||||
signal,
|
||||
{ type: GlobSearchType.DIR }
|
||||
);
|
||||
|
||||
if (!isCurrent()) return;
|
||||
|
||||
if (res.error) {
|
||||
queryResults = [];
|
||||
nav.reset(-1);
|
||||
searchError = res.error;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
searchScope = res.exactDir ?? res.args.path;
|
||||
queryResults = res.entries.map((e) => e.path).slice(0, SEARCH.MAX_RESULTS_SHOWN);
|
||||
|
||||
if (queryResults.length > 0) {
|
||||
nav.reset(0);
|
||||
nav.bumpScroll(); // scroll the list back to the top (first item is hovered)
|
||||
} else {
|
||||
nav.reset(-1);
|
||||
}
|
||||
|
||||
searchError = null;
|
||||
} catch (err) {
|
||||
if (!isCurrent() || signal.aborted) return;
|
||||
|
||||
queryResults = [];
|
||||
nav.reset(-1);
|
||||
searchError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Single funnel for every local close so the host refocus always fires.
|
||||
function closePicker() {
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
function commit(path: string) {
|
||||
onChange?.(path);
|
||||
closePicker();
|
||||
}
|
||||
|
||||
function setDirectory(value: string) {
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) return;
|
||||
|
||||
onChange?.(trimmed);
|
||||
}
|
||||
|
||||
// Resolve a browser-picked folder name (which exposes only the leaf name)
|
||||
// to a server-side absolute path; null when the server cannot locate it,
|
||||
// so the caller fails visibly instead of committing a bare leaf name.
|
||||
async function resolveNativeName(name: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await ToolsService.executeToolRaw(BuiltInTool.SERVER_FILE_GLOB_SEARCH, {
|
||||
include: buildCaseInsensitiveGlob(name),
|
||||
limit: SEARCH.NATIVE_LIMIT,
|
||||
max_depth: SEARCH.NATIVE_MAX_DEPTH,
|
||||
path: homeBase ?? HOME_TILDE,
|
||||
type: GlobSearchType.DIR
|
||||
});
|
||||
const base = typeof res.base === 'string' ? res.base : '';
|
||||
const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
|
||||
const match = entries.find(
|
||||
(e) => lastPathSegment(e.path).toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
|
||||
return match ? joinPath(base, match.path) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function browseNative() {
|
||||
if (disabled || !window.showDirectoryPicker) return;
|
||||
|
||||
try {
|
||||
const handle = await window.showDirectoryPicker();
|
||||
const path = await resolveNativeName(handle.name);
|
||||
|
||||
if (path) {
|
||||
setDirectory(path);
|
||||
closePicker();
|
||||
} else {
|
||||
// keep the previous cwd and fail visibly instead of committing a
|
||||
// bare leaf name that would resolve against the server cwd
|
||||
searchError = `Could not resolve "${handle.name}" to a server path`;
|
||||
}
|
||||
} catch (err) {
|
||||
// user cancelled - silently ignore; other errors are logged
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
|
||||
console.error('[ChatFormCurrentWorkingDirectory] showDirectoryPicker failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const value = query.trim();
|
||||
|
||||
if (!value) {
|
||||
closePicker();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setDirectory(value);
|
||||
closePicker();
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === KeyboardKey.ENTER) {
|
||||
event.preventDefault();
|
||||
|
||||
if (nav.hoveredIndex >= 0 && queryResults[nav.hoveredIndex]) {
|
||||
commit(queryResults[nav.hoveredIndex]);
|
||||
} else if (queryResults.length === 0) {
|
||||
handleSubmit();
|
||||
}
|
||||
} else if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||
if (queryResults.length > 0) {
|
||||
event.preventDefault();
|
||||
nav.move(1);
|
||||
}
|
||||
} else if (event.key === KeyboardKey.ARROW_UP) {
|
||||
if (queryResults.length > 0) {
|
||||
event.preventDefault();
|
||||
nav.move(-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearDirectory(event?: MouseEvent) {
|
||||
// Stop the click from bubbling into the chip button and re-opening
|
||||
// the picker on top of the now-cleared state.
|
||||
event?.stopPropagation();
|
||||
event?.preventDefault();
|
||||
onChange?.(null);
|
||||
closePicker();
|
||||
}
|
||||
|
||||
function handleDismiss(event?: MouseEvent) {
|
||||
event?.stopPropagation();
|
||||
event?.preventDefault();
|
||||
|
||||
if (directory) {
|
||||
clearDirectory(event);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (open) {
|
||||
void toolsStore.resolveServerHome();
|
||||
} else {
|
||||
search.cancel();
|
||||
// bits-ui-initiated close (Escape on the content, outside-click) -
|
||||
// the only path that bypasses closePicker().
|
||||
onClose?.();
|
||||
}
|
||||
}
|
||||
|
||||
let innerWidth = $state(0);
|
||||
const showTooltip = $derived(innerWidth > DEFAULT_MOBILE_BREAKPOINT);
|
||||
</script>
|
||||
|
||||
<button
|
||||
class={[
|
||||
'justify-self-start flex min-w-0 w-auto items-center gap-1 mt-1.5 py-1 px-2 backdrop-blur-2xl rounded-md',
|
||||
className
|
||||
]}
|
||||
{disabled}
|
||||
onclick={onOpen}
|
||||
type="button"
|
||||
>
|
||||
<ChatFormCurrentWorkingDirectoryChip
|
||||
{directory}
|
||||
{disabled}
|
||||
{homeBase}
|
||||
onClear={handleDismiss}
|
||||
{showTooltip}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Popover.Root onOpenChange={handleOpenChange} open={isOpen}>
|
||||
<Popover.Trigger
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 opacity-0"
|
||||
tabindex={-1}
|
||||
>
|
||||
<span class="sr-only">Open working directory picker</span>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
align="start"
|
||||
class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl"
|
||||
{customAnchor}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onkeydown={handleKeydown}
|
||||
preventScroll={false}
|
||||
side="top"
|
||||
sideOffset={12}
|
||||
>
|
||||
<div class="p-2 min-h-22 flex flex-col justify-between">
|
||||
<SearchInput
|
||||
bind:ref={searchInputRef}
|
||||
bind:value={query}
|
||||
class="w-full"
|
||||
onClose={closePicker}
|
||||
placeholder="Choose working directory"
|
||||
/>
|
||||
|
||||
{#if !fileSearchEnabled}
|
||||
<div class="px-2 py-1.5 text-sm text-muted-foreground">{searchUnavailableMessage}</div>
|
||||
{:else if query.trim() && (search.isSearching || queryResults.length > 0 || searchError)}
|
||||
<ChatFormCurrentWorkingDirectoryResultsList
|
||||
bind:container={listContainer}
|
||||
error={searchError}
|
||||
hoveredIndex={nav.hoveredIndex}
|
||||
isSearching={search.isSearching}
|
||||
onCommit={commit}
|
||||
onHover={(index) => nav.setHover(index)}
|
||||
rawQuery={query}
|
||||
results={queryResults}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if pickerSupported && fileSearchEnabled}
|
||||
<button
|
||||
class="-mt-1 flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={browseNative}
|
||||
type="button"
|
||||
>
|
||||
<FolderOpen class="size-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
<span>Browse</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if homeBase && fileSearchEnabled}
|
||||
<div aria-hidden="true" class="-mx-2 my-2 h-px bg-border/20"></div>
|
||||
|
||||
<span class="px-2 py-1.5 font-mono text-[10px]">
|
||||
Searching in:
|
||||
|
||||
<span class="truncate text-muted-foreground/70" title={searchScope}
|
||||
>{abbreviateHome(searchScope, homeBase)}</span
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<svelte:window bind:innerWidth />
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { Folder, X } from '@lucide/svelte';
|
||||
import { ActionIcon } from '$lib/components/app/actions';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { SET_WORKING_DIRECTORY_LABEL } from '$lib/constants';
|
||||
import { abbreviateWorkingDir } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
directory?: string | null;
|
||||
homeBase?: string | null;
|
||||
disabled?: boolean;
|
||||
showTooltip?: boolean;
|
||||
onClear?: (event?: MouseEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
directory = null,
|
||||
disabled = false,
|
||||
homeBase = null,
|
||||
onClear,
|
||||
showTooltip = false
|
||||
}: Props = $props();
|
||||
|
||||
const displayLabel = $derived(
|
||||
directory ? abbreviateWorkingDir(directory, homeBase) : SET_WORKING_DIRECTORY_LABEL
|
||||
);
|
||||
// Full path surface: hover the abbreviated label to recall the exact directory.
|
||||
const displayLabelTitle = $derived(directory ?? '');
|
||||
</script>
|
||||
|
||||
<span
|
||||
class:text-foreground={directory}
|
||||
class="text-muted-foreground inline-flex items-center gap-1 text-xs group"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1 cursor-pointer">
|
||||
<Folder class="w-3.5 h-3.5" />
|
||||
|
||||
{#if showTooltip && displayLabelTitle}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<span {...props} class="max-w-64 truncate">{displayLabel}</span>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>{displayLabelTitle}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else}
|
||||
<span class="max-w-64 truncate">{displayLabel}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if directory}
|
||||
<div
|
||||
class="w-0 overflow-hidden opacity-0 transition-[width,opacity] duration-200 ease-out group-hover:w-auto group-hover:opacity-100"
|
||||
>
|
||||
<ActionIcon
|
||||
ariaLabel="Reset working directory"
|
||||
class="!h-4 !w-4 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
{disabled}
|
||||
icon={X}
|
||||
iconSize="h-3 w-3"
|
||||
onclick={onClear}
|
||||
stopPropagationOnClick
|
||||
tooltip="Reset working directory"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</span>
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { Folder } from '@lucide/svelte';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import { UI_DATA_ATTRS } from '$lib/constants';
|
||||
import { highlightMatch } from '$lib/utils';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
// Fly-in transition for the results list.
|
||||
const FLY_Y_PX = -4;
|
||||
const FLY_DURATION_MS = 100;
|
||||
|
||||
interface Props {
|
||||
results: string[];
|
||||
hoveredIndex: number;
|
||||
isSearching: boolean;
|
||||
error: string | null;
|
||||
rawQuery: string;
|
||||
container?: HTMLDivElement | null;
|
||||
onCommit?: (path: string) => void;
|
||||
onHover?: (index: number) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
container = $bindable(null),
|
||||
error,
|
||||
hoveredIndex,
|
||||
isSearching,
|
||||
onCommit,
|
||||
onHover,
|
||||
rawQuery,
|
||||
results
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={container}
|
||||
transition:fly={{ duration: FLY_DURATION_MS, y: FLY_Y_PX }}
|
||||
class="max-h-48 overflow-y-auto py-2"
|
||||
>
|
||||
{#if isSearching && results.length === 0}
|
||||
<div class="px-2 py-1.5 text-sm text-muted-foreground">Searching...</div>
|
||||
{:else if error}
|
||||
<div class="px-2 py-1.5 text-sm text-destructive">{error}</div>
|
||||
{:else if results.length === 0}
|
||||
<div class="px-2 py-1.5 text-sm text-muted-foreground">No matching folders</div>
|
||||
{:else}
|
||||
{#each results as path, index (path)}
|
||||
<button
|
||||
type="button"
|
||||
{...{ [UI_DATA_ATTRS.RESULT_INDEX]: index }}
|
||||
class={cn(
|
||||
'relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground'
|
||||
)}
|
||||
data-highlighted={index === hoveredIndex ? '' : undefined}
|
||||
onclick={() => onCommit?.(path)}
|
||||
onmouseenter={() => onHover?.(index)}
|
||||
>
|
||||
<Folder class="size-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-left">
|
||||
{#each highlightMatch(path, rawQuery.trim()) as seg, segIndex (segIndex)}
|
||||
{#if seg.match}
|
||||
<mark class="rounded bg-yellow-200/60 px-0.5 text-foreground dark:bg-yellow-500/30"
|
||||
>{seg.text}</mark
|
||||
>
|
||||
{:else}
|
||||
{seg.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import ChatFormInputBasic from './ChatFormInputBasic.svelte';
|
||||
import ChatFormInputRich from './ChatFormInputRich.svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
onInput?: () => void;
|
||||
onKeydown?: (event: KeyboardEvent) => void;
|
||||
onPaste?: (event: ClipboardEvent) => void;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
useRichInput?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
disabled = false,
|
||||
onInput,
|
||||
onKeydown,
|
||||
onPaste,
|
||||
placeholder = 'Ask anything...',
|
||||
useRichInput = false,
|
||||
value = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
let basicRef: ChatFormInputBasic | undefined = $state();
|
||||
let richRef: ChatFormInputRich | undefined = $state();
|
||||
|
||||
// The two renderers share one imperative handle (focus/caret/height), so
|
||||
// the parent can drive whichever variant is mounted through this one.
|
||||
export function getElement() {
|
||||
return useRichInput ? richRef?.getElement() : basicRef?.getElement();
|
||||
}
|
||||
|
||||
export function focus() {
|
||||
if (useRichInput) richRef?.focus();
|
||||
else basicRef?.focus();
|
||||
}
|
||||
|
||||
export function resetHeight() {
|
||||
if (useRichInput) richRef?.resetHeight();
|
||||
else basicRef?.resetHeight();
|
||||
}
|
||||
|
||||
export function getCaretOffset(): number {
|
||||
return useRichInput ? (richRef?.getCaretOffset() ?? 0) : (basicRef?.getCaretOffset() ?? 0);
|
||||
}
|
||||
|
||||
export function setCaretOffset(offset: number) {
|
||||
if (useRichInput) richRef?.setCaretOffset(offset);
|
||||
else basicRef?.setCaretOffset(offset);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if useRichInput}
|
||||
<ChatFormInputRich
|
||||
bind:this={richRef}
|
||||
bind:value
|
||||
class={className}
|
||||
{disabled}
|
||||
{onInput}
|
||||
{onKeydown}
|
||||
{onPaste}
|
||||
{placeholder}
|
||||
/>
|
||||
{:else}
|
||||
<ChatFormInputBasic
|
||||
bind:this={basicRef}
|
||||
bind:value
|
||||
class={className}
|
||||
{disabled}
|
||||
{onInput}
|
||||
{onKeydown}
|
||||
{onPaste}
|
||||
{placeholder}
|
||||
/>
|
||||
{/if}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import { deviceStore } from '$lib/stores';
|
||||
import { autoResizeTextarea } from '$lib/utils';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
onInput?: () => void;
|
||||
onKeydown?: (event: KeyboardEvent) => void;
|
||||
onPaste?: (event: ClipboardEvent) => void;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
disabled = false,
|
||||
onInput,
|
||||
onKeydown,
|
||||
onPaste,
|
||||
placeholder = 'Ask anything...',
|
||||
value = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
let textareaElement: HTMLTextAreaElement | undefined;
|
||||
|
||||
onMount(() => {
|
||||
if (textareaElement) {
|
||||
autoResizeTextarea(textareaElement);
|
||||
textareaElement.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
|
||||
export function getElement() {
|
||||
return textareaElement;
|
||||
}
|
||||
|
||||
export function focus() {
|
||||
if (deviceStore.isMobile) return;
|
||||
|
||||
textareaElement?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
export function resetHeight() {
|
||||
if (textareaElement) {
|
||||
textareaElement.style.height = '1rem';
|
||||
}
|
||||
}
|
||||
|
||||
// Plain-text caret offsets, shared with the rich chat form input variant so
|
||||
// the picker/paste flows can address either renderer through one handle.
|
||||
export function getCaretOffset(): number {
|
||||
if (!textareaElement) return 0;
|
||||
|
||||
return textareaElement.selectionStart ?? textareaElement.value.length;
|
||||
}
|
||||
|
||||
export function setCaretOffset(offset: number) {
|
||||
textareaElement?.setSelectionRange(offset, offset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex-1 {className}">
|
||||
<textarea
|
||||
bind:this={textareaElement}
|
||||
bind:value
|
||||
class={[
|
||||
'text-md min-h-12 w-full resize-none border-0 bg-transparent p-0 leading-6 outline-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
disabled && 'cursor-not-allowed'
|
||||
]}
|
||||
{disabled}
|
||||
oninput={(event) => {
|
||||
autoResizeTextarea(event.currentTarget);
|
||||
onInput?.();
|
||||
}}
|
||||
onkeydown={onKeydown}
|
||||
onpaste={onPaste}
|
||||
{placeholder}
|
||||
style="max-height: var(--max-message-height);"
|
||||
></textarea>
|
||||
</div>
|
||||
+31
@@ -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}
|
||||
class="hidden {className}"
|
||||
{multiple}
|
||||
onchange={handleFileSelect}
|
||||
type="file"
|
||||
/>
|
||||
+867
@@ -0,0 +1,867 @@
|
||||
<script lang="ts">
|
||||
import { CODE_BLOCK, CODE_TOKEN_ATTR, UI_DATA_ATTRS } from '$lib/constants';
|
||||
import { BooleanString, ChatFormInputRichTokenKind, ColorMode } from '$lib/enums';
|
||||
import { deviceStore } from '$lib/stores';
|
||||
import type { ChatFormInputRichToken } from '$lib/types';
|
||||
import type { SourceHistoryEntry } from '$lib/utils';
|
||||
import {
|
||||
badgeAwareWordJump,
|
||||
buildFragment,
|
||||
domMatchesTokens,
|
||||
highlightCode,
|
||||
isIMEComposing,
|
||||
isOffsetInCodeBlock,
|
||||
leadingBadgeEdgeOffset,
|
||||
rangeToTextOffset,
|
||||
serializeContent,
|
||||
SourceHistory,
|
||||
stripBlockBoundaryLineBreaks,
|
||||
syncCodeBlockHatches,
|
||||
textOffsetToRange,
|
||||
tokenizeContent
|
||||
} from '$lib/utils';
|
||||
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
||||
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
||||
import { mode } from 'mode-watcher';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
onInput?: () => void;
|
||||
onKeydown?: (event: KeyboardEvent) => void;
|
||||
onPaste?: (event: ClipboardEvent) => void;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
disabled = false,
|
||||
onInput,
|
||||
onKeydown,
|
||||
onPaste,
|
||||
placeholder = 'Ask anything...',
|
||||
value = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
let rootElement: HTMLDivElement | undefined = $state();
|
||||
let lastEmittedValue = '';
|
||||
let isComposing = $state(false);
|
||||
|
||||
// Undo/redo in source space: the imperative token rebuilds destroy the
|
||||
// browser's native undo stack.
|
||||
const history = new SourceHistory();
|
||||
|
||||
// Browsers disagree on what an empty rich chat form input contains (`<br>`,
|
||||
// `<div><br></div>`, or nothing), so emptiness is decided by the
|
||||
// serialized source, not the DOM shape.
|
||||
function syncEmptyState(serialized?: string) {
|
||||
if (!rootElement) return;
|
||||
|
||||
const source = serialized ?? serializeContent(rootElement);
|
||||
|
||||
rootElement.dataset.empty = source.length === 0 ? BooleanString.TRUE : BooleanString.FALSE;
|
||||
}
|
||||
|
||||
function renderTokens(tokens: ChatFormInputRichToken[]) {
|
||||
if (!rootElement) return;
|
||||
|
||||
const caret = rangeToTextOffset(rootElement, safeRange());
|
||||
|
||||
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the rich chat form input host, never its children
|
||||
rootElement.replaceChildren(buildFragment(tokens));
|
||||
|
||||
syncCodeBlockHatches(rootElement);
|
||||
highlightCodeBlocks(rootElement);
|
||||
|
||||
restoreCaret(caret);
|
||||
resizeHeight();
|
||||
syncEmptyState();
|
||||
}
|
||||
|
||||
// Last highlighted source segment per block element - typing inside
|
||||
// a block re-highlights only when the segment actually changed.
|
||||
const highlightedSegments = new WeakMap<HTMLElement, string>();
|
||||
|
||||
const CODE_BLOCK_OPEN_RE = /^```([^\n`]*)\n/;
|
||||
|
||||
/**
|
||||
* Apply syntax highlighting to a code block element's CONTENT. The
|
||||
* fence lines stay plain text, and the blank padding that
|
||||
* `highlightCode` trims is re-added as plain text, so the element's
|
||||
* textContent stays byte-exact with the source segment. Replaces
|
||||
* the element's children - callers restore the caret afterwards.
|
||||
* Returns false when nothing changed.
|
||||
*/
|
||||
function highlightCodeBlockElement(el: HTMLElement): boolean {
|
||||
const segment = el.textContent ?? '';
|
||||
|
||||
if (highlightedSegments.get(el) === segment) return false;
|
||||
|
||||
const open = CODE_BLOCK_OPEN_RE.exec(segment);
|
||||
|
||||
if (!open) return false;
|
||||
|
||||
const prefix = open[0];
|
||||
const language = open[1].trim().split(/\s+/)[0] ?? '';
|
||||
const content = segment.slice(prefix.length, -3);
|
||||
const leading = content.match(CODE_BLOCK.TRIM_LEADING_PADDING_REGEX)?.[0] ?? '';
|
||||
const trailing = content.match(CODE_BLOCK.TRIM_TRAILING_PADDING_REGEX)?.[0] ?? '';
|
||||
const core = content.slice(leading.length, content.length - trailing.length);
|
||||
// autoDetect off: re-guessing the language on every keystroke
|
||||
// costs ~38ms a call and flickers while typing
|
||||
const html = core ? highlightCode(core, language || 'text', false) : '';
|
||||
const tpl = document.createElement('template');
|
||||
|
||||
tpl.innerHTML = html;
|
||||
|
||||
el.replaceChildren(
|
||||
document.createTextNode(prefix + leading),
|
||||
tpl.content.cloneNode(true),
|
||||
document.createTextNode(trailing + '```')
|
||||
);
|
||||
highlightedSegments.set(el, segment);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function highlightCodeBlocks(root: HTMLElement) {
|
||||
for (const el of root.querySelectorAll<HTMLElement>(
|
||||
`code[${CODE_TOKEN_ATTR}="${ChatFormInputRichTokenKind.CODE_BLOCK}"]`
|
||||
)) {
|
||||
highlightCodeBlockElement(el);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-highlight the code block the caret sits in after an edit.
|
||||
* Skipped when the block's segment is unchanged since its last
|
||||
* highlight, so edits outside blocks cost nothing.
|
||||
*/
|
||||
function rehighlightCaretCodeBlock() {
|
||||
if (!rootElement) return;
|
||||
|
||||
const range = safeRange();
|
||||
|
||||
if (!range) return;
|
||||
|
||||
let node: Node | null = range.startContainer;
|
||||
|
||||
if (node === rootElement) {
|
||||
node = rootElement.childNodes[range.startOffset - 1] ?? null;
|
||||
}
|
||||
|
||||
while (node && node !== rootElement) {
|
||||
if (
|
||||
node instanceof HTMLElement &&
|
||||
node.getAttribute(CODE_TOKEN_ATTR) === ChatFormInputRichTokenKind.CODE_BLOCK
|
||||
) {
|
||||
const caret = rangeToTextOffset(rootElement, range);
|
||||
|
||||
if (highlightCodeBlockElement(node)) {
|
||||
restoreCaret(caret);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
node = node.parentNode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the caret inside a fenced code block region? Source-level
|
||||
* (not DOM-level) so the still-OPEN fence counts too: while the
|
||||
* user is typing a block, no closing ``` exists yet and the
|
||||
* buffer is plain text with no block element to find. Root-level
|
||||
* caret positions right at a closed block's edge (escape
|
||||
* hatches, element boundaries restored by `textOffsetToRange`)
|
||||
* resolve past the closing fence, so they count as OUTSIDE.
|
||||
*/
|
||||
function caretInCodeBlock(): boolean {
|
||||
if (!rootElement) return false;
|
||||
|
||||
return isOffsetInCodeBlock(
|
||||
serializeContent(rootElement),
|
||||
rangeToTextOffset(rootElement, safeRange())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* hljs theme for the highlighted code blocks. Mirrors
|
||||
* SyntaxHighlightedCode.svelte: one shared style element
|
||||
* (deduped via the data attribute) swapped on mode change.
|
||||
*/
|
||||
function loadHighlightTheme(isDark: boolean) {
|
||||
document
|
||||
.querySelectorAll(`style[${UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW}]`)
|
||||
.forEach((s) => s.remove());
|
||||
|
||||
const style = document.createElement('style');
|
||||
|
||||
style.setAttribute(UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW, BooleanString.TRUE);
|
||||
style.textContent = isDark ? githubDarkCss : githubLightCss;
|
||||
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadHighlightTheme(mode.current === ColorMode.DARK);
|
||||
});
|
||||
|
||||
function safeRange(): Range | null {
|
||||
if (!rootElement) return null;
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection || selection.rangeCount === 0) return null;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
if (!rootElement.contains(range.startContainer) || !rootElement.contains(range.endContainer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return range;
|
||||
}
|
||||
|
||||
function restoreCaret(offset: number, extend = false) {
|
||||
if (!rootElement) return;
|
||||
|
||||
const target = textOffsetToRange(rootElement, offset);
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection) return;
|
||||
|
||||
if (extend && selection.anchorNode) {
|
||||
selection.setBaseAndExtent(
|
||||
selection.anchorNode,
|
||||
selection.anchorOffset,
|
||||
target.startContainer,
|
||||
target.startOffset
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(target);
|
||||
}
|
||||
|
||||
function resizeHeight() {
|
||||
if (!rootElement) return;
|
||||
|
||||
rootElement.style.height = 'auto';
|
||||
rootElement.style.height = `${rootElement.scrollHeight}px`;
|
||||
}
|
||||
|
||||
function recordHistory(newGroup: boolean) {
|
||||
if (!rootElement) return;
|
||||
|
||||
history.push(
|
||||
{ caret: rangeToTextOffset(rootElement, safeRange()), value: lastEmittedValue },
|
||||
Date.now(),
|
||||
newGroup
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-emit the current markdown source value to the parent, then
|
||||
* reconcile the DOM against the token stream: when a code span
|
||||
* was just completed or broken, the token boundaries no longer
|
||||
* match the element structure and the DOM is rebuilt (caret
|
||||
* preserved through the source-offset mapping).
|
||||
*/
|
||||
function processInput(inputType?: string) {
|
||||
if (isComposing || !rootElement) return;
|
||||
|
||||
syncEmptyState();
|
||||
resizeHeight();
|
||||
|
||||
// Shift+Enter right after a code block leaves an all-newline
|
||||
// text node (the fence's separator line plus Chromium's
|
||||
// artificial end-of-buffer line break). Strip both so the caret
|
||||
// lands on the line directly below the block.
|
||||
if (inputType === 'insertLineBreak' || inputType === 'insertParagraph') {
|
||||
const caret = rangeToTextOffset(rootElement, safeRange());
|
||||
|
||||
if (stripBlockBoundaryLineBreaks(rootElement)) {
|
||||
restoreCaret(caret);
|
||||
} else {
|
||||
const source = serializeContent(rootElement);
|
||||
|
||||
let end = caret;
|
||||
|
||||
// the caret must end up after the inserted \n; some browsers
|
||||
// leave it before (stuck at the end of the old line). A
|
||||
// preceding \n means it already sits past the break
|
||||
// (Chromium's artificial trailing newline) - leave it.
|
||||
if (source[end] === '\n' && source[end - 1] !== '\n') {
|
||||
end += 1;
|
||||
restoreCaret(end);
|
||||
}
|
||||
|
||||
// a line break at the buffer end renders only with a second,
|
||||
// artificial trailing \n: a lone trailing \n is collapsed, so
|
||||
// the new line is invisible and the next typed character
|
||||
// consumes it. Append it when missing - unless the trailing
|
||||
// \n doubles as a block's separator line (source ends with
|
||||
// \n\n) or sits inside a block element.
|
||||
let last = rootElement.lastChild;
|
||||
|
||||
while (last && last.nodeName === 'BR') last = last.previousSibling;
|
||||
|
||||
if (
|
||||
end === source.length &&
|
||||
source.endsWith('\n') &&
|
||||
source[source.length - 2] !== '\n' &&
|
||||
last?.nodeType === Node.TEXT_NODE
|
||||
) {
|
||||
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the rich chat form input host, never its children
|
||||
rootElement.appendChild(document.createTextNode('\n'));
|
||||
restoreCaret(source.length);
|
||||
resizeHeight();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
syncCodeBlockHatches(rootElement);
|
||||
|
||||
const serialized = serializeContent(rootElement);
|
||||
|
||||
syncEmptyState(serialized);
|
||||
|
||||
if (serialized === lastEmittedValue) return;
|
||||
|
||||
// Plain typing/deletes coalesce per time window; structural edits
|
||||
// (paste, newline, cut, autocorrect) start a new undo group.
|
||||
recordHistory(inputType !== 'insertText' && !inputType?.startsWith('deleteContent'));
|
||||
|
||||
lastEmittedValue = serialized;
|
||||
value = serialized;
|
||||
|
||||
// Rebuild when token boundaries shifted (a code span was just
|
||||
// completed or broken) - the browser-owned text nodes cannot
|
||||
// restyle themselves across element boundaries.
|
||||
const tokens = tokenizeContent(serialized);
|
||||
|
||||
if (!domMatchesTokens(rootElement, tokens)) {
|
||||
renderTokens(tokens);
|
||||
|
||||
// The rebuild can re-shape the DOM in a way that changes the
|
||||
// serialization (e.g. Chromium merged trailing text into the
|
||||
// block element and the rebuild splits it back out, which
|
||||
// synthesizes the separator newline) - keep value in sync.
|
||||
const reserialized = serializeContent(rootElement);
|
||||
|
||||
if (reserialized !== serialized) {
|
||||
lastEmittedValue = reserialized;
|
||||
value = reserialized;
|
||||
}
|
||||
} else {
|
||||
rehighlightCaretCodeBlock();
|
||||
}
|
||||
|
||||
onInput?.();
|
||||
}
|
||||
|
||||
function handleInput(event: Event) {
|
||||
processInput((event as InputEvent).inputType);
|
||||
}
|
||||
|
||||
function handleCompositionStart() {
|
||||
isComposing = true;
|
||||
}
|
||||
|
||||
function handleCompositionEnd() {
|
||||
isComposing = false;
|
||||
processInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a line break at the caret MANUALLY. Native Shift+Enter at
|
||||
* the buffer end varies across browsers (a lone trailing \n that the
|
||||
* renderer collapses, or a <br> that the hatch sync strips), which
|
||||
* can leave the caret stuck on the old line; splitting the text node
|
||||
* ourselves keeps the DOM shape - and the caret - deterministic.
|
||||
* `processInput` then appends the artificial trailing \n when the
|
||||
* break lands at the buffer end.
|
||||
*/
|
||||
function insertLineBreak() {
|
||||
if (!rootElement) return;
|
||||
|
||||
const range = safeRange();
|
||||
|
||||
if (!range) return;
|
||||
|
||||
if (!range.collapsed) {
|
||||
range.deleteContents();
|
||||
}
|
||||
|
||||
const container = range.startContainer;
|
||||
const offset = range.startOffset;
|
||||
const nl = document.createTextNode('\n');
|
||||
|
||||
// a break at the very end of a code block exits the block (the
|
||||
// new line belongs below it, not inside)
|
||||
let exitBlock: HTMLElement | null = null;
|
||||
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
let node: Node | null = container.parentNode;
|
||||
|
||||
while (node && node !== rootElement) {
|
||||
if (
|
||||
node instanceof HTMLElement &&
|
||||
node.getAttribute(CODE_TOKEN_ATTR) === ChatFormInputRichTokenKind.CODE_BLOCK
|
||||
) {
|
||||
const tail = document.createRange();
|
||||
|
||||
tail.setStart(container, offset);
|
||||
tail.setEnd(node, node.childNodes.length);
|
||||
|
||||
if (tail.toString().length === 0) exitBlock = node;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
node = node.parentNode;
|
||||
}
|
||||
}
|
||||
|
||||
if (exitBlock) {
|
||||
exitBlock.after(nl);
|
||||
} else if (container.nodeType === Node.TEXT_NODE) {
|
||||
const text = container as Text;
|
||||
|
||||
if (offset === 0) {
|
||||
text.before(nl);
|
||||
} else if (offset === text.length) {
|
||||
text.after(nl);
|
||||
} else {
|
||||
text.splitText(offset).before(nl);
|
||||
}
|
||||
} else {
|
||||
container.insertBefore(nl, container.childNodes[offset] ?? null);
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
const after = document.createRange();
|
||||
|
||||
after.setStartAfter(nl);
|
||||
after.collapse(true);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(after);
|
||||
|
||||
processInput('insertLineBreak');
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrow escape to the line BEFORE a leading code block. Native
|
||||
* caret movement has no position above a buffer-starting block,
|
||||
* so a transient `<br>` hatch is created on demand: it gives the
|
||||
* caret a visible line, is consumed by the first character typed
|
||||
* on it, and is removed again when the caret leaves (see
|
||||
* handleSelectionChange). Returns true when the caret was moved.
|
||||
*/
|
||||
function moveCaretBeforeLeadingCodeBlock(key: string, extend: boolean): boolean {
|
||||
if (!rootElement) return false;
|
||||
|
||||
// a hatch already exists - native movement handles it
|
||||
if (rootElement.firstChild?.nodeName === 'BR') return false;
|
||||
|
||||
const first = rootElement.firstChild;
|
||||
|
||||
if (
|
||||
!(first instanceof HTMLElement) ||
|
||||
first.getAttribute(CODE_TOKEN_ATTR) !== ChatFormInputRichTokenKind.CODE_BLOCK
|
||||
)
|
||||
return false;
|
||||
|
||||
const range = safeRange();
|
||||
|
||||
if (!range || !range.collapsed) return false;
|
||||
|
||||
// the caret must sit inside the block: on its very first
|
||||
// character for ArrowLeft, anywhere on its first line for
|
||||
// ArrowUp
|
||||
if (!first.contains(range.startContainer)) return false;
|
||||
|
||||
const caret = rangeToTextOffset(rootElement, range);
|
||||
|
||||
if (key === 'ArrowLeft') {
|
||||
if (caret !== 0) return false;
|
||||
} else {
|
||||
const firstLineEnd = (first.textContent ?? '').indexOf('\n');
|
||||
|
||||
if (firstLineEnd !== -1 && caret > firstLineEnd) return false;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the rich chat form input host, never its children
|
||||
rootElement.prepend(document.createElement('br'));
|
||||
restoreCaret(0, extend);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the transient leading hatch once the caret leaves it.
|
||||
* The hatch only exists to give the caret a line above a leading
|
||||
* code block; with the caret anywhere else the empty line would
|
||||
* just be visual noise. Typing on the hatch line consumes it via
|
||||
* the stale-hatch removal in `syncCodeBlockHatches` instead (the
|
||||
* new text node takes its place before the block).
|
||||
*/
|
||||
function handleSelectionChange() {
|
||||
if (!rootElement) return;
|
||||
|
||||
const first = rootElement.firstChild;
|
||||
|
||||
if (first?.nodeName !== 'BR') return;
|
||||
|
||||
const second = first.nextSibling;
|
||||
|
||||
if (
|
||||
!(second instanceof HTMLElement) ||
|
||||
second.getAttribute(CODE_TOKEN_ATTR) !== ChatFormInputRichTokenKind.CODE_BLOCK
|
||||
)
|
||||
return;
|
||||
|
||||
const range = safeRange();
|
||||
const onHatch =
|
||||
range !== null && range.startContainer === rootElement && range.startOffset === 0;
|
||||
|
||||
if (!onHatch) {
|
||||
first.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo/redo is replayed from source snapshots (the token rebuilds
|
||||
* destroy the native undo stack). Arrow keys around badges are
|
||||
* repaired locally: a badge is a non-editable island, so plain
|
||||
* ArrowLeft after a leading badge has no native previous position
|
||||
* and word jumps overshoot it by a word.
|
||||
*
|
||||
* Plain Enter inside a fenced code block (closed, or still open
|
||||
* while being typed) acts as Shift+Enter and adds a line instead of
|
||||
* submitting. ArrowLeft/ArrowUp at the edge of a leading code block
|
||||
* create the transient before-block hatch.
|
||||
*/
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const mod = event.ctrlKey || event.metaKey;
|
||||
|
||||
if (mod && !event.altKey && !isComposing && rootElement) {
|
||||
const key = event.key.toLowerCase();
|
||||
const isUndo = key === 'z' && !event.shiftKey;
|
||||
const isRedo = key === 'y' || (key === 'z' && event.shiftKey);
|
||||
|
||||
if (isUndo || isRedo) {
|
||||
event.preventDefault();
|
||||
const current = {
|
||||
caret: rangeToTextOffset(rootElement, safeRange()),
|
||||
value: lastEmittedValue
|
||||
};
|
||||
const entry = isUndo ? history.undo(current) : history.redo(current);
|
||||
|
||||
if (entry) applyHistoryEntry(entry);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
event.shiftKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
!isIMEComposing(event) &&
|
||||
!disabled &&
|
||||
!caretInCodeBlock() &&
|
||||
safeRange()
|
||||
) {
|
||||
// Own the break outside code blocks: native end-of-buffer
|
||||
// behavior varies across browsers and can leave the caret
|
||||
// stuck on the old line (see insertLineBreak).
|
||||
event.preventDefault();
|
||||
insertLineBreak();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
!event.shiftKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
!isIMEComposing(event) &&
|
||||
caretInCodeBlock()
|
||||
) {
|
||||
// The native plain-Enter path must never run: it splits the
|
||||
// buffer into `<div>` wrappers that `serializeContent` cannot
|
||||
// see. `insertLineBreak` reproduces the Shift+Enter DOM (a `\n`
|
||||
// text node) and fires `input` synchronously, so the usual
|
||||
// re-tokenize/re-highlight follows.
|
||||
event.preventDefault();
|
||||
document.execCommand('insertLineBreak');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
rootElement &&
|
||||
(event.key === 'ArrowLeft' || event.key === 'ArrowUp') &&
|
||||
!event.altKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey
|
||||
) {
|
||||
if (moveCaretBeforeLeadingCodeBlock(event.key, event.shiftKey)) {
|
||||
event.preventDefault();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (rootElement && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
|
||||
const isWordJump = (event.altKey || event.ctrlKey) && !event.metaKey && !event.shiftKey;
|
||||
const isPlainLeft =
|
||||
event.key === 'ArrowLeft' && !event.altKey && !event.ctrlKey && !event.metaKey;
|
||||
|
||||
if (isWordJump || isPlainLeft) {
|
||||
const source = serializeContent(rootElement);
|
||||
const caret = rangeToTextOffset(rootElement, safeRange());
|
||||
const target = isWordJump
|
||||
? badgeAwareWordJump(source, caret, event.key === 'ArrowRight' ? 'forward' : 'backward')
|
||||
: leadingBadgeEdgeOffset(source, caret);
|
||||
|
||||
if (target !== null) {
|
||||
event.preventDefault();
|
||||
restoreCaret(target, event.shiftKey);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onKeydown?.(event);
|
||||
}
|
||||
|
||||
// lastEmittedValue is set before `value` so the sync effect treats the
|
||||
// change as our own and does not re-render.
|
||||
function applyHistoryEntry(entry: SourceHistoryEntry) {
|
||||
if (!rootElement) return;
|
||||
|
||||
renderTokens(tokenizeContent(entry.value));
|
||||
lastEmittedValue = entry.value;
|
||||
value = entry.value;
|
||||
onInput?.();
|
||||
restoreCaret(entry.caret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text paste. preventDefault + manual insertText keeps the
|
||||
* browser from producing stray `<div>` wrappers mid-paste; insertText
|
||||
* fires `input` synchronously, so `processInput` re-tokenizes the
|
||||
* buffer and rebuilds when the pasted text carries badge or code
|
||||
* tokens.
|
||||
*/
|
||||
function handlePasteEvent(event: ClipboardEvent) {
|
||||
const pasted = event.clipboardData?.getData('text/plain');
|
||||
|
||||
if (pasted && pasted.length > 0) {
|
||||
event.preventDefault();
|
||||
|
||||
// Snap a collapsed caret through the offset mapping first: at
|
||||
// element-boundary carets (e.g. right before a badge) Chromium's
|
||||
// insertText can drop the preceding text node's trailing whitespace.
|
||||
const range = safeRange();
|
||||
|
||||
if (rootElement && range && range.collapsed) {
|
||||
restoreCaret(rangeToTextOffset(rootElement, range));
|
||||
}
|
||||
|
||||
document.execCommand('insertText', false, pasted);
|
||||
}
|
||||
}
|
||||
|
||||
// The parent's paste handler runs first and preventDefaults when it
|
||||
// consumes the event (files, quoted prompts, long text).
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
onPaste?.(event);
|
||||
|
||||
if (!event.defaultPrevented) {
|
||||
handlePasteEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
// The selection as markdown SOURCE (each badge contributes its full
|
||||
// `[name](file://...)` link), so copy/cut carry raw markdown and
|
||||
// pasting back re-renders the badges. Null for collapsed/outside
|
||||
// selections - native clipboard behavior is fine there.
|
||||
function selectionSourceSlice(): { text: string; range: Range } | null {
|
||||
if (!rootElement) return null;
|
||||
|
||||
const range = safeRange();
|
||||
|
||||
if (!range || range.collapsed) return null;
|
||||
|
||||
const startRange = range.cloneRange();
|
||||
|
||||
startRange.collapse(true);
|
||||
|
||||
const source = serializeContent(rootElement);
|
||||
const start = rangeToTextOffset(rootElement, startRange);
|
||||
const end = rangeToTextOffset(rootElement, range);
|
||||
|
||||
return { range, text: source.slice(start, end) };
|
||||
}
|
||||
|
||||
function handleCopy(event: ClipboardEvent) {
|
||||
const slice = selectionSourceSlice();
|
||||
|
||||
if (!slice) return;
|
||||
|
||||
event.clipboardData?.setData('text/plain', slice.text);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleCut(event: ClipboardEvent) {
|
||||
const slice = selectionSourceSlice();
|
||||
|
||||
if (!slice) return;
|
||||
|
||||
event.clipboardData?.setData('text/plain', slice.text);
|
||||
event.preventDefault();
|
||||
|
||||
// preventDefault suppresses the native deletion, so remove the
|
||||
// selection manually and re-emit.
|
||||
slice.range.deleteContents();
|
||||
processInput('deleteByCut');
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// untrack: the DOM is managed manually from input events, so the
|
||||
// initial render must not subscribe to the value.
|
||||
renderTokens(tokenizeContent(untrack(() => value)));
|
||||
lastEmittedValue = untrack(() => value ?? '');
|
||||
resizeHeight();
|
||||
syncEmptyState();
|
||||
document.addEventListener('selectionchange', handleSelectionChange);
|
||||
|
||||
if (!deviceStore.isMobile) {
|
||||
rootElement?.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
document.removeEventListener('selectionchange', handleSelectionChange);
|
||||
});
|
||||
|
||||
// External `value` updates. When incoming === lastEmittedValue the
|
||||
// change came from our own input, so leave the DOM alone - the
|
||||
// browser already owns the right shape.
|
||||
$effect(() => {
|
||||
const incoming = value ?? '';
|
||||
|
||||
if (incoming === lastEmittedValue) return;
|
||||
|
||||
recordHistory(true); // external edit (mention insert, clear, ...): own undo step
|
||||
renderTokens(tokenizeContent(incoming));
|
||||
lastEmittedValue = incoming;
|
||||
});
|
||||
|
||||
export function getElement() {
|
||||
return rootElement;
|
||||
}
|
||||
|
||||
export function getCaretOffset(): number {
|
||||
if (!rootElement) return 0;
|
||||
|
||||
return rangeToTextOffset(rootElement, safeRange());
|
||||
}
|
||||
|
||||
// Focus first: `selection.addRange` requires it on some browsers.
|
||||
export function setCaretOffset(offset: number) {
|
||||
if (rootElement && rootElement !== document.activeElement) {
|
||||
rootElement.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
restoreCaret(offset);
|
||||
}
|
||||
|
||||
export function focus() {
|
||||
if (deviceStore.isMobile) return;
|
||||
|
||||
rootElement?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
export function resetHeight() {
|
||||
if (rootElement) {
|
||||
rootElement.style.height = '';
|
||||
resizeHeight();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex-1 {className} mb-0.5">
|
||||
<div
|
||||
bind:this={rootElement}
|
||||
aria-disabled={disabled}
|
||||
aria-multiline="true"
|
||||
aria-placeholder={placeholder}
|
||||
class={[
|
||||
'chat-form-input-rich text-md min-h-12 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 leading-6 outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
disabled && 'cursor-not-allowed'
|
||||
]}
|
||||
contenteditable={!disabled}
|
||||
data-placeholder={placeholder}
|
||||
oncompositionend={handleCompositionEnd}
|
||||
oncompositionstart={handleCompositionStart}
|
||||
oncopy={handleCopy}
|
||||
oncut={handleCut}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
onpaste={handlePaste}
|
||||
role="textbox"
|
||||
style="max-height: var(--max-message-height);"
|
||||
tabindex={disabled ? -1 : 0}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* pre-wrap is load-bearing: without it Chromium collapses \n in
|
||||
text nodes and converts them to spaces while typing */
|
||||
.chat-form-input-rich {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chat-form-input-rich:global([data-empty='true'])::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--muted-foreground);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Inline code - mirrors markdown-content.css */
|
||||
.chat-form-input-rich :global(code[data-code-token='code_inline']) {
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Fenced code block - mirrors .code-block-wrapper in markdown-content.css */
|
||||
.chat-form-input-rich :global(code[data-code-token='code_block']) {
|
||||
display: block;
|
||||
margin: 0.25rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--code-background);
|
||||
color: var(--code-foreground);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
</style>
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { ChatAttachmentsListItemMcpResource, ScrollCarousel } from '$lib/components/app';
|
||||
import { ScrollCarouselVariant } from '$lib/enums';
|
||||
import { mcpStore } from '$lib/stores';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
onResourceClick?: (uri: string) => void;
|
||||
}
|
||||
|
||||
let { class: className, onResourceClick }: Props = $props();
|
||||
|
||||
const attachments = $derived(mcpStore.resources.attachments);
|
||||
const hasAttachments = $derived(mcpStore.resources.hasAttachments);
|
||||
|
||||
function handleRemove(attachmentId: string) {
|
||||
mcpStore.removeResourceAttachment(attachmentId);
|
||||
}
|
||||
|
||||
function handleResourceClick(uri: string) {
|
||||
onResourceClick?.(uri);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if hasAttachments}
|
||||
<div class={className}>
|
||||
<ScrollCarousel gapSize="2" variant={ScrollCarouselVariant.CENTER}>
|
||||
{#each attachments as attachment, i (attachment.id)}
|
||||
<ChatAttachmentsListItemMcpResource
|
||||
{attachment}
|
||||
class={i === 0 ? 'ml-3' : ''}
|
||||
onRemove={handleRemove}
|
||||
onclick={() => handleResourceClick(attachment.resource.uri)}
|
||||
/>
|
||||
{/each}
|
||||
</ScrollCarousel>
|
||||
</div>
|
||||
{/if}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { mcpStore } from '$lib/stores';
|
||||
import type { MCPServerSettingsEntry } from '$lib/types';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
server: MCPServerSettingsEntry | undefined;
|
||||
serverLabel: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
titleExtra?: Snippet;
|
||||
subtitle?: Snippet;
|
||||
}
|
||||
|
||||
let { description, server, serverLabel, subtitle, title, titleExtra }: 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
|
||||
alt=""
|
||||
class="h-3 w-3 shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
src={faviconUrl}
|
||||
/>
|
||||
{/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>
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
<script generics="T" lang="ts">
|
||||
import { SearchInput } from '$lib/components/app';
|
||||
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
|
||||
import { CHAT_FORM_POPOVER_MAX_HEIGHT, UI_DATA_ATTRS } from '$lib/constants';
|
||||
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
items: T[];
|
||||
isLoading: boolean;
|
||||
selectedIndex: number;
|
||||
searchQuery: string;
|
||||
showSearchInput: boolean;
|
||||
searchPlaceholder?: string;
|
||||
// Omit to distinguish "haven't searched yet" from "search returned nothing".
|
||||
emptyMessage?: string;
|
||||
autofocus?: boolean;
|
||||
inputRef?: HTMLInputElement | null;
|
||||
onSearchClose?: () => void;
|
||||
itemKey: (item: T, index: number) => string;
|
||||
item: Snippet<[T, number, boolean]>;
|
||||
skeleton?: Snippet;
|
||||
skeletonCount?: number;
|
||||
footer?: Snippet;
|
||||
// Counter bumped by the picker on keyboard nav; scrolls the selected
|
||||
// row into view without scrolling on hover or result replacement.
|
||||
scrollTrigger?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
autofocus = false,
|
||||
emptyMessage,
|
||||
footer,
|
||||
inputRef = $bindable(null),
|
||||
isLoading,
|
||||
item,
|
||||
itemKey,
|
||||
items,
|
||||
onSearchClose,
|
||||
scrollTrigger,
|
||||
searchPlaceholder = 'Search...',
|
||||
searchQuery = $bindable(),
|
||||
selectedIndex,
|
||||
showSearchInput,
|
||||
skeleton,
|
||||
skeletonCount = 6
|
||||
}: Props = $props();
|
||||
|
||||
let listContainer = $state<HTMLDivElement | null>(null);
|
||||
|
||||
let listPaddingTop = $derived(
|
||||
showSearchInput ? (isLoading || items.length > 0 ? 'pt-13' : 'pt-10') : ''
|
||||
);
|
||||
|
||||
// selectedIndex/items.length are untracked so hover and result replacement
|
||||
// never re-fire the scroll; keyboard nav is the only path that bumps the trigger.
|
||||
useScrollActiveRow({
|
||||
dataAttr: UI_DATA_ATTRS.PICKER_INDEX,
|
||||
getContainer: () => listContainer,
|
||||
getCount: () => items.length,
|
||||
getIndex: () => selectedIndex,
|
||||
getTrigger: () => scrollTrigger
|
||||
});
|
||||
</script>
|
||||
|
||||
<ScrollArea>
|
||||
{#if showSearchInput}
|
||||
<div class="absolute top-0 right-0 left-0 z-10 p-2 pb-0">
|
||||
<SearchInput
|
||||
bind:ref={inputRef}
|
||||
bind:value={searchQuery}
|
||||
{autofocus}
|
||||
onClose={onSearchClose}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div bind:this={listContainer} class={[`${CHAT_FORM_POPOVER_MAX_HEIGHT} p-2`, listPaddingTop]}>
|
||||
{#if isLoading}
|
||||
{#if skeleton}
|
||||
{@render skeleton()}
|
||||
{:else}
|
||||
<div aria-busy="true" aria-live="polite" class="flex flex-col">
|
||||
{#each { length: skeletonCount } as _, rowIndex (rowIndex)}
|
||||
<div class="flex items-start gap-3 rounded-lg px-3 py-2">
|
||||
<div class="mt-0.5 size-4 shrink-0 animate-pulse rounded-md bg-muted/60"></div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<div class="h-5 w-2/5 animate-pulse rounded-sm bg-muted/60"></div>
|
||||
|
||||
<div class="h-4 w-1/3 animate-pulse rounded-sm bg-muted/40"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if items && items.length === 0}
|
||||
{#if emptyMessage}
|
||||
<div class="py-6 text-center text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#each items as itemData, index (itemKey(itemData, index))}
|
||||
{@render item(itemData, index, index === selectedIndex)}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if footer}
|
||||
{@render footer()}
|
||||
{/if}
|
||||
</ScrollArea>
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { UI_DATA_ATTRS } from '$lib/constants';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
isSelected?: boolean;
|
||||
disabled?: boolean;
|
||||
onclick: () => void;
|
||||
onmouseenter?: () => void;
|
||||
dataIndex?: number;
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
children,
|
||||
class: className = '',
|
||||
dataIndex,
|
||||
disabled = false,
|
||||
isSelected = false,
|
||||
onclick,
|
||||
onmouseenter
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
{disabled}
|
||||
{onclick}
|
||||
{onmouseenter}
|
||||
type="button"
|
||||
{...{ [UI_DATA_ATTRS.PICKER_INDEX]: dataIndex }}
|
||||
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'
|
||||
: ''} {disabled ? 'cursor-not-allowed opacity-50' : ''} {className}"
|
||||
>
|
||||
{@render children()}
|
||||
</button>
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
titleWidth?: string;
|
||||
showBadge?: boolean;
|
||||
}
|
||||
|
||||
let { showBadge = false, titleWidth = 'w-48' }: 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>
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
isOpen?: boolean;
|
||||
srLabel?: string;
|
||||
onClose?: () => void;
|
||||
onKeydown?: (event: KeyboardEvent) => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
children,
|
||||
class: className = '',
|
||||
isOpen = $bindable(false),
|
||||
onClose,
|
||||
onKeydown,
|
||||
srLabel = 'Open picker'
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Popover.Root
|
||||
bind:open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 opacity-0"
|
||||
tabindex={-1}
|
||||
>
|
||||
<span class="sr-only">{srLabel}</span>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
align="start"
|
||||
class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl {className}"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onkeydown={onKeydown}
|
||||
preventScroll={false}
|
||||
side="top"
|
||||
sideOffset={12}
|
||||
>
|
||||
{@render children()}
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ChatFormPickerList,
|
||||
ChatFormPickerListItem,
|
||||
ChatFormPickerPopover
|
||||
} from '$lib/components/app/chat';
|
||||
import { CHAT_FORM_COMMAND_ICONS } from '$lib/constants';
|
||||
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
|
||||
import type { ChatFormCommand } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Slash-command picker; `query` (typed after `/`) filters the commands.
|
||||
* The parent owns the "dismissed token, don't act until it changes"
|
||||
* snapshot, so this picker just renders and reports selection.
|
||||
*/
|
||||
interface Props {
|
||||
class?: string;
|
||||
isOpen: boolean;
|
||||
query: string;
|
||||
commands: ChatFormCommand[];
|
||||
onClose: () => void;
|
||||
onSelect: (command: ChatFormCommand) => void;
|
||||
}
|
||||
|
||||
let { class: className = '', commands, isOpen, onClose, onSelect, query }: Props = $props();
|
||||
|
||||
const trimmedQuery = $derived((query ?? '').trim().toLowerCase());
|
||||
|
||||
const filteredCommands = $derived(
|
||||
trimmedQuery
|
||||
? commands.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(trimmedQuery) ||
|
||||
c.description.toLowerCase().includes(trimmedQuery) ||
|
||||
(c.keywords ?? []).some((k) => k.toLowerCase().includes(trimmedQuery))
|
||||
)
|
||||
: commands
|
||||
);
|
||||
|
||||
function firstEnabledIndex(): number {
|
||||
return filteredCommands.findIndex((c) => !c.disabled);
|
||||
}
|
||||
|
||||
function stepEnabled(from: number, dir: number): number {
|
||||
const n = filteredCommands.length;
|
||||
|
||||
if (n === 0) return -1;
|
||||
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const idx = (from + dir * i + n) % n;
|
||||
|
||||
if (!filteredCommands[idx].disabled) return idx;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
const nav = usePickerNavigation({
|
||||
count: () => filteredCommands.length,
|
||||
isOpen: () => isOpen,
|
||||
onClose: () => onClose(),
|
||||
onSelect: (index) => handleSelect(filteredCommands[index]),
|
||||
step: (from, dir) => (from < 0 ? firstEnabledIndex() : stepEnabled(from, dir))
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
nav.reset(firstEnabledIndex());
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (nav.hoveredIndex < 0 || nav.hoveredIndex >= filteredCommands.length) {
|
||||
nav.reset(firstEnabledIndex());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (filteredCommands[nav.hoveredIndex].disabled) {
|
||||
nav.reset(firstEnabledIndex());
|
||||
}
|
||||
});
|
||||
|
||||
function handleSelect(command: ChatFormCommand) {
|
||||
if (command.disabled) return;
|
||||
|
||||
onSelect(command);
|
||||
onClose();
|
||||
}
|
||||
|
||||
export function handleKeydown(event: KeyboardEvent): boolean {
|
||||
return nav.handleKeydown(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ChatFormPickerPopover
|
||||
bind:isOpen
|
||||
class={className}
|
||||
{onClose}
|
||||
onKeydown={handleKeydown}
|
||||
srLabel="Open command picker"
|
||||
>
|
||||
<ChatFormPickerList
|
||||
emptyMessage="No matching command"
|
||||
isLoading={false}
|
||||
itemKey={(command) => command.name}
|
||||
items={filteredCommands}
|
||||
scrollTrigger={nav.scrollTrigger}
|
||||
searchQuery={query ?? ''}
|
||||
selectedIndex={nav.hoveredIndex}
|
||||
showSearchInput={false}
|
||||
>
|
||||
{#snippet item(command, index, isSelected)}
|
||||
{@const Icon = CHAT_FORM_COMMAND_ICONS[command.action]}
|
||||
<ChatFormPickerListItem
|
||||
dataIndex={index}
|
||||
disabled={command.disabled}
|
||||
{isSelected}
|
||||
onclick={() => handleSelect(command)}
|
||||
onmouseenter={() => {
|
||||
if (!command.disabled) nav.setHover(index);
|
||||
}}
|
||||
>
|
||||
<Icon class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="font-mono text-sm font-medium">/{command.name}</span>
|
||||
|
||||
<span class="min-w-0 flex-1 truncate text-left text-xs text-muted-foreground">
|
||||
{command.description}
|
||||
</span>
|
||||
</div>
|
||||
</ChatFormPickerListItem>
|
||||
{/snippet}
|
||||
</ChatFormPickerList>
|
||||
</ChatFormPickerPopover>
|
||||
+446
@@ -0,0 +1,446 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ChatFormPickerItemHeader,
|
||||
ChatFormPickerList,
|
||||
ChatFormPickerListItem,
|
||||
ChatFormPickerListItemSkeleton,
|
||||
ChatFormPickerPopover,
|
||||
ChatFormPromptPickerArgumentForm
|
||||
} from '$lib/components/app/chat';
|
||||
import Badge from '$lib/components/ui/badge/badge.svelte';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
import { mcpStore } from '$lib/stores';
|
||||
import type { GetPromptResult, MCPPromptInfo, MCPServerSettingsEntry } from '$lib/types';
|
||||
import { debounce, uuid } from '$lib/utils';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
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,
|
||||
onClose,
|
||||
onPromptLoadComplete,
|
||||
onPromptLoadError,
|
||||
onPromptLoadStart,
|
||||
searchQuery = ''
|
||||
}: 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('');
|
||||
// Bumped on ArrowUp/ArrowDown only, so the list scrolls on keyboard
|
||||
// nav but not on hover or result changes.
|
||||
let scrollTrigger = $state(0);
|
||||
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 initialized = await mcpStore.ensureInitialized();
|
||||
|
||||
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:', {
|
||||
argName,
|
||||
promptName: selectedPrompt.name,
|
||||
serverName: selectedPrompt.serverName,
|
||||
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,
|
||||
result,
|
||||
suggestionsCount: result?.values.length ?? 0,
|
||||
value
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
scrollTrigger++;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_UP) {
|
||||
event.preventDefault();
|
||||
|
||||
if (filteredPrompts.length > 0) {
|
||||
selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1;
|
||||
scrollTrigger++;
|
||||
}
|
||||
|
||||
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.getServers();
|
||||
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}
|
||||
{onClose}
|
||||
onKeydown={handleKeydown}
|
||||
srLabel="Open prompt picker"
|
||||
>
|
||||
{#if selectedPrompt}
|
||||
{@const prompt = selectedPrompt}
|
||||
{@const server = serverSettingsMap.get(prompt.serverName)}
|
||||
{@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName}
|
||||
|
||||
<div class="p-4">
|
||||
<ChatFormPickerItemHeader
|
||||
description={prompt.description}
|
||||
{server}
|
||||
{serverLabel}
|
||||
title={prompt.title || prompt.name}
|
||||
>
|
||||
{#snippet titleExtra()}
|
||||
{#if prompt.arguments?.length}
|
||||
<Badge variant="secondary">
|
||||
{prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ChatFormPickerItemHeader>
|
||||
|
||||
<ChatFormPromptPickerArgumentForm
|
||||
{activeAutocomplete}
|
||||
{autocompleteIndex}
|
||||
{loadingSuggestions}
|
||||
onArgBlur={handleArgBlur}
|
||||
onArgFocus={handleArgFocus}
|
||||
onArgInput={handleArgInput}
|
||||
onArgKeydown={handleArgKeydown}
|
||||
onCancel={handleCancelArgumentForm}
|
||||
onSelectSuggestion={selectSuggestion}
|
||||
onSubmit={handleArgumentSubmit}
|
||||
prompt={selectedPrompt}
|
||||
{promptArgs}
|
||||
{promptError}
|
||||
{suggestions}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<ChatFormPickerList
|
||||
bind:searchQuery={internalSearchQuery}
|
||||
emptyMessage="No MCP prompts available"
|
||||
{isLoading}
|
||||
itemKey={(prompt) => prompt.serverName + ':' + prompt.name}
|
||||
items={filteredPrompts}
|
||||
{scrollTrigger}
|
||||
searchPlaceholder="Search prompts..."
|
||||
{selectedIndex}
|
||||
{showSearchInput}
|
||||
>
|
||||
{#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
|
||||
description={prompt.description}
|
||||
{server}
|
||||
{serverLabel}
|
||||
title={prompt.title || prompt.name}
|
||||
>
|
||||
{#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 showBadge titleWidth="w-32" />
|
||||
{/snippet}
|
||||
</ChatFormPickerList>
|
||||
{/if}
|
||||
</ChatFormPickerPopover>
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import ChatFormPromptPickerArgumentInput from './ChatFormPromptPickerArgumentInput.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import type { MCPPromptInfo } from '$lib/types';
|
||||
|
||||
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 {
|
||||
activeAutocomplete,
|
||||
autocompleteIndex,
|
||||
loadingSuggestions,
|
||||
onArgBlur,
|
||||
onArgFocus,
|
||||
onArgInput,
|
||||
onArgKeydown,
|
||||
onCancel,
|
||||
onSelectSuggestion,
|
||||
onSubmit,
|
||||
prompt,
|
||||
promptArgs,
|
||||
promptError,
|
||||
suggestions
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<form class="space-y-3 pt-4" onsubmit={onSubmit}>
|
||||
{#each prompt.arguments ?? [] as arg (arg.name)}
|
||||
<ChatFormPromptPickerArgumentInput
|
||||
argument={arg}
|
||||
autocompleteIndex={activeAutocomplete === arg.name ? autocompleteIndex : 0}
|
||||
isAutocompleteActive={activeAutocomplete === arg.name}
|
||||
isLoadingSuggestions={loadingSuggestions[arg.name] ?? false}
|
||||
onBlur={() => onArgBlur(arg.name)}
|
||||
onFocus={() => onArgFocus(arg.name)}
|
||||
onInput={(value) => onArgInput(arg.name, value)}
|
||||
onKeydown={(e) => onArgKeydown(e, arg.name)}
|
||||
onSelectSuggestion={(value) => onSelectSuggestion(arg.name, value)}
|
||||
suggestions={suggestions[arg.name] ?? []}
|
||||
value={promptArgs[arg.name] ?? ''}
|
||||
/>
|
||||
{/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 onclick={onCancel} size="sm" type="button" variant="secondary">Cancel</Button>
|
||||
|
||||
<Button size="sm" type="submit">Use Prompt</Button>
|
||||
</div>
|
||||
</form>
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import type { MCPPromptInfo } from '$lib/types';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
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,
|
||||
autocompleteIndex = 0,
|
||||
isAutocompleteActive = false,
|
||||
isLoadingSuggestions = false,
|
||||
onBlur,
|
||||
onFocus,
|
||||
onInput,
|
||||
onKeydown,
|
||||
onSelectSuggestion,
|
||||
suggestions = [],
|
||||
value = ''
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative grid gap-1">
|
||||
<Label class="mb-1 text-muted-foreground" for="arg-{argument.name}">
|
||||
<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
|
||||
autocomplete="off"
|
||||
id="arg-{argument.name}"
|
||||
onblur={onBlur}
|
||||
onfocus={onFocus}
|
||||
oninput={(e) => onInput(e.currentTarget.value)}
|
||||
onkeydown={onKeydown}
|
||||
placeholder={argument.description || argument.name}
|
||||
required={argument.required}
|
||||
type="text"
|
||||
{value}
|
||||
/>
|
||||
|
||||
{#if isAutocompleteActive && suggestions.length > 0}
|
||||
<div
|
||||
transition:fly={{ duration: 100, y: -5 }}
|
||||
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"
|
||||
>
|
||||
{#each suggestions as suggestion, i (suggestion)}
|
||||
<button
|
||||
class="w-full px-3 py-1.5 text-left text-sm hover:bg-accent {i === autocompleteIndex
|
||||
? 'bg-accent'
|
||||
: ''}"
|
||||
onmousedown={() => onSelectSuggestion(suggestion)}
|
||||
type="button"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
<script lang="ts">
|
||||
import { File, Folder } from '@lucide/svelte';
|
||||
import { ChatFormPickerList, ChatFormPickerListItem } from '$lib/components/app/chat';
|
||||
import HighlightedMatch from '$lib/components/app/forms/HighlightedMatch.svelte';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { FILE_GLOB_SEARCH_PICKERS, HOME_TILDE, SEARCH } from '$lib/constants';
|
||||
import {
|
||||
BuiltInTool,
|
||||
FileMentionEntryType,
|
||||
GlobSearchType,
|
||||
KeyboardKey,
|
||||
ToolSource
|
||||
} from '$lib/enums';
|
||||
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
|
||||
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
|
||||
import { conversationsStore, deviceStore, settingsStore, toolsStore } from '$lib/stores';
|
||||
import type { FileMentionEntry, GlobEntryResult } from '$lib/types';
|
||||
import { abbreviateHome, runGlobSearchWithChildren } from '$lib/utils';
|
||||
|
||||
/**
|
||||
* Floating file/folder mention picker. The chat input is the search
|
||||
* surface: `query` (typed after `@`) drives a `file_glob_search` tool
|
||||
* call scoped to `scopePath`. The parent owns the "dismissed token,
|
||||
* don't re-open until it changes" snapshot.
|
||||
*/
|
||||
interface Props {
|
||||
class?: string;
|
||||
isOpen: boolean;
|
||||
query: string;
|
||||
customAnchor?: HTMLElement | null;
|
||||
scopePath?: string | null;
|
||||
onClose: () => void;
|
||||
onSelect: (entry: FileMentionEntry) => void;
|
||||
/** Fired when `isOpen` becomes true, so the host can keep focus on the chat input. */
|
||||
onOpened?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
customAnchor = null,
|
||||
isOpen,
|
||||
onClose,
|
||||
onOpened,
|
||||
onSelect,
|
||||
query,
|
||||
scopePath = null
|
||||
}: Props = $props();
|
||||
|
||||
const nav = usePickerNavigation({
|
||||
count: () => displayedItems.length,
|
||||
isOpen: () => isOpen,
|
||||
onClose: () => onClose(),
|
||||
onSelect: (index) => handleSelect(displayedItems[index])
|
||||
});
|
||||
|
||||
// When the server does not expose file_glob_search (started without
|
||||
// --tools) or the user disabled it, the picker still opens but explains
|
||||
// why instead of firing searches that would only fail.
|
||||
const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.SERVER_FILE_GLOB_SEARCH));
|
||||
// effective policy: the active conversation's tool policy, or global defaults
|
||||
const fileSearchEnabled = $derived(
|
||||
fileSearchKey !== null &&
|
||||
conversationsStore.preferences.isToolEnabled(fileSearchKey) &&
|
||||
conversationsStore.preferences.isCategoryEnabled(ToolSource.SERVER)
|
||||
);
|
||||
|
||||
let searchResults = $state<FileMentionEntry[]>([]);
|
||||
let searchError = $state<string | null>(null);
|
||||
|
||||
// Coerce the depth setting to a positive integer; an invalid value
|
||||
// would otherwise reach the server as max_depth 0 = unlimited.
|
||||
const searchDepth = $derived.by(() => {
|
||||
const n = Number(settingsStore.config.mentionSearchMaxDepth);
|
||||
|
||||
return Number.isInteger(n) && n > 0 ? n : FILE_GLOB_SEARCH_PICKERS.DEFAULT_SEARCH_DEPTH;
|
||||
});
|
||||
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
|
||||
// A smaller window than the WD picker suffices: entries are ranked client-side.
|
||||
const MENTION_SEARCH_LIMIT = 50;
|
||||
|
||||
const search = useDebouncedSearch({
|
||||
canRun: () => isOpen && fileSearchEnabled,
|
||||
debounceMs: SEARCH.DEBOUNCE_MS,
|
||||
getQuery: () => trimmedQuery,
|
||||
run: async (query, signal, isCurrent) => {
|
||||
try {
|
||||
// A trailing path separator targets a directory, so also list its
|
||||
// children. Accept both `/` and `\`.
|
||||
const res = await runGlobSearchWithChildren(
|
||||
query,
|
||||
scopePath ?? home ?? HOME_TILDE,
|
||||
searchDepth,
|
||||
MENTION_SEARCH_LIMIT,
|
||||
signal,
|
||||
{ descendOnTrailingSeparator: true, type: GlobSearchType.ALL }
|
||||
);
|
||||
|
||||
if (!isCurrent()) return;
|
||||
|
||||
if (res.error) {
|
||||
searchResults = [];
|
||||
searchError = res.error;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const toEntry = (e: GlobEntryResult): FileMentionEntry => ({
|
||||
name: e.name,
|
||||
path: e.path,
|
||||
type: e.type === 'dir' ? FileMentionEntryType.DIRECTORY : FileMentionEntryType.FILE
|
||||
});
|
||||
|
||||
searchResults = res.entries.map(toEntry);
|
||||
searchError = null;
|
||||
} catch (err) {
|
||||
if (!isCurrent() || signal.aborted) return;
|
||||
|
||||
searchResults = [];
|
||||
searchError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const trimmedQuery = $derived((query ?? '').trim());
|
||||
const displayedItems = $derived(searchResults);
|
||||
|
||||
const emptyMessage = $derived.by(() => {
|
||||
if (fileSearchKey === null) {
|
||||
return 'File search is unavailable on this server (started without --tools)';
|
||||
}
|
||||
|
||||
if (!fileSearchEnabled) {
|
||||
return 'File search is disabled - enable "Search files" in Settings > Tools to use @-mentions';
|
||||
}
|
||||
|
||||
return searchError ? `Search failed - ${searchError}` : 'No matching files or folders';
|
||||
});
|
||||
|
||||
const showTooltip = $derived(!deviceStore.isMobile);
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
void toolsStore.resolveServerHome();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
nav.reset(0);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) onOpened?.();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const q = (query ?? '').trim();
|
||||
|
||||
if (!isOpen || !q || !fileSearchEnabled) {
|
||||
search.cancel();
|
||||
searchResults = [];
|
||||
searchError = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
search.setLoading(true);
|
||||
search.run(q);
|
||||
});
|
||||
|
||||
function handleSelect(entry: FileMentionEntry) {
|
||||
onSelect(entry);
|
||||
onClose();
|
||||
}
|
||||
|
||||
export function handleKeydown(event: KeyboardEvent): boolean {
|
||||
// Always consume Enter while the picker is open - even with no
|
||||
// result yet (skeletons) or no matches - so the chat form's
|
||||
// Enter-to-submit never fires mid-search.
|
||||
if (isOpen && event.key === KeyboardKey.ENTER) {
|
||||
event.preventDefault();
|
||||
|
||||
if (nav.hoveredIndex >= 0 && displayedItems[nav.hoveredIndex]) {
|
||||
handleSelect(displayedItems[nav.hoveredIndex]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return nav.handleKeydown(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
open={isOpen}
|
||||
>
|
||||
<!-- Invisible form-wide trigger: stops bits-ui's outside-click detector
|
||||
from closing the picker when the user clicks inside the textarea.
|
||||
We open programmatically via `open={isOpen}`, so it is inert
|
||||
(tabindex=-1 + pointer-events-none + opacity-0 + aria-hidden).
|
||||
Positioning comes from `customAnchor` at the form's top edge. -->
|
||||
<Popover.Trigger
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 opacity-0"
|
||||
tabindex={-1}
|
||||
>
|
||||
<span class="sr-only">Open file mention picker</span>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
align="start"
|
||||
class={[
|
||||
'w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl',
|
||||
className
|
||||
]}
|
||||
{customAnchor}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onkeydown={handleKeydown}
|
||||
preventScroll={false}
|
||||
side="top"
|
||||
sideOffset={12}
|
||||
>
|
||||
<ChatFormPickerList
|
||||
{emptyMessage}
|
||||
isLoading={search.isSearching}
|
||||
itemKey={(entry) => entry.type + ':' + entry.path}
|
||||
items={displayedItems}
|
||||
scrollTrigger={nav.scrollTrigger}
|
||||
searchQuery={query ?? ''}
|
||||
selectedIndex={nav.hoveredIndex}
|
||||
showSearchInput={false}
|
||||
>
|
||||
{#snippet item(entry, index, isSelected)}
|
||||
<ChatFormPickerListItem
|
||||
dataIndex={index}
|
||||
{isSelected}
|
||||
onclick={() => handleSelect(entry)}
|
||||
onmouseenter={() => nav.setHover(index)}
|
||||
>
|
||||
{@const Icon = entry.type === FileMentionEntryType.DIRECTORY ? Folder : File}
|
||||
<Icon
|
||||
class={[
|
||||
'mt-0.5 h-4 w-4 shrink-0',
|
||||
entry.type === FileMentionEntryType.DIRECTORY
|
||||
? 'text-amber-500'
|
||||
: 'text-muted-foreground'
|
||||
]}
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
{#if showTooltip}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<span {...props} class="truncate text-sm font-medium">{entry.name}</span>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>{entry.path}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else}
|
||||
<span class="truncate text-sm font-medium">{entry.name}</span>
|
||||
{/if}
|
||||
|
||||
<span
|
||||
class="shrink-0 rounded-full bg-muted px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{entry.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-left text-xs">
|
||||
<HighlightedMatch query={trimmedQuery} text={abbreviateHome(entry.path, home)} />
|
||||
</span>
|
||||
</div>
|
||||
</ChatFormPickerListItem>
|
||||
{/snippet}
|
||||
</ChatFormPickerList>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import ChatFormPickerCommand from './ChatFormPickerCommand.svelte';
|
||||
import ChatFormPickerMcpPrompts from './ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte';
|
||||
import ChatFormPickerMention from './ChatFormPickerMention.svelte';
|
||||
import type {
|
||||
ChatFormCommand,
|
||||
FileMentionEntry,
|
||||
GetPromptResult,
|
||||
MCPPromptInfo
|
||||
} from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
isCommandPickerOpen?: boolean;
|
||||
commandQuery?: string;
|
||||
commands?: ChatFormCommand[];
|
||||
isPromptPickerOpen?: boolean;
|
||||
promptSearchQuery?: string;
|
||||
isMentionPickerOpen?: boolean;
|
||||
mentionQuery?: string;
|
||||
mentionAnchor?: HTMLElement | null;
|
||||
scopePath?: string | null;
|
||||
onCommandPickerClose?: () => void;
|
||||
onCommandSelect?: (command: ChatFormCommand) => void;
|
||||
onPromptPickerClose?: () => void;
|
||||
onMentionPickerClose?: () => void;
|
||||
onMentionOpened?: () => void;
|
||||
onMentionSelect?: (entry: FileMentionEntry) => void;
|
||||
onPromptLoadStart?: (
|
||||
placeholderId: string,
|
||||
promptInfo: MCPPromptInfo,
|
||||
args?: Record<string, string>
|
||||
) => void;
|
||||
onPromptLoadComplete?: (placeholderId: string, result: GetPromptResult) => void;
|
||||
onPromptLoadError?: (placeholderId: string, error: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
commandQuery,
|
||||
commands = [],
|
||||
isCommandPickerOpen,
|
||||
isMentionPickerOpen,
|
||||
isPromptPickerOpen,
|
||||
mentionAnchor,
|
||||
mentionQuery,
|
||||
onCommandPickerClose,
|
||||
onCommandSelect,
|
||||
onMentionOpened,
|
||||
onMentionPickerClose,
|
||||
onMentionSelect,
|
||||
onPromptLoadComplete,
|
||||
onPromptLoadError,
|
||||
onPromptLoadStart,
|
||||
onPromptPickerClose,
|
||||
promptSearchQuery,
|
||||
scopePath
|
||||
}: Props = $props();
|
||||
|
||||
let commandPickerRef: ChatFormPickerCommand | undefined = $state(undefined);
|
||||
let promptPickerRef: ChatFormPickerMcpPrompts | undefined = $state(undefined);
|
||||
let mentionPickerRef: ChatFormPickerMention | undefined = $state(undefined);
|
||||
|
||||
/** Delegate keyboard events to the active picker child; true if handled. */
|
||||
export function handleKeydown(event: KeyboardEvent): boolean {
|
||||
if (isCommandPickerOpen && commandPickerRef?.handleKeydown(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isPromptPickerOpen && promptPickerRef?.handleKeydown(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isMentionPickerOpen && mentionPickerRef?.handleKeydown(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ChatFormPickerCommand
|
||||
bind:this={commandPickerRef}
|
||||
{commands}
|
||||
isOpen={isCommandPickerOpen ?? false}
|
||||
onClose={onCommandPickerClose ?? (() => {})}
|
||||
onSelect={onCommandSelect ?? (() => {})}
|
||||
query={commandQuery ?? ''}
|
||||
/>
|
||||
|
||||
<ChatFormPickerMcpPrompts
|
||||
bind:this={promptPickerRef}
|
||||
isOpen={isPromptPickerOpen}
|
||||
onClose={onPromptPickerClose}
|
||||
{onPromptLoadComplete}
|
||||
{onPromptLoadError}
|
||||
{onPromptLoadStart}
|
||||
searchQuery={promptSearchQuery}
|
||||
/>
|
||||
|
||||
<ChatFormPickerMention
|
||||
bind:this={mentionPickerRef}
|
||||
customAnchor={mentionAnchor}
|
||||
isOpen={isMentionPickerOpen ?? false}
|
||||
onClose={onMentionPickerClose ?? (() => {})}
|
||||
onOpened={onMentionOpened}
|
||||
onSelect={onMentionSelect ?? (() => {})}
|
||||
query={mentionQuery ?? ''}
|
||||
scopePath={scopePath ?? null}
|
||||
/>
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
ChatMessageAssistant,
|
||||
ChatMessageMcpPrompt,
|
||||
ChatMessageSynthetic,
|
||||
ChatMessageSystem,
|
||||
ChatMessageUser
|
||||
} from '$lib/components/app/chat';
|
||||
import {
|
||||
AGENTIC_TEXT_COPY_SEPARATOR,
|
||||
REASONING_TAGS,
|
||||
ROUTES,
|
||||
SYSTEM_MESSAGE_PLACEHOLDER
|
||||
} from '$lib/constants';
|
||||
import { setChatMessageActionsContext, setChatMessageEditContext } from '$lib/contexts';
|
||||
import { AgenticSectionType, AttachmentType, MessageRole } from '$lib/enums';
|
||||
import { DatabaseService } from '$lib/services/database.service';
|
||||
import { chatStore, conversationsStore, deviceStore } from '$lib/stores';
|
||||
import type {
|
||||
ChatMessageActions,
|
||||
ChatMessageDeletionInfo,
|
||||
DatabaseMessageExtraMcpPrompt
|
||||
} from '$lib/types';
|
||||
import { deriveAgenticSections } from '$lib/utils';
|
||||
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
chatActions: ChatMessageActions;
|
||||
message: DatabaseMessage;
|
||||
toolMessages?: DatabaseMessage[];
|
||||
isLastAssistantMessage?: boolean;
|
||||
isLastUserMessage?: boolean;
|
||||
nextAssistantMessage?: DatabaseMessage | null;
|
||||
siblingInfo?: ChatMessageSiblingInfo | null;
|
||||
}
|
||||
|
||||
let {
|
||||
chatActions,
|
||||
class: className = '',
|
||||
isLastAssistantMessage = false,
|
||||
isLastUserMessage = false,
|
||||
message,
|
||||
nextAssistantMessage = null,
|
||||
siblingInfo = null,
|
||||
toolMessages = []
|
||||
}: Props = $props();
|
||||
|
||||
let deletionInfo = $state<ChatMessageDeletionInfo | null>(null);
|
||||
// The system message placeholder must never surface as editable content; keeping
|
||||
// it in the derived (not just in handleEdit) guards against prop invalidation
|
||||
// reverting the override while editing
|
||||
let editedContent = $derived(
|
||||
message.role === MessageRole.SYSTEM && message.content === SYSTEM_MESSAGE_PLACEHOLDER
|
||||
? ''
|
||||
: message.content
|
||||
);
|
||||
|
||||
// Synthetic cwd-change messages render with the folder-row UI instead
|
||||
// of a user bubble. The persisted flag is the single source of truth.
|
||||
let isSynthetic = $derived(Boolean(message.isSynthetic));
|
||||
|
||||
let rawEditContent = $derived.by(() => {
|
||||
if (message.role !== MessageRole.ASSISTANT) return undefined;
|
||||
|
||||
const sections = deriveAgenticSections(message, toolMessages, [], false);
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const section of sections) {
|
||||
switch (section.type) {
|
||||
case AgenticSectionType.REASONING:
|
||||
case AgenticSectionType.REASONING_PENDING:
|
||||
parts.push(`${REASONING_TAGS.START}\n${section.content}\n${REASONING_TAGS.END}`);
|
||||
|
||||
break;
|
||||
|
||||
case AgenticSectionType.TEXT:
|
||||
parts.push(section.content);
|
||||
|
||||
break;
|
||||
|
||||
case AgenticSectionType.TOOL_CALL:
|
||||
case AgenticSectionType.TOOL_CALL_PENDING:
|
||||
case AgenticSectionType.TOOL_CALL_STREAMING: {
|
||||
const callObj: Record<string, unknown> = { name: section.toolName };
|
||||
|
||||
if (section.toolArgs) {
|
||||
try {
|
||||
callObj.arguments = JSON.parse(section.toolArgs);
|
||||
} catch {
|
||||
callObj.arguments = section.toolArgs;
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(JSON.stringify(callObj, null, 2));
|
||||
|
||||
if (section.toolResult) {
|
||||
parts.push(`[Tool Result]\n${section.toolResult}`);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n\n\n');
|
||||
});
|
||||
let editedExtras = $derived<DatabaseMessageExtra[]>(message.extra ? [...message.extra] : []);
|
||||
let editedUploadedFiles = $state<ChatUploadedFile[]>([]);
|
||||
let isEditing = $state(false);
|
||||
let showDeleteDialog = $state(false);
|
||||
let shouldBranchAfterEdit = $state(false);
|
||||
let textareaElement: HTMLTextAreaElement | undefined = $state();
|
||||
|
||||
let showSaveOnlyOption = $derived(message.role === MessageRole.USER);
|
||||
let showBranchAfterEditOption = $derived(message.role === MessageRole.ASSISTANT);
|
||||
|
||||
setChatMessageEditContext({
|
||||
cancel: handleCancelEdit,
|
||||
get editedContent() {
|
||||
return editedContent;
|
||||
},
|
||||
get editedExtras() {
|
||||
return editedExtras;
|
||||
},
|
||||
get editedUploadedFiles() {
|
||||
return editedUploadedFiles;
|
||||
},
|
||||
get isEditing() {
|
||||
return isEditing;
|
||||
},
|
||||
get messageRole() {
|
||||
return message.role;
|
||||
},
|
||||
get originalContent() {
|
||||
return message.role === MessageRole.ASSISTANT
|
||||
? (rawEditContent ?? message.content)
|
||||
: message.content;
|
||||
},
|
||||
get originalExtras() {
|
||||
return message.extra || [];
|
||||
},
|
||||
get rawEditContent() {
|
||||
return rawEditContent;
|
||||
},
|
||||
save: handleSaveEdit,
|
||||
saveOnly: handleSaveEditOnly,
|
||||
setContent: (content: string) => {
|
||||
editedContent = content;
|
||||
},
|
||||
setExtras: (extras: DatabaseMessageExtra[]) => {
|
||||
editedExtras = extras;
|
||||
},
|
||||
setShouldBranchAfterEdit: (value: boolean) => {
|
||||
shouldBranchAfterEdit = value;
|
||||
},
|
||||
setUploadedFiles: (files: ChatUploadedFile[]) => {
|
||||
editedUploadedFiles = files;
|
||||
},
|
||||
get shouldBranchAfterEdit() {
|
||||
return shouldBranchAfterEdit;
|
||||
},
|
||||
get showBranchAfterEditOption() {
|
||||
return showBranchAfterEditOption;
|
||||
},
|
||||
get showSaveOnlyOption() {
|
||||
return showSaveOnlyOption;
|
||||
},
|
||||
startEdit: handleEdit
|
||||
});
|
||||
|
||||
setChatMessageActionsContext({
|
||||
confirmDelete: handleConfirmDelete,
|
||||
copy: handleCopy,
|
||||
get deletionInfo() {
|
||||
return deletionInfo;
|
||||
},
|
||||
get forkConversation() {
|
||||
const isForkableUser = message.role === MessageRole.USER && !mcpPromptExtra;
|
||||
|
||||
return isForkableUser || message.role === MessageRole.ASSISTANT
|
||||
? handleForkConversation
|
||||
: undefined;
|
||||
},
|
||||
navigateToSibling: handleNavigateToSibling,
|
||||
requestDelete: handleDelete,
|
||||
setShowDeleteDialog: handleShowDeleteDialogChange,
|
||||
get showDeleteDialog() {
|
||||
return showDeleteDialog;
|
||||
},
|
||||
get siblingInfo() {
|
||||
return siblingInfo;
|
||||
}
|
||||
});
|
||||
|
||||
let mcpPromptExtra = $derived.by(() => {
|
||||
if (message.role !== MessageRole.USER) return null;
|
||||
|
||||
if (message.content.trim()) return null;
|
||||
|
||||
if (!message.extra || message.extra.length !== 1) return null;
|
||||
|
||||
const extra = message.extra[0];
|
||||
|
||||
if (extra.type === AttachmentType.MCP_PROMPT) {
|
||||
return extra as DatabaseMessageExtraMcpPrompt;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const pendingId = chatStore.pendingEditMessageId;
|
||||
|
||||
if (pendingId && pendingId === message.id && !isEditing) {
|
||||
handleEdit();
|
||||
chatStore.clearPendingEditMessageId();
|
||||
}
|
||||
});
|
||||
|
||||
async function handleCancelEdit() {
|
||||
isEditing = false;
|
||||
|
||||
// If canceling a new system message with placeholder content, remove it without deleting children
|
||||
if (message.role === MessageRole.SYSTEM && message.content === SYSTEM_MESSAGE_PLACEHOLDER) {
|
||||
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
|
||||
|
||||
if (conversationDeleted) {
|
||||
goto(ROUTES.START);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
editedContent =
|
||||
message.role === MessageRole.ASSISTANT
|
||||
? rawEditContent || message.content || ''
|
||||
: message.content;
|
||||
editedExtras = message.extra ? [...message.extra] : [];
|
||||
editedUploadedFiles = [];
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
// Agentic sessions render as a single entry anchored on the first assistant
|
||||
// turn, whose own content is typically just the first tool call. Copy the
|
||||
// text sections of the whole session so the clipboard matches the visible
|
||||
// response instead of the anchor turn.
|
||||
if (message.role === MessageRole.ASSISTANT) {
|
||||
const sections = deriveAgenticSections(message, toolMessages, [], false);
|
||||
const text = sections
|
||||
.filter((section) => section.type === AgenticSectionType.TEXT)
|
||||
.map((section) => section.content)
|
||||
.join(AGENTIC_TEXT_COPY_SEPARATOR);
|
||||
|
||||
if (text) {
|
||||
chatActions.copy(message, text);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
chatActions.copy(message);
|
||||
}
|
||||
|
||||
async function handleConfirmDelete() {
|
||||
if (message.role === MessageRole.SYSTEM) {
|
||||
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
|
||||
|
||||
if (conversationDeleted) {
|
||||
goto(ROUTES.START);
|
||||
}
|
||||
} else {
|
||||
chatActions.delete(message);
|
||||
}
|
||||
|
||||
showDeleteDialog = false;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
deletionInfo = await chatStore.getDeletionInfo(message.id);
|
||||
showDeleteDialog = true;
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
isEditing = true;
|
||||
|
||||
// Clear temporary placeholder content for system messages
|
||||
if (message.role === MessageRole.SYSTEM && message.content === SYSTEM_MESSAGE_PLACEHOLDER) {
|
||||
editedContent = '';
|
||||
} else if (message.role === MessageRole.ASSISTANT) {
|
||||
editedContent = rawEditContent || message.content || '';
|
||||
} else {
|
||||
editedContent = message.content;
|
||||
}
|
||||
|
||||
textareaElement?.focus({ preventScroll: true });
|
||||
editedExtras = message.extra ? [...message.extra] : [];
|
||||
editedUploadedFiles = [];
|
||||
|
||||
setTimeout(() => {
|
||||
if (textareaElement) {
|
||||
textareaElement.focus();
|
||||
textareaElement.setSelectionRange(
|
||||
textareaElement.value.length,
|
||||
textareaElement.value.length
|
||||
);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function handleRegenerate(modelOverride?: string) {
|
||||
chatActions.regenerateWithBranching(message, modelOverride);
|
||||
}
|
||||
|
||||
function handleContinue() {
|
||||
chatActions.continueAssistantMessage(message);
|
||||
}
|
||||
|
||||
function handleForkConversation(options: { name: string; includeAttachments: boolean }) {
|
||||
chatActions.forkConversation(message, options);
|
||||
}
|
||||
|
||||
function handleNavigateToSibling(siblingId: string) {
|
||||
chatActions.navigateToSibling(siblingId);
|
||||
}
|
||||
|
||||
// After the system message flow ends, hand focus to the main chat form
|
||||
function focusMainChatForm() {
|
||||
if (deviceStore.isMobile) return;
|
||||
|
||||
document.querySelector<HTMLTextAreaElement>('.chat-screen-form-wrapper textarea')?.focus();
|
||||
}
|
||||
|
||||
async function handleSaveEdit() {
|
||||
if (message.role === MessageRole.SYSTEM) {
|
||||
// System messages: update in place without branching
|
||||
const newContent = editedContent.trim();
|
||||
|
||||
// If content is empty, remove without deleting children
|
||||
if (!newContent) {
|
||||
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
|
||||
|
||||
isEditing = false;
|
||||
|
||||
if (conversationDeleted) {
|
||||
goto(ROUTES.START);
|
||||
} else {
|
||||
focusMainChatForm();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await DatabaseService.updateMessage(message.id, { content: newContent });
|
||||
const index = conversationsStore.findMessageIndex(message.id);
|
||||
|
||||
if (index !== -1) {
|
||||
conversationsStore.updateMessageAtIndex(index, { content: newContent });
|
||||
}
|
||||
|
||||
focusMainChatForm();
|
||||
} else if (message.role === MessageRole.USER) {
|
||||
const finalExtras = await getMergedExtras();
|
||||
|
||||
chatActions.editWithBranching(message, editedContent.trim(), finalExtras);
|
||||
} else {
|
||||
// For assistant messages, preserve exact content including trailing whitespace
|
||||
// This is important for the Continue feature to work properly
|
||||
chatActions.editWithReplacement(message, editedContent, shouldBranchAfterEdit);
|
||||
}
|
||||
|
||||
isEditing = false;
|
||||
shouldBranchAfterEdit = false;
|
||||
editedUploadedFiles = [];
|
||||
}
|
||||
|
||||
async function handleSaveEditOnly() {
|
||||
if (message.role === MessageRole.USER) {
|
||||
// For user messages, trim to avoid accidental whitespace
|
||||
const finalExtras = await getMergedExtras();
|
||||
|
||||
chatActions.editUserMessagePreserveResponses(message, editedContent.trim(), finalExtras);
|
||||
}
|
||||
|
||||
isEditing = false;
|
||||
editedUploadedFiles = [];
|
||||
}
|
||||
|
||||
async function getMergedExtras(): Promise<DatabaseMessageExtra[]> {
|
||||
if (editedUploadedFiles.length === 0) {
|
||||
return editedExtras;
|
||||
}
|
||||
|
||||
const plainFiles = $state.snapshot(editedUploadedFiles);
|
||||
const result = await parseFilesToMessageExtras(plainFiles);
|
||||
const newExtras = result?.extras || [];
|
||||
|
||||
return [...editedExtras, ...newExtras];
|
||||
}
|
||||
|
||||
function handleShowDeleteDialogChange(show: boolean) {
|
||||
showDeleteDialog = show;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if message.role === MessageRole.SYSTEM}
|
||||
<ChatMessageSystem bind:textareaElement class={className} {message} />
|
||||
{:else if mcpPromptExtra}
|
||||
<ChatMessageMcpPrompt class={className} mcpPrompt={mcpPromptExtra} {message} />
|
||||
{:else if isSynthetic}
|
||||
<ChatMessageSynthetic class={className} {message} />
|
||||
{:else if message.role === MessageRole.USER}
|
||||
<ChatMessageUser class={className} {isLastUserMessage} {message} {nextAssistantMessage} />
|
||||
{:else}
|
||||
<ChatMessageAssistant
|
||||
bind:textareaElement
|
||||
class={className}
|
||||
{isLastAssistantMessage}
|
||||
{message}
|
||||
onContinue={handleContinue}
|
||||
onRegenerate={handleRegenerate}
|
||||
{toolMessages}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ChatMessageActionIcons,
|
||||
ChatMessageAgenticContent,
|
||||
ChatMessageAssistantModel,
|
||||
ChatMessageAssistantProcessingInfo,
|
||||
ChatMessageAssistantRawOutput,
|
||||
ChatMessageAssistantStatistics,
|
||||
ChatMessageEditForm
|
||||
} from '$lib/components/app';
|
||||
import { getChatMessageEditContext } from '$lib/contexts';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
|
||||
import { chatStore, modelsStore, serverStore, settingsStore } from '$lib/stores';
|
||||
import { modelLoadProgressText } from '$lib/utils';
|
||||
import { hasAgenticContent } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
isLastAssistantMessage?: boolean;
|
||||
message: DatabaseMessage;
|
||||
toolMessages?: DatabaseMessage[];
|
||||
onContinue?: () => void;
|
||||
onRegenerate: (modelOverride?: string) => void;
|
||||
textareaElement?: HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
isLastAssistantMessage = false,
|
||||
message,
|
||||
onContinue,
|
||||
onRegenerate,
|
||||
textareaElement = $bindable(),
|
||||
toolMessages = []
|
||||
}: Props = $props();
|
||||
|
||||
// Get edit context
|
||||
const editCtx = getChatMessageEditContext();
|
||||
|
||||
const isAgentic = $derived(hasAgenticContent(message, toolMessages));
|
||||
const processingState = useProcessingState();
|
||||
|
||||
let currentConfig = $derived(settingsStore.config);
|
||||
let isRouter = $derived(serverStore.isRouterMode);
|
||||
|
||||
let showRawOutput = $state(false);
|
||||
|
||||
let displayedModel = $derived(message.model ?? null);
|
||||
|
||||
let isCurrentlyLoading = $derived(chatStore.isLoading);
|
||||
let isStreaming = $derived(chatStore.isStreaming());
|
||||
let hasNoContent = $derived(!message?.content?.trim());
|
||||
let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming);
|
||||
|
||||
// during a router auto-load the message has no model yet: target the model frozen in the
|
||||
// persisted stream state (survives a reload), then fall back to the dropdown selection
|
||||
let loadTargetModel = $derived(
|
||||
message.model ?? chatStore.getResumeModel(message.convId) ?? modelsStore.selectedModelName
|
||||
);
|
||||
let modelLoadProgress = $derived(
|
||||
isRouter && loadTargetModel ? modelsStore.status.getLoadProgress(loadTargetModel) : null
|
||||
);
|
||||
let modelLoadingText = $derived(modelLoadProgressText(modelLoadProgress));
|
||||
|
||||
let showProcessingInfoTop = $derived(
|
||||
message?.role === MessageRole.ASSISTANT &&
|
||||
isActivelyProcessing &&
|
||||
hasNoContent &&
|
||||
!isAgentic &&
|
||||
isLastAssistantMessage
|
||||
);
|
||||
|
||||
let showProcessingInfoBottom = $derived(
|
||||
message?.role === MessageRole.ASSISTANT &&
|
||||
isActivelyProcessing &&
|
||||
(!hasNoContent || isAgentic) &&
|
||||
isLastAssistantMessage
|
||||
);
|
||||
|
||||
let assistantEl: HTMLDivElement | undefined = $state();
|
||||
let lastUserMessageHeight = $state(0);
|
||||
let assistantMarginTop = $state(0);
|
||||
|
||||
// The measured CSS vars feed the :last-child min-height rule only, so only
|
||||
// the last assistant message needs them. Reading isLastAssistantMessage
|
||||
// here also re-runs the effect when this message stops being the last.
|
||||
$effect(() => {
|
||||
if (!assistantEl || !isLastAssistantMessage) return;
|
||||
|
||||
assistantMarginTop = Math.round(parseFloat(getComputedStyle(assistantEl).marginTop));
|
||||
|
||||
const chatMessageEl = assistantEl.closest('.chat-message');
|
||||
const previousChatMessage = chatMessageEl?.previousElementSibling;
|
||||
const userMessageEl = previousChatMessage?.querySelector(
|
||||
'.chat-message-user'
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (!previousChatMessage || !userMessageEl) {
|
||||
lastUserMessageHeight = 0;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// a first exchange opened only by synthetic rows (e.g. the working
|
||||
// directory notice) must fit the screen as well, so they count as part
|
||||
// of the user turn; real earlier messages still scroll away above it
|
||||
const leadingRows: Element[] = [];
|
||||
|
||||
for (
|
||||
let row = previousChatMessage.previousElementSibling;
|
||||
row;
|
||||
row = row.previousElementSibling
|
||||
) {
|
||||
leadingRows.unshift(row);
|
||||
}
|
||||
|
||||
const leadingRowsAreSynthetic =
|
||||
leadingRows.length > 0 &&
|
||||
leadingRows.every((row) => row.classList.contains('chat-message--synthetic'));
|
||||
const updateHeight = () => {
|
||||
const rect = userMessageEl.getBoundingClientRect();
|
||||
const marginTop = Math.round(parseFloat(getComputedStyle(userMessageEl).marginTop));
|
||||
const leadingHeight = leadingRowsAreSynthetic
|
||||
? previousChatMessage.getBoundingClientRect().top -
|
||||
leadingRows[0].getBoundingClientRect().top
|
||||
: 0;
|
||||
|
||||
lastUserMessageHeight = Math.round(rect.height + marginTop + leadingHeight);
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateHeight);
|
||||
|
||||
resizeObserver.observe(userMessageEl);
|
||||
|
||||
if (leadingRowsAreSynthetic) {
|
||||
for (const row of leadingRows) resizeObserver.observe(row);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (showProcessingInfoTop || showProcessingInfoBottom) {
|
||||
processingState.startMonitoring();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={assistantEl}
|
||||
style:--assistant-margin-top={assistantMarginTop > 0 ? `${assistantMarginTop}px` : undefined}
|
||||
style:--last-user-message-height={lastUserMessageHeight > 0
|
||||
? `${lastUserMessageHeight}px`
|
||||
: undefined}
|
||||
aria-label="Assistant message with actions"
|
||||
class="chat-message-assistant text-md group w-full leading-7.5 {className}"
|
||||
role="group"
|
||||
>
|
||||
{#if showProcessingInfoTop}
|
||||
<ChatMessageAssistantProcessingInfo {modelLoadingText} position="top" {processingState} />
|
||||
{/if}
|
||||
|
||||
{#if editCtx.isEditing}
|
||||
<ChatMessageEditForm />
|
||||
{:else}
|
||||
{#if showRawOutput}
|
||||
<ChatMessageAssistantRawOutput {message} {toolMessages} />
|
||||
{:else}
|
||||
<ChatMessageAgenticContent
|
||||
{isLastAssistantMessage}
|
||||
isStreaming={chatStore.isStreaming()}
|
||||
{message}
|
||||
{toolMessages}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if showProcessingInfoBottom}
|
||||
<ChatMessageAssistantProcessingInfo {modelLoadingText} position="bottom" {processingState} />
|
||||
{/if}
|
||||
|
||||
{#if displayedModel}
|
||||
<div class="info my-6 grid gap-4 tabular-nums">
|
||||
<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
|
||||
<ChatMessageAssistantModel
|
||||
{displayedModel}
|
||||
isLoading={chatStore.isLoading}
|
||||
{isRouter}
|
||||
{onRegenerate}
|
||||
/>
|
||||
|
||||
<ChatMessageAssistantStatistics
|
||||
isLoading={chatStore.isLoading}
|
||||
{message}
|
||||
{processingState}
|
||||
showMessageStats={currentConfig.showMessageStats}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if message.timestamp && !editCtx.isEditing}
|
||||
<ChatMessageActionIcons
|
||||
actionsPosition="left"
|
||||
justify="start"
|
||||
onContinue={currentConfig.enableContinueGeneration ? onContinue : undefined}
|
||||
onRawOutputToggle={(enabled) => (showRawOutput = enabled)}
|
||||
{onRegenerate}
|
||||
rawOutputEnabled={showRawOutput}
|
||||
role={MessageRole.ASSISTANT}
|
||||
showRawOutputSwitch={currentConfig.showRawOutputSwitch}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.chat-message):last-child .chat-message-assistant {
|
||||
--assistant-min-height-offset: calc(
|
||||
var(--last-user-message-height, 19rem) + var(--chat-form-height, 6rem) +
|
||||
var(--chat-form-bottom-position, 0.5rem) + var(--chat-form-padding-top, 6rem) +
|
||||
var(--assistant-margin-top, 3rem) + var(--chat-tabs-offset, 0px)
|
||||
);
|
||||
min-height: calc(100dvh - var(--assistant-min-height-offset));
|
||||
|
||||
@media (width > 768px) {
|
||||
--assistant-min-height-offset: calc(
|
||||
var(--last-user-message-height, 18rem) + var(--chat-form-height, 6rem) +
|
||||
var(--chat-form-bottom-position, 1rem) + var(--chat-form-padding-top, 6rem) +
|
||||
var(--assistant-margin-top, 3rem) + var(--chat-tabs-offset, 0px)
|
||||
);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { ModelBadge, ModelsSelector } from '$lib/components/app';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
displayedModel: string | null;
|
||||
isRouter: boolean;
|
||||
isLoading: boolean;
|
||||
onRegenerate: (modelOverride?: string) => void;
|
||||
}
|
||||
|
||||
let { displayedModel, isLoading, isRouter, onRegenerate }: Props = $props();
|
||||
|
||||
function handleCopyModel() {
|
||||
void copyToClipboard(displayedModel ?? '');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isRouter}
|
||||
<ModelsSelector
|
||||
currentModel={displayedModel}
|
||||
disabled={isLoading}
|
||||
onModelChange={async (modelId: string, modelName: string) => {
|
||||
const status = modelsStore.getModelStatus(modelId);
|
||||
|
||||
// the picker's own selection applies before this resolves, so a model
|
||||
// that is not loaded yet gets its load request in first
|
||||
if (status !== ServerModelStatus.LOADED) {
|
||||
await modelsStore.status.load(modelId);
|
||||
}
|
||||
|
||||
onRegenerate(modelName);
|
||||
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
|
||||
{/if}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import type { UseProcessingStateReturn } from '$lib/hooks/use-processing-state.svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
modelLoadingText: string | null;
|
||||
processingState: UseProcessingStateReturn;
|
||||
position: 'top' | 'bottom';
|
||||
}
|
||||
|
||||
let { modelLoadingText, position, processingState }: Props = $props();
|
||||
|
||||
const marginClass = $derived(position === 'top' ? 'mt-6' : 'mt-4');
|
||||
</script>
|
||||
|
||||
<div in:fade class="{marginClass} w-full max-w-3xl">
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<span class="shimmer-text text-sm">
|
||||
{modelLoadingText ??
|
||||
processingState.getPromptProgressText() ??
|
||||
processingState.getProcessingMessage() ??
|
||||
'Processing...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { buildAssistantRawOutput, deriveAgenticSections } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
message: DatabaseMessage;
|
||||
toolMessages?: DatabaseMessage[];
|
||||
}
|
||||
|
||||
let { message, toolMessages = [] }: Props = $props();
|
||||
|
||||
let rawOutputContent = $derived.by(() => {
|
||||
const sections = deriveAgenticSections(message, toolMessages, [], false);
|
||||
|
||||
return buildAssistantRawOutput(sections);
|
||||
});
|
||||
</script>
|
||||
|
||||
<pre class="raw-output">{rawOutputContent || ''}</pre>
|
||||
|
||||
<style>
|
||||
.raw-output {
|
||||
width: 100%;
|
||||
max-width: 48rem;
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 1rem;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
color: var(--foreground);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
Loaded 100 of 3918 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user