mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-10-28 09:45:55 +08:00
ui : add the models manager (#29583)
* ui : add the drawer, sheet and grouped list primitives Add the drawer and sheet overlay components, the toggle and toggle group, the shared searchable input, the collapsible sections and the grouped list, and the near viewport helper they position with. Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash * ui : add the shared model components and data layer Add the model row pieces shared by the selector and the manager, the models store with its download status feed, the huggingface and migration services, and the model utils, enums and constants they read through. Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash * ui : rework the models selector around its providers Group the selector components under their own folder, rework the list around the provider grouping, add the mobile trigger and the download item, and derive the selection state in one hook. Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash * ui : add the models manager Add the manager table with its repo, quant and status rows, the filters and the toolbar, the row actions in a drawer, the downloads section, the manage models dialog and the stories and tests. Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash * ui : rework the chat form actions and the mobile experience Move the add actions into a drawer and a sheet, float the model actions in a bar on a phone, open the context panel in a drawer, and derive the attachment, reasoning and tools menus in hooks. Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash * ui : move the mcp servers to the sidebar rail and tidy the dialogs Move the mcp servers dialog to the sidebar rail with its menu entries, even out the dialogs on a phone, and group the mcp and settings stores behind their own modules. Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash * ui : polish the shell The secondary button gets its own look back and the chat add button its own light surface. Pressable elements get a pointer cursor again, the font rendering smooths on the app shell, and the agent skills stay out of prettier's way. The root layout props probe rework that came with this polish reads the providers api url and stays with the providers change instead. Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash * ui : wrap the model id classes Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash * ui : load the model the new chat CTA picks Start a new chat selected the model but left it unloaded, so the chat opened against a server with nothing in memory. Both CTAs share a load helper now. Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash * refactor: Post-review fixes
This commit is contained in:
1 parent
2c564f43df
commit
b013e56a71
174 files changed
+8374
-2854
No files matched your search
@@ -19,3 +19,7 @@ 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
|
||||
+91
-69
@@ -54,10 +54,13 @@ Llama UI supports two server operation modes:
|
||||
|
||||
### 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
|
||||
|
||||
@@ -144,6 +147,7 @@ proxy: {
|
||||
| ----------------- | ------------------------------- | -------------------------------------------------------- |
|
||||
| **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 |
|
||||
@@ -350,94 +354,103 @@ Components are organized in `app/` (application-specific) and `ui/` (shadcn-svel
|
||||
|
||||
**Dialog Components** (`app/dialogs/`):
|
||||
|
||||
| Component | Responsibility |
|
||||
| ------------------------------- | -------------------------------------------------------- |
|
||||
| `DialogChatSettings` | Full-screen settings configuration |
|
||||
| `DialogModelInformation` | Model details (context size, modalities, parallel slots) |
|
||||
| `DialogChatAttachmentPreview` | Full preview for images, PDFs (text or page view), code |
|
||||
| `DialogConfirmation` | Generic confirmation for destructive actions |
|
||||
| `DialogConversationTitleUpdate` | Edit conversation title |
|
||||
| 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 with Loaded/Available groups (ROUTER mode) |
|
||||
| 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 |
|
||||
| `ActionButton`, `ActionDropdown` | Reusable action buttons and menus |
|
||||
| `BadgeModality`, `BadgeInfo` | Status and capability badges |
|
||||
| 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 dropdown state (loaded/available groups) |
|
||||
| `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 |
|
||||
| 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); composes `props`, `status` |
|
||||
| `mcpStore` | MCP host role: multi-server lifecycle, tool routing; 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 |
|
||||
| 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=` |
|
||||
| `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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -660,8 +673,17 @@ flowchart TB
|
||||
```
|
||||
|
||||
- **IndexedDB**: Conversations and messages (large, structured data)
|
||||
- **LocalStorage**: Settings, user parameter overrides, theme (small key-value data)
|
||||
- **Memory only**: Server props, model list (fetched fresh on each session)
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+60
-4
@@ -12,7 +12,7 @@
|
||||
"@eslint/compat": "1.4.1",
|
||||
"@eslint/js": "9.39.2",
|
||||
"@internationalized/date": "3.12.2",
|
||||
"@lucide/svelte": "1.25.0",
|
||||
"@lucide/svelte": "1.46.0",
|
||||
"@modelcontextprotocol/sdk": "1.30.0",
|
||||
"@playwright/test": "1.56.1",
|
||||
"@storybook/addon-a11y": "10.5.6",
|
||||
@@ -79,6 +79,7 @@
|
||||
"unified": "11.0.5",
|
||||
"unist-util-visit": "5.1.0",
|
||||
"uuid": "13.0.2",
|
||||
"vaul-svelte": "1.0.0-next.7",
|
||||
"vite": "7.3.6",
|
||||
"vite-plugin-devtools-json": "0.2.1",
|
||||
"vitest": "4.1.10",
|
||||
@@ -3225,9 +3226,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@lucide/svelte": {
|
||||
"version": "1.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz",
|
||||
"integrity": "sha512-v9m+dD68jxVnqkU3K59mG/RSRFlPGzmKCGSyMfnXcaGv9jODDQMyQkcp1CGvk3Y/cUj9v7f8rw1n//K0B53xGQ==",
|
||||
"version": "1.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.46.0.tgz",
|
||||
"integrity": "sha512-Ru8jDjoCVLJ7J7pPRUdY+odSBV2BI6rj68JTmT+ENq/ZwYOPGPAiKVvKz+XLyAoK4HAwlRTzZHpsMvrHfsetqg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
@@ -16998,6 +16999,61 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/vaul-svelte": {
|
||||
"version": "1.0.0-next.7",
|
||||
"resolved": "https://registry.npmjs.org/vaul-svelte/-/vaul-svelte-1.0.0-next.7.tgz",
|
||||
"integrity": "sha512-7zN7Bi3dFQixvvbUJY9uGDe7Ws/dGZeBQR2pXdXmzQiakjrxBvWo0QrmsX3HK+VH+SZOltz378cmgmCS9f9rSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"runed": "^0.23.2",
|
||||
"svelte-toolbelt": "^0.7.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18",
|
||||
"pnpm": ">=8.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vaul-svelte/node_modules/runed": {
|
||||
"version": "0.23.4",
|
||||
"resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz",
|
||||
"integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/huntabyte",
|
||||
"https://github.com/sponsors/tglide"
|
||||
],
|
||||
"dependencies": {
|
||||
"esm-env": "^1.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vaul-svelte/node_modules/svelte-toolbelt": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz",
|
||||
"integrity": "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/huntabyte"
|
||||
],
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"runed": "^0.23.2",
|
||||
"style-to-object": "^1.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18",
|
||||
"pnpm": ">=8.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vfile": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@eslint/compat": "1.4.1",
|
||||
"@eslint/js": "9.39.2",
|
||||
"@internationalized/date": "3.12.2",
|
||||
"@lucide/svelte": "1.25.0",
|
||||
"@lucide/svelte": "1.46.0",
|
||||
"@modelcontextprotocol/sdk": "1.30.0",
|
||||
"@playwright/test": "1.56.1",
|
||||
"@storybook/addon-a11y": "10.5.6",
|
||||
@@ -99,6 +99,7 @@
|
||||
"unified": "11.0.5",
|
||||
"unist-util-visit": "5.1.0",
|
||||
"uuid": "13.0.2",
|
||||
"vaul-svelte": "1.0.0-next.7",
|
||||
"vite": "7.3.6",
|
||||
"vite-plugin-devtools-json": "0.2.1",
|
||||
"vitest": "4.1.10",
|
||||
|
||||
+28
-1
@@ -6,6 +6,33 @@
|
||||
|
||||
@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;
|
||||
--background: oklch(1 0 0);
|
||||
@@ -140,7 +167,7 @@
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground antialiased;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
ChatFormInputFileInputInvisible,
|
||||
ChatFormMcpResourcesList,
|
||||
ChatFormPickers,
|
||||
DialogMcpResourcesBrowser,
|
||||
DialogMcpServers
|
||||
DialogMcpResourcesBrowser
|
||||
} from '$lib/components/app';
|
||||
import {
|
||||
CLIPBOARD_CONTENT_QUOTE_PREFIX,
|
||||
@@ -34,7 +33,8 @@
|
||||
modelsStore,
|
||||
serverStore,
|
||||
settingsStore,
|
||||
toolsStore
|
||||
toolsStore,
|
||||
uiStore
|
||||
} from '$lib/stores';
|
||||
import type {
|
||||
FileMentionEntry,
|
||||
@@ -193,7 +193,6 @@
|
||||
let preSelectedResourceUri = $state<string | undefined>(undefined);
|
||||
|
||||
// MCP Servers Dialog State
|
||||
let isMcpServersDialogOpen = $state(false);
|
||||
|
||||
let currentConfig = $derived(settingsStore.config);
|
||||
|
||||
@@ -478,6 +477,19 @@
|
||||
queueMicrotask(() => inputRef?.focus());
|
||||
}
|
||||
|
||||
// A flow outside the chat, e.g. the models manager closing onto it, asks for the
|
||||
// composer back. The dialog's focus scope is still tearing down, so focus on a
|
||||
// frame of its own.
|
||||
$effect(() => {
|
||||
if (uiStore.manageModelsOpen) return;
|
||||
|
||||
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) {
|
||||
@@ -628,7 +640,6 @@
|
||||
isReasoning={chatStore.isReasoning}
|
||||
{isRecording}
|
||||
onFileUpload={handleFileUpload}
|
||||
onMcpSettingsClick={() => (isMcpServersDialogOpen = true)}
|
||||
onMicClick={handleMicClick}
|
||||
{onStop}
|
||||
onSystemPromptClick={() => onSystemPromptClick?.({ files: uploadedFiles, message: value })}
|
||||
@@ -667,5 +678,3 @@
|
||||
}}
|
||||
preSelectedUri={preSelectedResourceUri}
|
||||
/>
|
||||
|
||||
<DialogMcpServers bind:open={isMcpServersDialogOpen} />
|
||||
+2
-2
@@ -15,11 +15,11 @@
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="w-full">
|
||||
<Button
|
||||
class="file-upload-button md:h-8 md:w-8 h-9 w-9 rounded-full p-0"
|
||||
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="secondary"
|
||||
variant="ghost"
|
||||
>
|
||||
<span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
|
||||
|
||||
|
||||
+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}
|
||||
+20
-29
@@ -1,23 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { File, Image, MessageSquare, Mic, Plus, Video } from '@lucide/svelte';
|
||||
import {
|
||||
ChatFormActionAddReasoningSubmenu,
|
||||
ChatFormActionAddToolsSubmenu,
|
||||
McpLogo
|
||||
} from '$lib/components/app';
|
||||
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';
|
||||
import { serverStore } from '$lib/stores';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
@@ -48,9 +45,18 @@
|
||||
);
|
||||
|
||||
const FILE_MODALITY_ICONS: Record<string, { icon: typeof Image; label: string }> = {
|
||||
[AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY]: { icon: Mic, label: 'Audio' },
|
||||
[AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY]: { icon: Video, label: 'Video' },
|
||||
[AttachmentItemEnabledWhen.HAS_VISION_MODALITY]: { icon: Image, label: 'Vision' }
|
||||
[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(() =>
|
||||
@@ -70,8 +76,9 @@
|
||||
<DropdownMenu.Trigger
|
||||
{...props}
|
||||
class={cn(
|
||||
buttonVariants({ variant: 'secondary' }),
|
||||
'file-upload-button h-8 w-8 cursor-pointer rounded-full p-0'
|
||||
// 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}
|
||||
>
|
||||
@@ -97,13 +104,6 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
<!-- in router mode the models selector owns the reasoning submenu -->
|
||||
{#if !serverStore.isRouterMode}
|
||||
<ChatFormActionAddReasoningSubmenu />
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
{/if}
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={() => attachmentMenu.callbacks[AttachmentAction.FILE_UPLOAD]()}
|
||||
@@ -111,7 +111,7 @@
|
||||
<File class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<span>Add files</span>
|
||||
<span>{ATTACHMENT_MENU_TEXT.ADD_FILES}</span>
|
||||
|
||||
{#if supportedModalities.length > 0}
|
||||
<span class="flex items-center gap-0.75 text-muted-foreground">
|
||||
@@ -140,19 +140,10 @@
|
||||
>
|
||||
<MessageSquare class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>System Message</span>
|
||||
<span>{ATTACHMENT_MENU_TEXT.SYSTEM_MESSAGE}</span>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<ChatFormActionAddToolsSubmenu />
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={chatFormActions.onMcpSettingsClick}
|
||||
>
|
||||
<McpLogo class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>MCP Servers</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Check, Info, Lightbulb, LightbulbOff } from '@lucide/svelte';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
|
||||
|
||||
const reasoning = useReasoningMenu();
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
{#if reasoning.isReasoningActive}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
|
||||
{:else if reasoning.isOff}
|
||||
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{:else}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
|
||||
<span
|
||||
class="text-sm inline-flex gap-2 {!reasoning.isReasoningActive
|
||||
? 'text-muted-foreground'
|
||||
: ''}"
|
||||
>
|
||||
Reasoning
|
||||
|
||||
<span class="capitalize text-muted-foreground">
|
||||
{reasoning.currentEffort}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent
|
||||
class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
|
||||
>
|
||||
{#each reasoning.levels as level (level.value)}
|
||||
{@const tokenLabel = reasoning.tokenLabel(level)}
|
||||
<DropdownMenu.Item
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent {reasoning.isSelected(
|
||||
level
|
||||
)
|
||||
? 'bg-accent'
|
||||
: ''}"
|
||||
onclick={() => 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="flex-1">{level.label}</span>
|
||||
|
||||
{#if tokenLabel}
|
||||
<span class="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="left">
|
||||
<p>Maximum reasoning effort with extended context usage</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
-284
@@ -1,284 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
File,
|
||||
Lightbulb,
|
||||
LightbulbOff,
|
||||
MessageSquare,
|
||||
PencilRuler
|
||||
} from '@lucide/svelte';
|
||||
import { McpLogo } from '$lib/components/app';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import {
|
||||
ATTACHMENT_FILE_ITEMS,
|
||||
ICON_CLASS_DEFAULT,
|
||||
TOOLTIP_DELAY_DURATION
|
||||
} from '$lib/constants';
|
||||
import { getChatFormActionsContext } from '$lib/contexts';
|
||||
import { AttachmentAction } from '$lib/enums/attachment.enums';
|
||||
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
|
||||
import { useReasoningMenu } from '$lib/hooks/use-reasoning-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 sheetOpen = $state(false);
|
||||
let filesExpanded = $state(true);
|
||||
let reasoningExpanded = $state(false);
|
||||
let toolsExpanded = $state(false);
|
||||
|
||||
const attachmentMenu = useAttachmentMenu(
|
||||
() => ({
|
||||
hasAudioModality: chatFormActions.hasAudioModality,
|
||||
hasVideoModality: chatFormActions.hasVideoModality,
|
||||
hasVisionModality: chatFormActions.hasVisionModality
|
||||
}),
|
||||
() => ({
|
||||
onFileUpload: chatFormActions.onFileUpload,
|
||||
onSystemPromptClick: chatFormActions.onSystemPromptClick
|
||||
}),
|
||||
() => {
|
||||
sheetOpen = false;
|
||||
}
|
||||
);
|
||||
|
||||
const toolsPanel = useToolsPanel();
|
||||
const reasoning = useReasoningMenu();
|
||||
|
||||
const sheetItemClass =
|
||||
'flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent active:bg-accent disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
const sheetItemRowClass =
|
||||
'flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent';
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1 {className}">
|
||||
<Sheet.Root bind:open={sheetOpen}>
|
||||
{@render trigger({ disabled: chatFormActions.disabled, onclick: () => (sheetOpen = true) })}
|
||||
|
||||
<Sheet.Content class="max-h-[85vh] gap-0 overflow-y-auto" side="bottom">
|
||||
<Sheet.Header>
|
||||
<Sheet.Title>Add to chat</Sheet.Title>
|
||||
|
||||
<Sheet.Description class="sr-only">
|
||||
Add files, system prompt or configure MCP servers
|
||||
</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
|
||||
<div class="flex flex-col gap-1 px-1.5 pb-2">
|
||||
{#if reasoning.modelSupportsThinking}
|
||||
<Collapsible.Root
|
||||
onOpenChange={(open) => (reasoningExpanded = open)}
|
||||
open={reasoningExpanded}
|
||||
>
|
||||
<Collapsible.Trigger class={sheetItemClass}>
|
||||
{#if reasoningExpanded}
|
||||
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
{:else}
|
||||
<ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
{/if}
|
||||
|
||||
{#if reasoning.thinkingEnabled}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
|
||||
{:else if reasoning.isOff}
|
||||
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{:else}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
|
||||
<span class="flex-1">Reasoning</span>
|
||||
|
||||
<span class="text-xs capitalize text-muted-foreground">
|
||||
{reasoning.currentEffort}
|
||||
</span>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content>
|
||||
<div class="flex flex-col gap-0.5 pl-4">
|
||||
{#each reasoning.levels as level (level.value)}
|
||||
{@const tokenLabel = reasoning.tokenLabel(level)}
|
||||
<button
|
||||
class:bg-accent={reasoning.isSelected(level)}
|
||||
class={sheetItemRowClass}
|
||||
onclick={() => reasoning.select(level)}
|
||||
type="button"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
{#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="text-sm">{level.label}</span>
|
||||
</div>
|
||||
|
||||
{#if tokenLabel}
|
||||
<span class="shrink-0 text-[11px] text-muted-foreground opacity-60">
|
||||
{tokenLabel}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{/if}
|
||||
|
||||
<Collapsible.Root onOpenChange={(open) => (filesExpanded = open)} open={filesExpanded}>
|
||||
<Collapsible.Trigger class={sheetItemClass}>
|
||||
{#if filesExpanded}
|
||||
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
{:else}
|
||||
<ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
{/if}
|
||||
|
||||
<File class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
|
||||
<span class="flex-1">Add files</span>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content>
|
||||
<div class="flex flex-col gap-0.5 pl-4">
|
||||
{#each ATTACHMENT_FILE_ITEMS as item (item.id)}
|
||||
{@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)}
|
||||
{#if enabled}
|
||||
<button
|
||||
class={sheetItemClass}
|
||||
onclick={() => attachmentMenu.callbacks[item.action]()}
|
||||
type="button"
|
||||
>
|
||||
<item.icon class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
{:else if item.disabledTooltip}
|
||||
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
|
||||
<Tooltip.Trigger>
|
||||
<button class={sheetItemClass} disabled type="button">
|
||||
<item.icon class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content side="right">
|
||||
<p>{item.disabledTooltip}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
|
||||
<button
|
||||
class={sheetItemClass}
|
||||
onclick={() => attachmentMenu.callbacks[AttachmentAction.SYSTEM_PROMPT_CLICK]()}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquare class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
|
||||
<span>System Message</span>
|
||||
</button>
|
||||
|
||||
{#if toolsPanel.totalToolCount > 0}
|
||||
<Collapsible.Root onOpenChange={(open) => (toolsExpanded = open)} open={toolsExpanded}>
|
||||
<Collapsible.Trigger class={sheetItemClass}>
|
||||
{#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">Tools</span>
|
||||
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{toolsPanel.totalToolCount} tool{toolsPanel.totalToolCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content>
|
||||
<div class="flex flex-col gap-0.5 pl-4">
|
||||
{#each toolsPanel.categoryGroups as group (group.key)}
|
||||
{@render sheetGroupRow(group)}
|
||||
{/each}
|
||||
|
||||
{#each toolsPanel.mcpGroups as group (group.key)}
|
||||
{@render sheetGroupRow(group)}
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class={sheetItemClass}
|
||||
onclick={() => {
|
||||
sheetOpen = false;
|
||||
chatFormActions.onMcpSettingsClick?.();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<McpLogo class="inline {ICON_CLASS_DEFAULT} shrink-0" />
|
||||
|
||||
<span>MCP Servers</span>
|
||||
</button>
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
</div>
|
||||
|
||||
{#snippet sheetGroupRow(group: ToolGroup)}
|
||||
{@const checkState = toolsPanel.getGroupCheckState(group)}
|
||||
{@const enabledCount = toolsPanel.getEnabledToolCount(group)}
|
||||
{@const favicon = toolsPanel.getFavicon(group)}
|
||||
{@const groupDisabled = toolsPanel.isGroupDisabled(group)}
|
||||
|
||||
<button
|
||||
class="{sheetItemRowClass} {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}
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
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 { CLI_FLAGS, ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
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';
|
||||
@@ -17,7 +17,7 @@
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
<PencilRuler class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>Tools</span>
|
||||
<span>{ATTACHMENT_MENU_TEXT.TOOLS}</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent class="w-72 p-0">
|
||||
|
||||
+3
-3
@@ -1,16 +1,16 @@
|
||||
<script lang="ts">
|
||||
import ChatFormActionAddButton from './ChatFormActionAddButton.svelte';
|
||||
import ChatFormActionAddDrawer from './ChatFormActionAddDrawer.svelte';
|
||||
import ChatFormActionAddDropdown from './ChatFormActionAddDropdown.svelte';
|
||||
import ChatFormActionAddSheet from './ChatFormActionAddSheet.svelte';
|
||||
import { deviceStore } from '$lib/stores';
|
||||
</script>
|
||||
|
||||
{#if deviceStore.isMobile}
|
||||
<ChatFormActionAddSheet>
|
||||
<ChatFormActionAddDrawer>
|
||||
{#snippet trigger({ disabled, onclick })}
|
||||
<ChatFormActionAddButton {disabled} {onclick} />
|
||||
{/snippet}
|
||||
</ChatFormActionAddSheet>
|
||||
</ChatFormActionAddDrawer>
|
||||
{:else}
|
||||
<ChatFormActionAddDropdown />
|
||||
{/if}
|
||||
-170
@@ -1,170 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
|
||||
import { conversationsStore, deviceStore, modelsStore, serverStore } from '$lib/stores';
|
||||
import { getConversationModel } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
forceForegroundText?: boolean;
|
||||
hasAudioModality?: boolean;
|
||||
hasVideoModality?: boolean;
|
||||
hasVisionModality?: boolean;
|
||||
hasModelSelected?: boolean;
|
||||
isSelectedModelInCache?: boolean;
|
||||
submitTooltip?: string;
|
||||
useGlobalSelection?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
disabled = false,
|
||||
forceForegroundText = false,
|
||||
hasAudioModality = $bindable(false),
|
||||
hasModelSelected = $bindable(false),
|
||||
hasVideoModality = $bindable(false),
|
||||
hasVisionModality = $bindable(false),
|
||||
isSelectedModelInCache = $bindable(true),
|
||||
submitTooltip = $bindable(''),
|
||||
useGlobalSelection = false
|
||||
}: Props = $props();
|
||||
|
||||
let isRouter = $derived(serverStore.isRouterMode);
|
||||
let isOffline = $derived(!!serverStore.error);
|
||||
|
||||
let conversationModel = $derived(
|
||||
getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
|
||||
);
|
||||
|
||||
let lastSyncedConversationModel: string | null = null;
|
||||
|
||||
let selectorModel = $derived.by(() => {
|
||||
const storeModel = modelsStore.selectedModelName;
|
||||
|
||||
if (storeModel && storeModel !== conversationModel) {
|
||||
return storeModel;
|
||||
}
|
||||
|
||||
if (conversationModel) {
|
||||
return conversationModel;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (conversationModel && conversationModel !== lastSyncedConversationModel) {
|
||||
if (modelsStore.models.some((m) => m.model === conversationModel)) {
|
||||
modelsStore.selectedModelName = conversationModel;
|
||||
modelsStore.selectModelByName(conversationModel);
|
||||
} else {
|
||||
modelsStore.selectedModelName = null;
|
||||
modelsStore.clearSelection();
|
||||
}
|
||||
|
||||
lastSyncedConversationModel = conversationModel;
|
||||
} else if (
|
||||
isRouter &&
|
||||
!modelsStore.selectedModelId &&
|
||||
modelsStore.loadedModelIds.length > 0 &&
|
||||
conversationsStore.activeMessages.length > 0 &&
|
||||
!conversationModel
|
||||
) {
|
||||
lastSyncedConversationModel = null;
|
||||
const first = modelsStore.models.find((m) => modelsStore.loadedModelIds.includes(m.model));
|
||||
|
||||
if (first) modelsStore.selectModelById(first.id);
|
||||
}
|
||||
});
|
||||
|
||||
let activeModelId = $derived(modelsStore.activeModelId);
|
||||
|
||||
let modelPropsVersion = $state(0); // Used to trigger reactivity after fetch
|
||||
|
||||
$effect(() => {
|
||||
if (activeModelId) {
|
||||
const cached = modelsStore.props.getModelProps(activeModelId);
|
||||
|
||||
if (!cached) {
|
||||
modelsStore.props.fetchModelProps(activeModelId).then(() => {
|
||||
modelPropsVersion++;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void modelPropsVersion;
|
||||
|
||||
hasAudioModality = activeModelId ? modelsStore.props.modelSupportsAudio(activeModelId) : false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void modelPropsVersion;
|
||||
|
||||
hasVideoModality = activeModelId ? modelsStore.props.modelSupportsVideo(activeModelId) : false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void modelPropsVersion;
|
||||
|
||||
hasVisionModality = activeModelId
|
||||
? modelsStore.props.modelSupportsVision(activeModelId)
|
||||
: false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
hasModelSelected = !isRouter || !!conversationModel || !!modelsStore.selectedModelId;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!isRouter) {
|
||||
isSelectedModelInCache = true;
|
||||
} else if (conversationModel) {
|
||||
isSelectedModelInCache = modelsStore.models.some(
|
||||
(option) => option.model === conversationModel
|
||||
);
|
||||
} else {
|
||||
const currentModelId = modelsStore.selectedModelId;
|
||||
|
||||
if (!currentModelId) {
|
||||
isSelectedModelInCache = false;
|
||||
} else {
|
||||
isSelectedModelInCache = modelsStore.models.some((option) => option.id === currentModelId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!hasModelSelected) {
|
||||
submitTooltip = 'Please select a model first';
|
||||
} else if (!isSelectedModelInCache) {
|
||||
submitTooltip = 'Selected model is not available, please select another';
|
||||
} else {
|
||||
submitTooltip = '';
|
||||
}
|
||||
});
|
||||
|
||||
let selectorModelRef: ModelsSelectorDropdown | ModelsSelectorSheet | undefined =
|
||||
$state(undefined);
|
||||
|
||||
export function open() {
|
||||
selectorModelRef?.open();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if deviceStore.isMobile}
|
||||
<ModelsSelectorSheet
|
||||
bind:this={selectorModelRef}
|
||||
currentModel={selectorModel}
|
||||
disabled={disabled || isOffline}
|
||||
{forceForegroundText}
|
||||
{useGlobalSelection}
|
||||
/>
|
||||
{:else}
|
||||
<ModelsSelectorDropdown
|
||||
bind:this={selectorModelRef}
|
||||
currentModel={selectorModel}
|
||||
disabled={disabled || isOffline}
|
||||
{forceForegroundText}
|
||||
{useGlobalSelection}
|
||||
/>
|
||||
{/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}
|
||||
+1
-1
@@ -27,7 +27,7 @@
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
class="h-8 w-8 rounded-full p-0 {isRecording
|
||||
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}
|
||||
|
||||
+24
-23
@@ -2,16 +2,18 @@
|
||||
import { SkipForward, Square } from '@lucide/svelte';
|
||||
import { page } from '$app/state';
|
||||
import {
|
||||
ChatFormActionModels,
|
||||
ChatFormActionReasoning,
|
||||
ChatFormActionRecord,
|
||||
ChatFormActionsAdd,
|
||||
ChatFormActionSubmit,
|
||||
ChatFormContextGauge
|
||||
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';
|
||||
@@ -31,7 +33,6 @@
|
||||
onMicClick?: () => void;
|
||||
onStop?: () => void;
|
||||
onSystemPromptClick?: () => void;
|
||||
onMcpSettingsClick?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -43,7 +44,6 @@
|
||||
isReasoning = false,
|
||||
isRecording = false,
|
||||
onFileUpload,
|
||||
onMcpSettingsClick,
|
||||
onMicClick,
|
||||
onStop,
|
||||
onSystemPromptClick,
|
||||
@@ -54,12 +54,20 @@
|
||||
|
||||
let currentConfig = $derived(settingsStore.config);
|
||||
|
||||
let hasAudioModality = $state(false);
|
||||
let hasVideoModality = $state(false);
|
||||
let hasVisionModality = $state(false);
|
||||
let hasModelSelected = $state(false);
|
||||
let isSelectedModelInCache = $state(true);
|
||||
let submitTooltip = $state('');
|
||||
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)
|
||||
@@ -68,7 +76,7 @@
|
||||
hasAudioModality && !canSubmit && !hasAudioAttachments && currentConfig.autoMicOnEmpty
|
||||
);
|
||||
|
||||
let selectorModelRef: ChatFormActionModels | undefined = $state(undefined);
|
||||
let selectorModelRef: ModelsSelector | undefined = $state(undefined);
|
||||
|
||||
export function openModelSelector() {
|
||||
selectorModelRef?.open();
|
||||
@@ -135,9 +143,6 @@
|
||||
get onFileUpload() {
|
||||
return onFileUpload;
|
||||
},
|
||||
get onMcpSettingsClick() {
|
||||
return onMcpSettingsClick;
|
||||
},
|
||||
get onSystemPromptClick() {
|
||||
return onSystemPromptClick;
|
||||
}
|
||||
@@ -160,13 +165,9 @@
|
||||
{/if}
|
||||
|
||||
{#if showModelSelector}
|
||||
<ChatFormActionModels
|
||||
bind:hasAudioModality
|
||||
bind:hasModelSelected
|
||||
bind:hasVideoModality
|
||||
bind:hasVisionModality
|
||||
bind:isSelectedModelInCache
|
||||
bind:submitTooltip
|
||||
<ChatFormActionReasoning />
|
||||
|
||||
<ModelsSelector
|
||||
bind:this={selectorModelRef}
|
||||
{disabled}
|
||||
forceForegroundText
|
||||
@@ -177,7 +178,7 @@
|
||||
|
||||
{#if isReasoning}
|
||||
<Button
|
||||
class="group h-8 w-8 rounded-full p-0"
|
||||
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"
|
||||
@@ -194,7 +195,7 @@
|
||||
|
||||
{#if isLoading && !canSubmit}
|
||||
<Button
|
||||
class="group h-8 w-8 rounded-full p-0 hover:bg-destructive/10!"
|
||||
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"
|
||||
|
||||
+8
-3
@@ -8,7 +8,7 @@
|
||||
gaugeTriggerPointerDown
|
||||
} from './gauge-popup.svelte';
|
||||
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
|
||||
import { chatStore, conversationsStore } from '$lib/stores';
|
||||
import { chatStore, conversationsStore, deviceStore } from '$lib/stores';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
const gauge = useContextGauge();
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
<div
|
||||
aria-label="Context usage"
|
||||
class="flex h-5 w-5 cursor-default items-center justify-center"
|
||||
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}
|
||||
@@ -53,5 +53,10 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<ContextGaugeDial level={gauge.colorLevel} percent={gauge.contextPercent} />
|
||||
<!-- 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>
|
||||
+3
-1
@@ -16,6 +16,8 @@
|
||||
</div>
|
||||
|
||||
{#if subtitle}
|
||||
<div class="text-[10px] leading-tight text-muted-foreground/70">{subtitle}</div>
|
||||
<div class="text-[10px] leading-tight text-muted-foreground/70 max-md:text-[11px]">
|
||||
{subtitle}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
+11
-18
@@ -1,8 +1,5 @@
|
||||
<script lang="ts">
|
||||
import ContextGaugeDetailRow from './ContextGaugeDetailRow.svelte';
|
||||
import { gaugePopup } from './gauge-popup.svelte';
|
||||
import { ChevronDown } from '@lucide/svelte';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import { STATS_UNITS } from '$lib/constants';
|
||||
|
||||
interface Props {
|
||||
@@ -35,21 +32,15 @@
|
||||
const hasCurrent = $derived(currentRead > 0 || currentOutput > 0);
|
||||
</script>
|
||||
|
||||
<Collapsible.Root bind:open={gaugePopup.detailsOpen} class="mt-3 border-t border-border/50 pt-4">
|
||||
<Collapsible.Trigger
|
||||
class="flex w-full cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<span>Token usage details</span>
|
||||
<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>
|
||||
|
||||
<ChevronDown
|
||||
class={'ml-auto h-3 w-3 transition-transform' + (gaugePopup.detailsOpen ? ' rotate-180' : '')}
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content class="flex flex-col gap-4 text-xs pt-4">
|
||||
<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">
|
||||
<h3
|
||||
class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground/70 mb-2 max-md:text-xs"
|
||||
>
|
||||
Across all turns
|
||||
</h3>
|
||||
|
||||
@@ -76,7 +67,9 @@
|
||||
|
||||
{#if hasCurrent}
|
||||
<div>
|
||||
<h3 class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground/70 mb-2">
|
||||
<h3
|
||||
class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground/70 mb-2 max-md:text-xs"
|
||||
>
|
||||
This turn · KV cache
|
||||
</h3>
|
||||
|
||||
@@ -121,5 +114,5 @@
|
||||
{#each transientDetails as detail (detail)}
|
||||
<div class="font-mono text-muted-foreground">{detail}</div>
|
||||
{/each}
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
</div>
|
||||
+3
-2
@@ -10,8 +10,9 @@
|
||||
|
||||
let { level, percent, size = 'sm' }: Props = $props();
|
||||
|
||||
const RADIUS = 11;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
// 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');
|
||||
|
||||
+79
-50
@@ -8,7 +8,9 @@
|
||||
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();
|
||||
@@ -24,9 +26,10 @@
|
||||
|
||||
// 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.
|
||||
// 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) return;
|
||||
if (!gaugePopup.open || deviceStore.isMobile) return;
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
@@ -52,7 +55,78 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if gaugePopup.open}
|
||||
{#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-lg border border-border/50 bg-popover p-3 text-sm text-popover-foreground shadow-lg ring-1 ring-foreground/10"
|
||||
@@ -67,55 +141,10 @@
|
||||
|
||||
<span class="text-muted-foreground">·</span>
|
||||
|
||||
<span class="font-mono text-muted-foreground">
|
||||
{formatParameters(gauge.contextUsed)}
|
||||
/ {gauge.contextTotal !== null ? formatParameters(gauge.contextTotal) : '-'}
|
||||
</span>
|
||||
{@render usage()}
|
||||
</div>
|
||||
|
||||
{#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">
|
||||
<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}
|
||||
{@render body()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
+1
-1
@@ -16,7 +16,7 @@ import {
|
||||
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let lastPointerType = '';
|
||||
|
||||
export const gaugePopup = $state({ bottom: 0, centerX: 0, detailsOpen: false, open: false });
|
||||
export const gaugePopup = $state({ bottom: 0, centerX: 0, open: false });
|
||||
|
||||
function openFrom(trigger: HTMLElement): void {
|
||||
clearTimeout(closeTimer);
|
||||
|
||||
+2
-10
@@ -1,12 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { FolderOpen, Sparkles } from '@lucide/svelte';
|
||||
import {
|
||||
ChatFormPickerList,
|
||||
ChatFormPickerListItem,
|
||||
ChatFormPickerPopover
|
||||
} from '$lib/components/app/chat';
|
||||
import { MODEL_SELECTOR_ICON } from '$lib/constants';
|
||||
import { ChatFormCommandAction } from '$lib/enums';
|
||||
import { CHAT_FORM_COMMAND_ICONS } from '$lib/constants';
|
||||
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
|
||||
import type { ChatFormCommand } from '$lib/types';
|
||||
|
||||
@@ -26,12 +24,6 @@
|
||||
|
||||
let { class: className = '', commands, isOpen, onClose, onSelect, query }: Props = $props();
|
||||
|
||||
const commandIcon: Record<ChatFormCommandAction, typeof Sparkles> = {
|
||||
[ChatFormCommandAction.CWD]: FolderOpen,
|
||||
[ChatFormCommandAction.MODEL]: MODEL_SELECTOR_ICON,
|
||||
[ChatFormCommandAction.PROMPT]: Sparkles
|
||||
};
|
||||
|
||||
const trimmedQuery = $derived((query ?? '').trim().toLowerCase());
|
||||
|
||||
const filteredCommands = $derived(
|
||||
@@ -119,7 +111,7 @@
|
||||
showSearchInput={false}
|
||||
>
|
||||
{#snippet item(command, index, isSelected)}
|
||||
{@const Icon = commandIcon[command.action]}
|
||||
{@const Icon = CHAT_FORM_COMMAND_ICONS[command.action]}
|
||||
<ChatFormPickerListItem
|
||||
dataIndex={index}
|
||||
disabled={command.disabled}
|
||||
|
||||
+6
-12
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { ModelBadge, ModelsSelectorDropdown } from '$lib/components/app';
|
||||
import { ModelBadge, ModelsSelector } from '$lib/components/app';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
@@ -13,28 +13,22 @@
|
||||
|
||||
let { displayedModel, isLoading, isRouter, onRegenerate }: Props = $props();
|
||||
|
||||
let pendingModel = $state<string | null>(null);
|
||||
|
||||
function handleCopyModel() {
|
||||
void copyToClipboard(displayedModel ?? '');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isRouter}
|
||||
<ModelsSelectorDropdown
|
||||
currentModel={pendingModel ?? displayedModel}
|
||||
<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) {
|
||||
pendingModel = modelId;
|
||||
|
||||
try {
|
||||
await modelsStore.status.load(modelId);
|
||||
} finally {
|
||||
pendingModel = null;
|
||||
}
|
||||
await modelsStore.status.load(modelId);
|
||||
}
|
||||
|
||||
onRegenerate(modelName);
|
||||
|
||||
+2
-1
@@ -7,6 +7,7 @@
|
||||
import { mcpStore } from '$lib/stores';
|
||||
import type { AgenticSection, SearchResult } from '$lib/types';
|
||||
import {
|
||||
buildProxiedIconUrl,
|
||||
extractSearchQuery,
|
||||
extractSearchResults,
|
||||
faviconForUrl,
|
||||
@@ -82,7 +83,7 @@
|
||||
</script>
|
||||
|
||||
{#snippet pill(result: SearchResult)}
|
||||
{@const faviconUrl = faviconForUrl(result.url)}
|
||||
{@const faviconUrl = buildProxiedIconUrl(faviconForUrl(result.url), mcpStore.isProxyAvailable)}
|
||||
{@const safeUrl = sanitizeExternalUrl(result.url)}
|
||||
{@const showHoverCard = safeUrl !== null && hasDetails(result)}
|
||||
{#if safeUrl}
|
||||
|
||||
+1
-1
@@ -208,7 +208,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-1 px-2">
|
||||
<div class="flex items-center gap-1 max-md:pt-1 md:px-1">
|
||||
{#if activeView === ChatMessageStatsView.GENERATION && hasGenerationStats}
|
||||
<ChatMessageStatisticsBadge
|
||||
class="bg-transparent"
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
}: Props = $props();
|
||||
|
||||
let chatFormRef: ChatForm | undefined = $state(undefined);
|
||||
|
||||
let formWrapperEl: HTMLDivElement | undefined = $state();
|
||||
let chatId = $derived(page.params.id as string | undefined);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
let isLoadingModel = $derived(serverStore.status === 503);
|
||||
</script>
|
||||
|
||||
{#if hasError}
|
||||
{#if hasError || isLoadingModel}
|
||||
<div class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1">
|
||||
<Alert.Root variant={isLoadingModel ? 'default' : 'destructive'}>
|
||||
{#if isLoadingModel}
|
||||
|
||||
@@ -165,7 +165,7 @@ export { default as ChatForm } from './ChatForm/ChatForm.svelte';
|
||||
/**
|
||||
* Wrapper component for the "add to chat" button (Plus icon).
|
||||
* Exposes a `button` snippet that can be used inside DropdownMenu.Trigger (desktop)
|
||||
* or Sheet.Root (mobile) to maintain consistent styling while allowing
|
||||
* or Drawer.Root (mobile) to maintain consistent styling while allowing
|
||||
* platform-specific trigger wrappers.
|
||||
*/
|
||||
export { default as ChatFormActionsAdd } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionsAdd.svelte';
|
||||
@@ -192,11 +192,11 @@ export { default as ChatFormActions } from './ChatForm/ChatFormActions/ChatFormA
|
||||
export { default as ChatFormActionSubmit } from './ChatForm/ChatFormActions/ChatFormActionSubmit.svelte';
|
||||
|
||||
/**
|
||||
* Model selector component for the chat form action bar. Renders either a dropdown
|
||||
* (desktop) or bottom sheet (mobile) for selecting the conversation model in router mode.
|
||||
* Model selector component for the chat form action bar and the assistant message
|
||||
* model badge. Renders a dropdown on desktop and a bottom drawer on a phone.
|
||||
* Exposes an `open` method for programmatically opening the selector.
|
||||
*/
|
||||
export { default as ChatFormActionModels } from './ChatForm/ChatFormActions/ChatFormActionModels.svelte';
|
||||
export { default as ChatFormActionReasoning } from './ChatForm/ChatFormActions/ChatFormActionReasoning.svelte';
|
||||
|
||||
/**
|
||||
* Dropdown submenu for managing tool permissions in the chat form.
|
||||
@@ -220,15 +220,6 @@ export { default as ChatFormActionModels } from './ChatForm/ChatFormActions/Chat
|
||||
*/
|
||||
export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte';
|
||||
|
||||
/**
|
||||
* Dropdown submenu for selecting reasoning effort level.
|
||||
*
|
||||
* Shows a "Reasoning" sub-menu item with a lightbulb icon indicating
|
||||
* thinking status, and a nested list of effort levels.
|
||||
* Only visible when the current model supports thinking.
|
||||
*/
|
||||
export { default as ChatFormActionAddReasoningSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddReasoningSubmenu.svelte';
|
||||
|
||||
/**
|
||||
* Compact context-usage gauge with per-turn and cumulative breakdown in the tooltip.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import ModelsManager from '$lib/components/app/models/ModelsManager/ModelsManager.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { MODEL_ICON } from '$lib/constants';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
let { onOpenChange, open = $bindable(false) }: Props = $props();
|
||||
|
||||
function handleOpenChange(value: boolean) {
|
||||
open = value;
|
||||
onOpenChange?.(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root onOpenChange={handleOpenChange} {open}>
|
||||
<Dialog.Content
|
||||
class="max-md:h-[100dvh]! max-md:w-screen! max-md:max-w-none! max-md:rounded-none! max-md:border-0! md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-380! flex flex-col p-4 pb-0"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<Dialog.Header class="flex flex-row items-center pr-8">
|
||||
<Dialog.Title class="flex min-h-7 items-center gap-2">
|
||||
<MODEL_ICON class="h-5 w-5 shrink-0" />
|
||||
|
||||
<span>Models</span>
|
||||
</Dialog.Title>
|
||||
|
||||
<Dialog.Description class="sr-only">
|
||||
Browse, load and delete the models the server can serve.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="min-h-0 flex-1">
|
||||
<ModelsManager class="h-full" />
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -5,7 +5,7 @@
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { DEFAULT_RESOURCE_FILENAME, MIME_TYPE_SUBSTRINGS } from '$lib/constants';
|
||||
import { MimeTypeText } from '$lib/enums';
|
||||
import { mcpStore } from '$lib/stores';
|
||||
import { deviceStore, mcpStore } from '$lib/stores';
|
||||
import type { DatabaseMessageExtraMcpResource } from '$lib/types';
|
||||
import {
|
||||
downloadResourceContent,
|
||||
@@ -51,7 +51,10 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open {onOpenChange}>
|
||||
<Dialog.Content class="grid max-h-[90vh] max-w-5xl overflow-hidden sm:w-auto sm:max-w-6xl">
|
||||
<Dialog.Content
|
||||
class="grid max-h-[90vh] max-w-5xl overflow-hidden p-4 sm:w-auto sm:max-w-6xl"
|
||||
onOpenAutoFocus={(event) => deviceStore.isMobile && event.preventDefault()}
|
||||
>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="pr-8">{extra.name}</Dialog.Title>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Braces, FolderOpen, Loader2, Plus } from '@lucide/svelte';
|
||||
import { Braces, FolderOpen, Loader2, Plus, X } from '@lucide/svelte';
|
||||
import {
|
||||
McpResourcePreview,
|
||||
McpResourcesBrowser,
|
||||
@@ -7,8 +7,8 @@
|
||||
} from '$lib/components/app';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { mcpStore } from '$lib/stores';
|
||||
import { ICON_CLASS_DEFAULT, PANEL_CLOSE_MOBILE_CLASS } from '$lib/constants';
|
||||
import { deviceStore, mcpStore } from '$lib/stores';
|
||||
import type { MCPResourceContent, MCPResourceInfo, MCPResourceTemplateInfo } from '$lib/types';
|
||||
import { getResourceDisplayName } from '$lib/utils';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
@@ -252,8 +252,11 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root onOpenChange={handleOpenChange} {open}>
|
||||
<Dialog.Content class="max-h-[80vh] md:max-w-4xl! w-full! overflow-hidden p-0">
|
||||
<Dialog.Header class="border-b border-border/30 px-6 py-4">
|
||||
<Dialog.Content
|
||||
class="max-h-[80vh] md:max-w-4xl! w-full! overflow-hidden p-0"
|
||||
onOpenAutoFocus={(event) => deviceStore.isMobile && event.preventDefault()}
|
||||
>
|
||||
<Dialog.Header class="border-b border-border/30 px-4 py-3" showCloseButton={false}>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<FolderOpen class="h-5 w-5" />
|
||||
|
||||
@@ -267,6 +270,15 @@
|
||||
<Dialog.Description>
|
||||
Browse and attach resources from connected MCP servers to your chat context.
|
||||
</Dialog.Description>
|
||||
|
||||
<!-- the body is flush with the dialog, so the corner close needs its own inset -->
|
||||
<Dialog.Close
|
||||
class="absolute top-0 right-0 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden max-md:top-1 max-md:right-1 {PANEL_CLOSE_MOBILE_CLASS}"
|
||||
>
|
||||
<X class="size-4" />
|
||||
|
||||
<span class="sr-only">Close</span>
|
||||
</Dialog.Close>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex h-[500px] min-w-0">
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
RECOMMENDED_MCP_SERVERS
|
||||
} from '$lib/constants';
|
||||
import { BooleanString, HealthCheckStatus } from '$lib/enums';
|
||||
import { mcpStore } from '$lib/stores';
|
||||
import { deviceStore, mcpStore } from '$lib/stores';
|
||||
import { canonicalizeServerUrl, parseHeadersToArray, uuid } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
@@ -244,7 +244,10 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root onOpenChange={handleOpenChange} {open}>
|
||||
<Dialog.Content class="max-w-2xl!">
|
||||
<Dialog.Content
|
||||
class="max-w-2xl! p-4"
|
||||
onOpenAutoFocus={(event) => deviceStore.isMobile && event.preventDefault()}
|
||||
>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="select-none">Add New MCP Server</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { McpLogo } from '$lib/components/app';
|
||||
import { SettingsMcpServers } from '$lib/components/app/settings';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { deviceStore } from '$lib/stores';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
@@ -18,7 +19,8 @@
|
||||
|
||||
<Dialog.Root onOpenChange={handleOpenChange} {open}>
|
||||
<Dialog.Content
|
||||
class="md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-360! flex flex-col"
|
||||
class="max-md:h-[100dvh]! max-md:w-screen! max-md:max-w-none! max-md:rounded-none! max-md:border-0! md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-360! flex flex-col p-4"
|
||||
onOpenAutoFocus={(event) => deviceStore.isMobile && event.preventDefault()}
|
||||
>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
|
||||
@@ -1,369 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { ActionIconCopyToClipboard, BadgesModality } from '$lib/components/app';
|
||||
import ModelsManagerModelConfigurationInformation from '$lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfigurationInformation.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { modelsStore, serverStore } from '$lib/stores';
|
||||
import type { ApiLlamaCppServerProps } from '$lib/types';
|
||||
import { formatFileSize, formatNumber, formatParameters } from '$lib/utils';
|
||||
import { MODEL_ICON } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { modelsStore, uiStore } from '$lib/stores';
|
||||
import type { HfModelDetailInfo } from '$lib/types/huggingface';
|
||||
import { repoOf } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
// when set, fetch props from the child process (router mode)
|
||||
modelId?: string | null;
|
||||
}
|
||||
// the props cache is a plain Map, so the read has to name its version to stay reactive
|
||||
let serverProps = $derived.by(() => {
|
||||
void modelsStore.props.cacheVersion;
|
||||
|
||||
let { modelId = null, onOpenChange, open = $bindable() }: Props = $props();
|
||||
|
||||
let isRouter = $derived(serverStore.isRouterMode);
|
||||
|
||||
// per-model props fetched from the child process
|
||||
let routerModelProps = $state<ApiLlamaCppServerProps | null>(null);
|
||||
let isLoadingRouterProps = $state(false);
|
||||
|
||||
// in router mode use per-model props, otherwise use global props
|
||||
let serverProps = $derived(isRouter && modelId ? routerModelProps : serverStore.props);
|
||||
|
||||
let modelName = $derived(isRouter && modelId ? modelId : modelsStore.singleModelName);
|
||||
let models = $derived(modelsStore.models);
|
||||
let isLoadingModels = $derived(modelsStore.loading);
|
||||
|
||||
// in router mode, find the model option matching modelId
|
||||
// in single mode, use the first model as before
|
||||
let firstModel = $derived.by(() => {
|
||||
if (isRouter && modelId) {
|
||||
return models.find((m) => m.model === modelId) ?? null;
|
||||
}
|
||||
|
||||
return models[0] ?? null;
|
||||
return uiStore.modelInformation
|
||||
? modelsStore.props.getModelProps(uiStore.modelInformation.model)
|
||||
: null;
|
||||
});
|
||||
|
||||
// Get modalities from modelStore using the model ID from the first model
|
||||
let modalities = $derived.by(() => {
|
||||
if (!firstModel?.id) return [];
|
||||
// The server reports the full metadata only once a model is loaded. With the
|
||||
// Hub enabled, it fills those gaps instead.
|
||||
let hubDetails = $state<HfModelDetailInfo | null>(null);
|
||||
|
||||
return modelsStore.props.getModelModalitiesArray(firstModel.id);
|
||||
});
|
||||
|
||||
// Ensure models are fetched when dialog opens
|
||||
$effect(() => {
|
||||
if (open && models.length === 0) {
|
||||
modelsStore.fetch();
|
||||
}
|
||||
});
|
||||
const option = uiStore.modelInformation;
|
||||
|
||||
// fetch per-model props from child process when dialog opens in router mode
|
||||
$effect(() => {
|
||||
if (open && isRouter && modelId) {
|
||||
isLoadingRouterProps = true;
|
||||
modelsStore.props
|
||||
.fetchModelProps(modelId)
|
||||
.then((props) => {
|
||||
routerModelProps = props;
|
||||
})
|
||||
.catch(() => {
|
||||
routerModelProps = null;
|
||||
})
|
||||
.finally(() => {
|
||||
isLoadingRouterProps = false;
|
||||
});
|
||||
if (!option) return;
|
||||
|
||||
const repo = repoOf(option.model);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
hubDetails = null;
|
||||
|
||||
if (!HuggingFaceService.isEnabled() || !repo.includes('/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
routerModelProps = null;
|
||||
}
|
||||
void HuggingFaceService.getDetails(repo)
|
||||
.then((details) => {
|
||||
if (!cancelled) hubDetails = details;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open {onOpenChange}>
|
||||
<Dialog.Root
|
||||
onOpenChange={(open) => {
|
||||
if (!open) uiStore.modelInformation = null;
|
||||
}}
|
||||
open={uiStore.modelInformation !== null}
|
||||
>
|
||||
<Dialog.Content
|
||||
class="z-9999 max-md:h-[100dvh]! max-md:w-screen! max-md:max-w-none! md:w-[calc(100vw-4rem)]! md:max-w-[60rem]! md:max-h-[80dvh]!"
|
||||
class="z-[70] flex max-md:h-[100dvh]! max-md:w-screen! max-md:max-w-none! max-md:rounded-none! max-md:border-0! md:h-[calc(100vh-4rem)]! md:w-[calc(100vw-4rem)]! md:max-w-md! flex-col"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
overlayClass="z-[70]"
|
||||
>
|
||||
<!-- sticky header holds only the close button; the title scrolls with the body -->
|
||||
<Dialog.Header />
|
||||
<Dialog.Header class="flex flex-row items-center pr-8">
|
||||
<Dialog.Title class="flex min-w-0 items-center gap-2 text-base">
|
||||
<MODEL_ICON class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
<div class="min-w-0 space-y-6 md:py-4 -mt-4! md:mt-0 pb-4">
|
||||
<div class="min-w-0 space-y-2">
|
||||
<Dialog.Title>Model Information</Dialog.Title>
|
||||
|
||||
<Dialog.Description>Current model details and capabilities</Dialog.Description>
|
||||
</div>
|
||||
|
||||
{#if isLoadingModels || isLoadingRouterProps}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="text-sm text-muted-foreground">Loading model information...</div>
|
||||
</div>
|
||||
{:else if firstModel}
|
||||
{@const modelMeta = firstModel.meta}
|
||||
|
||||
{#if serverProps}
|
||||
<!-- Desktop: fixed-layout table, long values scroll inside their cell -->
|
||||
<Table.Root class="hidden table-fixed md:table">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[10rem]">Model</Table.Head>
|
||||
|
||||
<Table.Head>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap">
|
||||
{modelName}
|
||||
</span>
|
||||
|
||||
<ActionIconCopyToClipboard
|
||||
ariaLabel="Copy model name to clipboard"
|
||||
canCopy={!!modelName}
|
||||
text={modelName || ''}
|
||||
/>
|
||||
</div>
|
||||
</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
<!-- Model Path -->
|
||||
<Table.Row>
|
||||
<Table.Cell class="h-10 align-middle font-medium">File Path</Table.Cell>
|
||||
|
||||
<Table.Cell class="h-10 align-middle font-mono text-xs">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap">
|
||||
{serverProps.model_path}
|
||||
</span>
|
||||
|
||||
<ActionIconCopyToClipboard
|
||||
ariaLabel="Copy model path to clipboard"
|
||||
text={serverProps.model_path}
|
||||
/>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
|
||||
<!-- Context Size -->
|
||||
{#if serverProps?.default_generation_settings?.n_ctx}
|
||||
<Table.Row>
|
||||
<Table.Cell class="h-10 align-middle font-medium">Context Size</Table.Cell>
|
||||
|
||||
<Table.Cell
|
||||
>{formatNumber(serverProps.default_generation_settings.n_ctx)} tokens</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell class="h-10 align-middle font-medium text-red-500"
|
||||
>Context Size</Table.Cell
|
||||
>
|
||||
|
||||
<Table.Cell class="text-red-500">Not available</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Training Context -->
|
||||
{#if modelMeta?.n_ctx_train}
|
||||
<Table.Row>
|
||||
<Table.Cell class="h-10 align-middle font-medium">Training Context</Table.Cell>
|
||||
|
||||
<Table.Cell>{formatNumber(modelMeta.n_ctx_train)} tokens</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Model Size -->
|
||||
{#if modelMeta?.size}
|
||||
<Table.Row>
|
||||
<Table.Cell class="h-10 align-middle font-medium">Model Size</Table.Cell>
|
||||
|
||||
<Table.Cell>{formatFileSize(modelMeta.size)}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Parameters -->
|
||||
{#if modelMeta?.n_params}
|
||||
<Table.Row>
|
||||
<Table.Cell class="h-10 align-middle font-medium">Parameters</Table.Cell>
|
||||
|
||||
<Table.Cell>{formatParameters(modelMeta.n_params)}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Embedding Size -->
|
||||
{#if modelMeta?.n_embd}
|
||||
<Table.Row>
|
||||
<Table.Cell class="align-middle font-medium">Embedding Size</Table.Cell>
|
||||
|
||||
<Table.Cell>{formatNumber(modelMeta.n_embd)}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Vocabulary Size -->
|
||||
{#if modelMeta?.n_vocab}
|
||||
<Table.Row>
|
||||
<Table.Cell class="align-middle font-medium">Vocabulary Size</Table.Cell>
|
||||
|
||||
<Table.Cell>{formatNumber(modelMeta.n_vocab)} tokens</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Vocabulary Type -->
|
||||
{#if modelMeta?.vocab_type}
|
||||
<Table.Row>
|
||||
<Table.Cell class="align-middle font-medium">Vocabulary Type</Table.Cell>
|
||||
|
||||
<Table.Cell class="align-middle capitalize">{modelMeta.vocab_type}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Total Slots -->
|
||||
<Table.Row>
|
||||
<Table.Cell class="align-middle font-medium">Parallel Slots</Table.Cell>
|
||||
|
||||
<Table.Cell>{serverProps.total_slots}</Table.Cell>
|
||||
</Table.Row>
|
||||
|
||||
<!-- Modalities -->
|
||||
{#if modalities.length > 0}
|
||||
<Table.Row>
|
||||
<Table.Cell class="align-middle font-medium">Modalities</Table.Cell>
|
||||
|
||||
<Table.Cell>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<BadgesModality {modalities} />
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Build Info -->
|
||||
<Table.Row>
|
||||
<Table.Cell class="align-middle font-medium">Build Info</Table.Cell>
|
||||
|
||||
<Table.Cell class="align-middle font-mono text-xs"
|
||||
>{serverProps.build_info}</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
|
||||
<!-- Chat Template -->
|
||||
{#if serverProps.chat_template}
|
||||
<Table.Row>
|
||||
<Table.Cell class="py-4" colspan={2}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-medium">Chat Template</span>
|
||||
|
||||
<div class="overflow-x-auto rounded-md bg-muted p-4">
|
||||
<pre
|
||||
class="font-mono text-xs whitespace-pre">{serverProps.chat_template}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
<!-- Mobile: stacked layout; long values wrap instead of scrolling the page -->
|
||||
<div class="flex min-w-0 flex-col gap-4 md:hidden">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="text-xs font-medium text-muted-foreground">Model</div>
|
||||
|
||||
<div class="flex min-w-0 items-start gap-2">
|
||||
<span class="min-w-0 flex-1 break-all font-mono text-xs">{modelName}</span>
|
||||
|
||||
<ActionIconCopyToClipboard
|
||||
ariaLabel="Copy model name to clipboard"
|
||||
canCopy={!!modelName}
|
||||
text={modelName || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="text-xs font-medium text-muted-foreground">File Path</div>
|
||||
|
||||
<div class="flex min-w-0 items-start gap-2">
|
||||
<span class="min-w-0 flex-1 break-all font-mono text-xs"
|
||||
>{serverProps.model_path}</span
|
||||
>
|
||||
|
||||
<ActionIconCopyToClipboard
|
||||
ariaLabel="Copy model path to clipboard"
|
||||
text={serverProps.model_path}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if serverProps?.default_generation_settings?.n_ctx}
|
||||
{@render infoRow(
|
||||
'Context Size',
|
||||
`${formatNumber(serverProps.default_generation_settings.n_ctx)} tokens`
|
||||
)}
|
||||
{:else}
|
||||
{@render infoRow('Context Size', 'Not available', 'text-red-500')}
|
||||
{/if}
|
||||
|
||||
{#if modelMeta?.n_ctx_train}
|
||||
{@render infoRow('Training Context', `${formatNumber(modelMeta.n_ctx_train)} tokens`)}
|
||||
{/if}
|
||||
|
||||
{#if modelMeta?.size}
|
||||
{@render infoRow('Model Size', formatFileSize(modelMeta.size))}
|
||||
{/if}
|
||||
|
||||
{#if modelMeta?.n_params}
|
||||
{@render infoRow('Parameters', formatParameters(modelMeta.n_params))}
|
||||
{/if}
|
||||
|
||||
{#if modelMeta?.n_embd}
|
||||
{@render infoRow('Embedding Size', formatNumber(modelMeta.n_embd))}
|
||||
{/if}
|
||||
|
||||
{#if modelMeta?.n_vocab}
|
||||
{@render infoRow('Vocabulary Size', `${formatNumber(modelMeta.n_vocab)} tokens`)}
|
||||
{/if}
|
||||
|
||||
{#if modelMeta?.vocab_type}
|
||||
{@render infoRow('Vocabulary Type', modelMeta.vocab_type, 'capitalize')}
|
||||
{/if}
|
||||
|
||||
{@render infoRow('Parallel Slots', `${serverProps.total_slots}`)}
|
||||
|
||||
{#if modalities.length > 0}
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="text-xs font-medium text-muted-foreground">Modalities</div>
|
||||
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<BadgesModality {modalities} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="text-xs font-medium text-muted-foreground">Build Info</div>
|
||||
|
||||
<span class="block break-all font-mono text-xs">{serverProps.build_info}</span>
|
||||
</div>
|
||||
|
||||
{#if serverProps.chat_template}
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div class="text-xs font-medium text-muted-foreground">Chat Template</div>
|
||||
|
||||
<div class="overflow-x-auto rounded-md bg-muted p-4">
|
||||
<pre class="font-mono text-xs whitespace-pre">{serverProps.chat_template}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if uiStore.modelInformation}
|
||||
<span class="min-w-0 truncate">{uiStore.modelInformation.model}</span>
|
||||
{/if}
|
||||
{:else if !isLoadingModels}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="text-sm text-muted-foreground">No model information available</div>
|
||||
</div>
|
||||
</Dialog.Title>
|
||||
|
||||
<Dialog.Description class="sr-only">
|
||||
Details of the model, from the server or the Hub.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
{#if uiStore.modelInformation}
|
||||
<ModelsManagerModelConfigurationInformation
|
||||
hub={hubDetails}
|
||||
option={uiStore.modelInformation}
|
||||
{serverProps}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
{#snippet infoRow(label: string, value: string, valueClass: string = '')}
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="shrink-0 text-xs font-medium text-muted-foreground {valueClass}">{label}</span>
|
||||
|
||||
<span class="text-sm {valueClass}">{value}</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { Settings } from '@lucide/svelte';
|
||||
import { Settings, X } from '@lucide/svelte';
|
||||
import { SettingsChat } from '$lib/components/app/settings';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { PANEL_CLOSE_MOBILE_CLASS } from '$lib/constants';
|
||||
import { deviceStore } from '$lib/stores';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
@@ -19,14 +21,24 @@
|
||||
|
||||
<Dialog.Root onOpenChange={handleOpenChange} {open}>
|
||||
<Dialog.Content
|
||||
class="md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-6xl! flex flex-col p-0 md:p-6 gap-0"
|
||||
class="max-md:h-[100dvh]! max-md:w-screen! max-md:max-w-none! max-md:rounded-none! max-md:border-0! md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-6xl! flex flex-col p-0 md:p-6 gap-0"
|
||||
onOpenAutoFocus={(event) => deviceStore.isMobile && event.preventDefault()}
|
||||
>
|
||||
<Dialog.Header class="md:p-0 p-4">
|
||||
<Dialog.Header class="md:p-0 p-4" showCloseButton={false}>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Settings class="h-5 w-5" />
|
||||
|
||||
<span>Settings</span>
|
||||
</Dialog.Title>
|
||||
|
||||
<!-- the body is flush with the dialog, so the corner close needs its own inset -->
|
||||
<Dialog.Close
|
||||
class="absolute top-0 right-0 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden max-md:top-1 max-md:right-1 {PANEL_CLOSE_MOBILE_CLASS}"
|
||||
>
|
||||
<X class="size-4" />
|
||||
|
||||
<span class="sr-only">Close</span>
|
||||
</Dialog.Close>
|
||||
</Dialog.Header>
|
||||
|
||||
<SettingsChat {initialSection} onClose={() => (open = false)} onSectionChange={() => {}} />
|
||||
|
||||
@@ -411,45 +411,7 @@ export { default as DialogConversationSelection } from './DialogConversationSele
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **DialogModelInformation** - Model details display
|
||||
*
|
||||
* Dialog showing comprehensive information about the currently loaded model
|
||||
* and server configuration. Displays model metadata, capabilities, and
|
||||
* server settings in a structured table format.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Uses ShadCN Dialog with wide layout for table display
|
||||
* - Fetches data from serverStore (props) and modelsStore (metadata)
|
||||
* - Auto-fetches models when dialog opens if not loaded
|
||||
*
|
||||
* **Information Displayed:**
|
||||
* - **Model**: Name with copy button
|
||||
* - **File Path**: Full path to model file with copy button
|
||||
* - **Context Size**: Current context window size
|
||||
* - **Training Context**: Original training context (if available)
|
||||
* - **Model Size**: File size in human-readable format
|
||||
* - **Parameters**: Parameter count (e.g., "7B", "70B")
|
||||
* - **Embedding Size**: Embedding dimension
|
||||
* - **Vocabulary Size**: Token vocabulary size
|
||||
* - **Vocabulary Type**: Tokenizer type (BPE, etc.)
|
||||
* - **Parallel Slots**: Number of concurrent request slots
|
||||
* - **Modalities**: Supported input types (text, vision, audio)
|
||||
* - **Build Info**: Server build information
|
||||
* - **Chat Template**: Full Jinja template in scrollable code block
|
||||
*
|
||||
* **Features:**
|
||||
* - Copy buttons for model name and path
|
||||
* - Modality badges with icons
|
||||
* - Responsive table layout with container queries
|
||||
* - Loading state while fetching model info
|
||||
* - Scrollable chat template display
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <DialogModelInformation bind:open={showModelInfo} />
|
||||
* ```
|
||||
*/
|
||||
/** **DialogModelInformation** - model details a phone opens instead of the manager pane. */
|
||||
export { default as DialogModelInformation } from './DialogModelInformation.svelte';
|
||||
|
||||
/**
|
||||
@@ -537,3 +499,6 @@ export { default as DialogMcpResourcePreview } from './DialogMcpResourcePreview.
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogMermaidPreview } from './DialogMermaidPreview.svelte';
|
||||
|
||||
/** **DialogManageModels** - shell for the models manager. */
|
||||
export { default as DialogManageModels } from './DialogManageModels.svelte';
|
||||
@@ -11,6 +11,8 @@
|
||||
onClose?: () => void;
|
||||
onKeyDown?: (event: KeyboardEvent) => void;
|
||||
class?: string;
|
||||
/** Input height, matching the small size of the other controls. */
|
||||
size?: 'sm' | 'default';
|
||||
id?: string;
|
||||
ref?: HTMLInputElement | null;
|
||||
isCancelAlwaysVisible?: boolean;
|
||||
@@ -26,6 +28,7 @@
|
||||
onKeyDown,
|
||||
placeholder = 'Search...',
|
||||
ref = $bindable(null),
|
||||
size = 'default',
|
||||
value = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
@@ -58,7 +61,7 @@
|
||||
bind:ref
|
||||
bind:value
|
||||
{autofocus}
|
||||
class="pl-9 {showClearButton ? 'pr-9' : ''}"
|
||||
class="max-md:h-10 pl-9 {showClearButton ? 'pr-9' : ''} {size === 'sm' ? 'h-8' : ''}"
|
||||
{id}
|
||||
oninput={handleInput}
|
||||
onkeydown={onKeyDown}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
let { children, open }: Props = $props();
|
||||
|
||||
// rows stay mounted through the collapse transition so it can play
|
||||
const EXPAND_TRANSITION_MS = 200;
|
||||
// the initial state only: the effect below owns it afterwards
|
||||
// svelte-ignore state_referenced_locally
|
||||
let contentMounted = $state(open);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
contentMounted = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => (contentMounted = false), EXPAND_TRANSITION_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="collapsible-region" data-expanded={open}>
|
||||
{#if contentMounted}
|
||||
<div class="collapsible-region-content">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* `interpolate-size` lets `height: auto` take part in the transition, so the content
|
||||
needs no measured height. Older engines fall back to an interpolating grid row. */
|
||||
.collapsible-region {
|
||||
height: 0;
|
||||
/* clip, not hidden: hidden would make the region a scrollport, and the
|
||||
sticky rows inside it would then never leave their opening position */
|
||||
overflow: clip;
|
||||
visibility: hidden;
|
||||
interpolate-size: allow-keywords;
|
||||
transition:
|
||||
height 200ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
visibility 200ms;
|
||||
}
|
||||
|
||||
.collapsible-region[data-expanded='true'] {
|
||||
height: auto;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@supports not (interpolate-size: allow-keywords) {
|
||||
.collapsible-region {
|
||||
display: grid;
|
||||
/* minmax(0, 1fr) pins the column to the region width: a plain auto
|
||||
column would size to the content and push wide rows out of the list */
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: 0fr;
|
||||
/* the row owns the height here: leaving height: 0 in place would snap
|
||||
the region shut before the row could interpolate */
|
||||
height: auto;
|
||||
transition:
|
||||
grid-template-rows 200ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
visibility 200ms;
|
||||
}
|
||||
|
||||
.collapsible-region[data-expanded='true'] {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.collapsible-region-content {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: clip;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import CollapsibleRegion from './CollapsibleRegion.svelte';
|
||||
import { ChevronDown, ChevronUp } from '@lucide/svelte';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
/** Trigger content; the chevron is appended. */
|
||||
trigger: Snippet;
|
||||
/** Render the trigger as a dropdown menu item, so menu keyboard navigation reaches it. */
|
||||
inMenu?: boolean;
|
||||
/** Start expanded. The section owns the state, so a parent rebuild cannot snap
|
||||
* it back open; bind `open` to control it from outside. */
|
||||
defaultOpen?: boolean;
|
||||
/** Controlled open state, for a caller that binds it. */
|
||||
open?: boolean;
|
||||
/** Called on every toggle, for a caller that persists the state. */
|
||||
ontoggle?: ((open: boolean) => void) | null;
|
||||
/** Extra classes for the chevron, to line it up with a row's own control. */
|
||||
chevronClass?: string;
|
||||
/** Hide the chevron while expanded, until the trigger is hovered. */
|
||||
revealChevronOnHover?: boolean;
|
||||
/** Where the trigger sits relative to the content. */
|
||||
triggerPosition?: 'bottom' | 'top';
|
||||
triggerClass?: string;
|
||||
triggerStyle?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
chevronClass = '',
|
||||
children,
|
||||
defaultOpen = true,
|
||||
inMenu = false,
|
||||
ontoggle = null,
|
||||
open = $bindable(defaultOpen),
|
||||
revealChevronOnHover = false,
|
||||
trigger,
|
||||
triggerClass = '',
|
||||
triggerPosition = 'top',
|
||||
triggerStyle = ''
|
||||
}: Props = $props();
|
||||
|
||||
function toggle(): void {
|
||||
open = !open;
|
||||
ontoggle?.(open);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet chevron()}
|
||||
<span
|
||||
class="ml-auto shrink-0 text-muted-foreground {chevronClass} {open && revealChevronOnHover
|
||||
? 'opacity-0 group-hover:opacity-100'
|
||||
: ''}"
|
||||
>
|
||||
{#if open}
|
||||
<ChevronUp class={ICON_CLASS_DEFAULT} />
|
||||
{:else}
|
||||
<ChevronDown class={ICON_CLASS_DEFAULT} />
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet triggerButton()}
|
||||
{#if inMenu}
|
||||
<!-- A menu item (for keyboard nav) wrapping the trigger via `child`;
|
||||
closeOnSelect keeps the menu open while the section toggles. -->
|
||||
<DropdownMenu.Item
|
||||
class="group w-full min-w-0 cursor-pointer items-center gap-2 rounded-md text-left text-sm"
|
||||
closeOnSelect={false}
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<!-- No `class` here: a static attribute would override the spread props.class. -->
|
||||
<button {...props} aria-expanded={open} onclick={toggle} style={triggerStyle} type="button">
|
||||
{@render trigger()}
|
||||
|
||||
{@render chevron()}
|
||||
</button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
<button
|
||||
aria-expanded={open}
|
||||
class="group {triggerClass}"
|
||||
onclick={toggle}
|
||||
style={triggerStyle}
|
||||
type="button"
|
||||
>
|
||||
{@render trigger()}
|
||||
|
||||
{@render chevron()}
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet region()}
|
||||
<!-- Custom expand region instead of bits-ui Collapsible (whose conditional
|
||||
rendering kills the transition). -->
|
||||
<CollapsibleRegion {open}>{@render children()}</CollapsibleRegion>
|
||||
{/snippet}
|
||||
|
||||
{#if triggerPosition === 'top'}
|
||||
{@render triggerButton()}
|
||||
|
||||
{@render region()}
|
||||
{:else}
|
||||
{@render region()}
|
||||
|
||||
{@render triggerButton()}
|
||||
{/if}
|
||||
@@ -0,0 +1,188 @@
|
||||
<script lang="ts" module>
|
||||
/** One group of a grouped list, rendered under its own heading. */
|
||||
export interface GroupedListGroup<G, E> {
|
||||
entries: E[];
|
||||
group: G;
|
||||
key: string;
|
||||
}
|
||||
|
||||
/** What a show-more row counts: rows of one group, or whole groups of the list. */
|
||||
export const GroupedListUnit = {
|
||||
ENTRIES: 'entries',
|
||||
GROUPS: 'families'
|
||||
} as const;
|
||||
|
||||
export type GroupedListUnit = (typeof GroupedListUnit)[keyof typeof GroupedListUnit];
|
||||
</script>
|
||||
|
||||
<script generics="G, E" lang="ts">
|
||||
import CollapsibleRegion from './CollapsibleRegion.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
/** Heading of one group. Omit it for a list whose rows need no heading. */
|
||||
group?: Snippet<
|
||||
[{ depth: number; expanded: boolean; group: G; key: string; toggle: () => void }]
|
||||
>;
|
||||
/** Groups to render. Omit to render `items` as a flat list. */
|
||||
groups?: GroupedListGroup<G, E>[] | null;
|
||||
/** Entries of one group shown before its show more row. 0 shows every entry. */
|
||||
groupWindow?: number;
|
||||
/** Group ids folded away, read once so a caller can restore what it persisted. */
|
||||
initialCollapsed?: string[];
|
||||
/** One row. Depth is 1 under a group, 0 in a flat list. */
|
||||
item: Snippet<[{ depth: number; entry: E }]>;
|
||||
/** Identity of a row, used for the keyed each. */
|
||||
keyOf: (entry: E) => string;
|
||||
/** The show more row. Without it the rest of a window stays hidden. */
|
||||
more?: Snippet<[{ count: number; onMore: () => void; unit: GroupedListUnit }]>;
|
||||
/** Flat entries, rendered when the list has no groups. */
|
||||
items?: E[];
|
||||
/** Entries of the list shown before its show more row. 0 shows every entry. */
|
||||
sectionWindow?: number;
|
||||
/** Called on every group toggle, for a caller that persists the state. */
|
||||
onCollapsedChange?: ((key: string, collapsed: boolean) => void) | null;
|
||||
/** Sticky class of a group heading, so a surface can shade it differently. */
|
||||
stickyClass?: string;
|
||||
/** Sticky offset of a group heading, e.g. `top: 2.25rem`. Empty keeps it scrolling. */
|
||||
stickyStyle?: string;
|
||||
/** Weight of one entry against a window, e.g. the rows it renders. */
|
||||
weightOf?: (entry: E) => number;
|
||||
}
|
||||
|
||||
let {
|
||||
group,
|
||||
groups = null,
|
||||
groupWindow = 0,
|
||||
initialCollapsed = [],
|
||||
item,
|
||||
items = [],
|
||||
keyOf,
|
||||
more,
|
||||
onCollapsedChange = null,
|
||||
sectionWindow = 0,
|
||||
stickyClass = 'bg-popover',
|
||||
stickyStyle = '',
|
||||
weightOf
|
||||
}: Props = $props();
|
||||
|
||||
// groups start open: this tracks the ones the user folded away, seeded once from
|
||||
// whatever the caller persisted
|
||||
// svelte-ignore state_referenced_locally
|
||||
const collapsed = new SvelteSet<string>(initialCollapsed);
|
||||
// windows grow one step at a time, per group and per list
|
||||
const groupSteps = new SvelteMap<string, number>();
|
||||
let sectionSteps = $state(0);
|
||||
|
||||
function toggleGroup(key: string): void {
|
||||
if (collapsed.has(key)) {
|
||||
collapsed.delete(key);
|
||||
} else {
|
||||
collapsed.add(key);
|
||||
}
|
||||
|
||||
onCollapsedChange?.(key, collapsed.has(key));
|
||||
}
|
||||
|
||||
function growGroup(key: string): void {
|
||||
groupSteps.set(key, (groupSteps.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
let groupCap = $derived((key: string) =>
|
||||
groupWindow > 0 ? groupWindow * (1 + (groupSteps.get(key) ?? 0)) : Infinity
|
||||
);
|
||||
let weight = $derived(weightOf ?? (() => 1));
|
||||
|
||||
/** Groups with their entries cut to the window, and what each cut leaves behind. */
|
||||
let windowed = $derived.by((): Array<{ group: G; hidden: number; key: string; rows: E[] }> => {
|
||||
const shown: Array<{ group: G; hidden: number; key: string; rows: E[] }> = [];
|
||||
const cap = sectionWindow > 0 ? sectionWindow + sectionSteps : Infinity;
|
||||
|
||||
let used = 0;
|
||||
|
||||
for (const entry of groups ?? []) {
|
||||
if (used >= cap) break;
|
||||
|
||||
const rows = entry.entries.slice(0, groupCap(entry.key));
|
||||
|
||||
shown.push({
|
||||
group: entry.group,
|
||||
hidden: entry.entries.length - rows.length,
|
||||
key: entry.key,
|
||||
rows
|
||||
});
|
||||
used += rows.reduce((sum, row) => sum + weight(row), 0);
|
||||
}
|
||||
|
||||
return shown;
|
||||
});
|
||||
|
||||
let flatRows = $derived(sectionWindow > 0 ? items.slice(0, sectionWindow + sectionSteps) : items);
|
||||
|
||||
/** Rows one group weighs against the window. */
|
||||
const groupWeight = (entry: GroupedListGroup<G, E>) =>
|
||||
entry.entries.reduce((sum, row) => sum + weight(row), 0);
|
||||
|
||||
/** The groups one page reveals, and the rows they weigh against the window. */
|
||||
let groupPage = $derived.by(() => {
|
||||
const page = (groups ?? []).slice(windowed.length, windowed.length + sectionWindow);
|
||||
|
||||
return {
|
||||
count: page.length,
|
||||
weight: page.reduce((sum, entry) => sum + groupWeight(entry), 0)
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if groups}
|
||||
{#each windowed as entry (entry.key)}
|
||||
{@const expanded = !collapsed.has(entry.key)}
|
||||
|
||||
{#if group}
|
||||
<div class="sticky z-10 {stickyClass}" style={stickyStyle}>
|
||||
{@render group({
|
||||
depth: 0,
|
||||
expanded,
|
||||
group: entry.group,
|
||||
key: entry.key,
|
||||
toggle: () => toggleGroup(entry.key)
|
||||
})}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<CollapsibleRegion open={expanded}>
|
||||
{#each entry.rows as row (keyOf(row))}
|
||||
{@render item({ depth: 1, entry: row })}
|
||||
{/each}
|
||||
|
||||
{#if entry.hidden > 0 && more}
|
||||
{@render more({
|
||||
count: Math.min(groupWindow, entry.hidden),
|
||||
onMore: () => growGroup(entry.key),
|
||||
unit: GroupedListUnit.ENTRIES
|
||||
})}
|
||||
{/if}
|
||||
</CollapsibleRegion>
|
||||
{/each}
|
||||
|
||||
{#if windowed.length < (groups?.length ?? 0) && more}
|
||||
{@render more({
|
||||
count: groupPage.count,
|
||||
onMore: () => (sectionSteps += Math.max(1, groupPage.weight)),
|
||||
unit: GroupedListUnit.GROUPS
|
||||
})}
|
||||
{/if}
|
||||
{:else}
|
||||
{#each flatRows as row (keyOf(row))}
|
||||
{@render item({ depth: 0, entry: row })}
|
||||
{/each}
|
||||
|
||||
{#if flatRows.length < items.length && more}
|
||||
{@render more({
|
||||
count: Math.min(sectionWindow, items.length - flatRows.length),
|
||||
onMore: () => (sectionSteps += Math.min(sectionWindow, items.length - flatRows.length)),
|
||||
unit: GroupedListUnit.ENTRIES
|
||||
})}
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -59,3 +59,12 @@ export { default as CodeBlockActions } from './CodeBlockActions.svelte';
|
||||
* `class` and `style` props and inherits color via `currentColor`.
|
||||
*/
|
||||
export { default as Logo } from './Logo.svelte';
|
||||
|
||||
/** Section with a trigger that expands in place. */
|
||||
export { default as CollapsibleSection } from './CollapsibleSection.svelte';
|
||||
export {
|
||||
default as GroupedList,
|
||||
type GroupedListGroup,
|
||||
GroupedListUnit
|
||||
} from './GroupedList.svelte';
|
||||
export { default as CollapsibleRegion } from './CollapsibleRegion.svelte';
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import ModelOrgAvatar from './ModelOrgAvatar.svelte';
|
||||
import { HF_BASE_MODEL_TAG_REGEX, SETTINGS_KEYS } from '$lib/constants';
|
||||
import { HuggingFaceService, ModelsService } from '$lib/services';
|
||||
import { settingsStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { nearViewport, orgOf } from '$lib/utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
/** Rendered when the model has neither an org avatar nor a provider mark. */
|
||||
fallback?: Snippet;
|
||||
option: ModelOption;
|
||||
quantPositionClass?: string;
|
||||
quantSize?: string;
|
||||
/** Show the base model's org as the main image, the repo (quantizer) org as the
|
||||
* corner badge. The base org costs one Hub request per repo. Left unset with
|
||||
* {@link showRepoOrgAvatar}, the row follows the family grouping. */
|
||||
showBaseModelAvatar?: boolean;
|
||||
/** Show the repo's own org as the main image, skipping the base model.
|
||||
* Used inside a heading that already carries the base org. */
|
||||
showRepoOrgAvatar?: boolean;
|
||||
/** Keep the quantizer badge off, e.g. when a row stands for a whole family. */
|
||||
showQuantBadge?: boolean;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
fallback,
|
||||
option,
|
||||
quantPositionClass = '-bottom-1 -right-1',
|
||||
quantSize = 'h-3 w-3',
|
||||
showBaseModelAvatar,
|
||||
showQuantBadge = true,
|
||||
showRepoOrgAvatar,
|
||||
size = 'size-5'
|
||||
}: Props = $props();
|
||||
|
||||
let parsedId = $derived(ModelsService.parseModelId(option.model));
|
||||
let orgName = $derived(parsedId.orgName);
|
||||
// A row names neither flag: a family heading already carries the base org, so a
|
||||
// grouped list shows the repo's own org, and an ungrouped one the base org with the
|
||||
// quantizer badge. A caller that names one of the two keeps control of the avatar.
|
||||
let followsGrouping = $derived(
|
||||
showBaseModelAvatar === undefined && showRepoOrgAvatar === undefined
|
||||
);
|
||||
let groupedByFamily = $derived(
|
||||
settingsStore.config[SETTINGS_KEYS.GROUP_MODELS_BY_FAMILY] ?? false
|
||||
);
|
||||
let baseOrgMain = $derived(showBaseModelAvatar ?? (followsGrouping && !groupedByFamily));
|
||||
let repoOrgMain = $derived(showRepoOrgAvatar ?? (followsGrouping && groupedByFamily));
|
||||
// Avatars come from the Hub, so with the metadata setting off they are
|
||||
// hidden entirely and no base-model lookup runs.
|
||||
let hubEnabled = $derived(HuggingFaceService.isEnabled());
|
||||
let tagBaseModel = $derived(
|
||||
(option.tags ?? [])
|
||||
.find((t) => HF_BASE_MODEL_TAG_REGEX.test(t))
|
||||
?.match(HF_BASE_MODEL_TAG_REGEX)?.[1] ?? null
|
||||
);
|
||||
// long lists mount hundreds of avatars at once, so the base org lookup waits
|
||||
// until the row is near the viewport
|
||||
let fetchedBaseModelOrg = $state<string | null>(null);
|
||||
let baseModelOrg = $derived(orgOf(tagBaseModel) || fetchedBaseModelOrg);
|
||||
let isNearViewport = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
fetchedBaseModelOrg = null;
|
||||
|
||||
if (!isNearViewport || !baseOrgMain || !orgName || tagBaseModel) return;
|
||||
|
||||
if (!hubEnabled) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void HuggingFaceService.getBaseModel(option.model)
|
||||
.then((base) => {
|
||||
if (!cancelled && base?.org) fetchedBaseModelOrg = base.org;
|
||||
})
|
||||
// best-effort lookup: offline or unknown repos keep the repo org
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if orgName && hubEnabled}
|
||||
<span
|
||||
use:nearViewport={() => (isNearViewport = true)}
|
||||
class={['inline-flex shrink-0', className]}
|
||||
>
|
||||
<ModelOrgAvatar
|
||||
class="mt-0"
|
||||
org={repoOrgMain ? orgName : (baseModelOrg ?? orgName)}
|
||||
quantOrg={baseOrgMain && showQuantBadge && !repoOrgMain ? orgName : undefined}
|
||||
{quantPositionClass}
|
||||
{quantSize}
|
||||
{size}
|
||||
/>
|
||||
</span>
|
||||
{:else}
|
||||
{@render fallback?.()}
|
||||
{/if}
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import ModelId from './ModelId.svelte';
|
||||
import { Package } from '@lucide/svelte';
|
||||
import { ActionIconCopyToClipboard, BadgeInfo } from '$lib/components/app';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { MODEL_ICON } from '$lib/constants';
|
||||
import { modelsStore, serverStore } from '$lib/stores';
|
||||
|
||||
interface Props {
|
||||
@@ -29,7 +29,7 @@
|
||||
{#snippet badgeContent(triggerProps?: Record<string, unknown>)}
|
||||
<BadgeInfo {...triggerProps ?? {}} class={className} {onclick}>
|
||||
{#snippet icon()}
|
||||
<Package class="h-3 w-3" />
|
||||
<MODEL_ICON class="h-3 w-3" />
|
||||
{/snippet}
|
||||
|
||||
{#if model}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import ModelCapabilityIcons from './ModelCapabilityIcons.svelte';
|
||||
import { MODEL_ID } from '$lib/constants';
|
||||
import { ModelCapability } from '$lib/enums';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { detectThinkingSupport, detectToolUseSupport, nearViewport, repoOf } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
/** Hide the modality icons, keeping the capability ones. */
|
||||
hideModalities?: boolean;
|
||||
option: ModelOption;
|
||||
}
|
||||
|
||||
let { class: className = '', hideModalities = false, option }: Props = $props();
|
||||
|
||||
// a listing that declares its capabilities (Ollama-compatible backends) needs no lookup
|
||||
let declared = $derived(option.capabilities);
|
||||
let isNearViewport = $state(false);
|
||||
let template = $state('');
|
||||
|
||||
$effect(() => {
|
||||
template = '';
|
||||
|
||||
if (!isNearViewport) return;
|
||||
|
||||
// only the chat template says whether tools and reasoning work, and the Hub
|
||||
// carries it for a local GGUF whether the model is loaded or not
|
||||
const repo = repoOf(option.model);
|
||||
|
||||
if (!repo.includes(MODEL_ID.ORG_SEPARATOR)) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void HuggingFaceService.getDetails(repo)
|
||||
.then((details) => {
|
||||
if (!cancelled) template = details?.gguf?.chat_template ?? '';
|
||||
})
|
||||
// best-effort: offline or a repo we cannot read leaves the icons off
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
let supportsThinking = $derived(
|
||||
declared.includes(ModelCapability.REASONING) || detectThinkingSupport(template)
|
||||
);
|
||||
let supportsToolUse = $derived(
|
||||
declared.includes(ModelCapability.TOOL_USE) || detectToolUseSupport(template)
|
||||
);
|
||||
</script>
|
||||
|
||||
<span use:nearViewport={() => (isNearViewport = true)} class={className}>
|
||||
<ModelCapabilityIcons
|
||||
{hideModalities}
|
||||
modalities={option.modalities}
|
||||
{supportsThinking}
|
||||
{supportsToolUse}
|
||||
/>
|
||||
</span>
|
||||
@@ -1,6 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { Image, Lightbulb, Mic, Video, Wrench } from '@lucide/svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import {
|
||||
CAPABILITY_ICONS,
|
||||
CAPABILITY_LABELS,
|
||||
MODALITY_FLAG_KEYS,
|
||||
MODALITY_ICONS,
|
||||
MODALITY_LABELS,
|
||||
MODALITY_ORDER
|
||||
} from '$lib/constants';
|
||||
import { ModelCapability } from '$lib/enums';
|
||||
import type { ModelModalities } from '$lib/types/models';
|
||||
|
||||
interface Props {
|
||||
@@ -23,71 +31,57 @@
|
||||
supportsToolUse = false
|
||||
}: Props = $props();
|
||||
|
||||
let hasModalityIcons = $derived(modalities?.vision || modalities?.video || modalities?.audio);
|
||||
let capabilities = $derived([
|
||||
...(supportsToolUse ? [ModelCapability.TOOL_USE] : []),
|
||||
...(supportsThinking ? [ModelCapability.REASONING] : [])
|
||||
]);
|
||||
|
||||
let shownModalities = $derived(
|
||||
MODALITY_ORDER.filter((modality) => modalities?.[MODALITY_FLAG_KEYS[modality]])
|
||||
);
|
||||
|
||||
// an icon-less box still takes the gap where its icons would sit, so a caller with
|
||||
// nothing to show renders no box at all
|
||||
let hasIcons = $derived(
|
||||
(!hideCapabilities && capabilities.length > 0) ||
|
||||
(shownModalities.length > 0 && !hideModalities)
|
||||
);
|
||||
</script>
|
||||
|
||||
<span class="inline-flex items-center {gapClass}">
|
||||
{#if supportsToolUse && !hideCapabilities}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Wrench class="{iconSize} text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
{#if hasIcons}
|
||||
<span class="inline-flex items-center {gapClass}">
|
||||
{#if !hideCapabilities}
|
||||
{#each capabilities as capability (capability)}
|
||||
{@const Icon = CAPABILITY_ICONS[capability]}
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Tool use</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
{#if supportsThinking && !hideCapabilities}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Lightbulb class="{iconSize} text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Reasoning</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
{#if hasModalityIcons && !hideModalities}
|
||||
<span class="inline-flex items-center text-muted-foreground">
|
||||
{#if modalities?.vision}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Image class={iconSize} />
|
||||
<Icon class="{iconSize} text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Vision</p>
|
||||
<p>{CAPABILITY_LABELS[capability]}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if modalities?.video}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Video class={iconSize} />
|
||||
</Tooltip.Trigger>
|
||||
{#if shownModalities.length > 0 && !hideModalities}
|
||||
<span class="inline-flex items-center {gapClass} text-muted-foreground">
|
||||
{#each shownModalities as modality (modality)}
|
||||
{@const Icon = MODALITY_ICONS[modality]}
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Video</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Icon class={iconSize} />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
{#if modalities?.audio}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Mic class={iconSize} />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Audio</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<Tooltip.Content>
|
||||
<p>{MODALITY_LABELS[modality]}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import { MODEL_ID } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { nearViewport, repoOf } from '$lib/utils';
|
||||
import { formatContextLength } from '$lib/utils/formatters';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
/** Context the model is set to run with; paired with the supported one. */
|
||||
configured?: number | null;
|
||||
option: ModelOption;
|
||||
}
|
||||
|
||||
let { class: className = '', configured = null, option }: Props = $props();
|
||||
|
||||
// a listing that reports a context window (OpenRouter, Groq, HF) needs no lookup
|
||||
let reported = $derived(option.contextLength ?? null);
|
||||
let isNearViewport = $state(false);
|
||||
let fetched = $state<number | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
fetched = null;
|
||||
|
||||
if (reported || !isNearViewport) return;
|
||||
|
||||
// a local GGUF carries its trained context in the model metadata
|
||||
const repo = repoOf(option.model);
|
||||
|
||||
// a local file path has no repo to read, and the Hub would answer 404
|
||||
if (!repo.includes(MODEL_ID.ORG_SEPARATOR)) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void HuggingFaceService.getDetails(repo)
|
||||
.then((details) => {
|
||||
if (!cancelled) fetched = details?.gguf?.context_length ?? null;
|
||||
})
|
||||
// best-effort: offline or a repo we cannot read keeps the dash
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
let context = $derived(reported ?? fetched);
|
||||
let values = $derived(
|
||||
[configured, context].filter((value): value is number => typeof value === 'number')
|
||||
);
|
||||
</script>
|
||||
|
||||
<span
|
||||
use:nearViewport={() => (isNearViewport = true)}
|
||||
class={['text-sm text-muted-foreground', className]}
|
||||
>
|
||||
{formatContextLength(values)}
|
||||
</span>
|
||||
+2
-2
@@ -18,14 +18,14 @@
|
||||
{#if overlay}
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-b-sm">
|
||||
<div
|
||||
class="h-full bg-primary transition-[width] duration-200 ease-out"
|
||||
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
|
||||
style="width: {percent}%"
|
||||
></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full bg-primary transition-[width] duration-200 ease-out"
|
||||
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
|
||||
style="width: {percent}%"
|
||||
></div>
|
||||
</div>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { MODEL_VARIANT_BADGE_CLASS } from '$lib/constants';
|
||||
import type { ModelSidecarBadge } from '$lib/types/models';
|
||||
import { isAuxSidecar } from '$lib/utils';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
/** Draft sidecars available for the model, each with its own quant. */
|
||||
draftSidecars?: ModelSidecarBadge[];
|
||||
}
|
||||
|
||||
let { draftSidecars = [] }: Props = $props();
|
||||
|
||||
// the router listing and the --model-draft args both report a model's drafts, so
|
||||
// the same sidecar can arrive twice
|
||||
let badges = $derived.by(() => {
|
||||
const seen = new SvelteSet<string>();
|
||||
|
||||
return draftSidecars.filter((badge) => {
|
||||
if (isAuxSidecar(badge.kind)) return false;
|
||||
|
||||
const key = `${badge.kind}:${badge.quant ?? ''}`;
|
||||
|
||||
if (seen.has(key)) return false;
|
||||
|
||||
seen.add(key);
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#each badges as badge (badge.kind + (badge.quant ?? ''))}
|
||||
<span aria-hidden="true" class="text-muted-foreground">+</span>
|
||||
|
||||
<span
|
||||
class={MODEL_VARIANT_BADGE_CLASS}
|
||||
title="{badge.kind.toUpperCase()} draft model{badge.quant ? ` at ${badge.quant}` : ''}"
|
||||
>
|
||||
{badge.kind}
|
||||
</span>
|
||||
{/each}
|
||||
@@ -1,13 +1,15 @@
|
||||
<script lang="ts">
|
||||
import ModelCapabilityIcons from './ModelCapabilityIcons.svelte';
|
||||
import ModelDraftSidecars from './ModelDraftSidecars.svelte';
|
||||
import { Database, ScrollText } from '@lucide/svelte';
|
||||
import { TruncatedText } from '$lib/components/app';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { type ModelSidecar } from '$lib/constants';
|
||||
import { MODEL_BADGE_CLASS, MODEL_VARIANT_BADGE_CLASS } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import { settingsStore } from '$lib/stores';
|
||||
import type { ModelModalities } from '$lib/types/models';
|
||||
import { type ModelSidecarBadge } from '$lib/types/models';
|
||||
import { isAuxSidecar } from '$lib/utils';
|
||||
import { formatParameters } from '$lib/utils';
|
||||
|
||||
@@ -35,7 +37,10 @@
|
||||
contextLength?: number;
|
||||
/** Min/max GGUF file size (main + draft) across quants; renders a range when set. */
|
||||
sizeRange?: { min: number; max: number } | null;
|
||||
draftSidecars?: ModelSidecar[];
|
||||
/** Draft sidecars available for the model, badged with their own quant. */
|
||||
draftSidecars?: ModelSidecarBadge[];
|
||||
/** Give the id a row of its own, with its badges and icons together under it. */
|
||||
stackId?: boolean;
|
||||
/** Allow badges to wrap onto new lines instead of truncating. */
|
||||
wrap?: boolean;
|
||||
class?: string;
|
||||
@@ -59,6 +64,7 @@
|
||||
showRaw = undefined,
|
||||
showRawTooltip = false,
|
||||
sizeRange,
|
||||
stackId = false,
|
||||
supportsThinking = false,
|
||||
supportsToolUse = false,
|
||||
tags,
|
||||
@@ -67,12 +73,10 @@
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const badgeClass =
|
||||
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono bg-foreground/15 dark:bg-foreground/10 text-foreground [a&]:hover:bg-foreground/25';
|
||||
const badgeClass = MODEL_BADGE_CLASS;
|
||||
const tagBadgeClass =
|
||||
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground';
|
||||
const variantBadgeClass =
|
||||
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md bg-primary px-1.5 py-0 text-[10px] font-mono font-semibold uppercase tracking-wide text-primary-foreground';
|
||||
const variantBadgeClass = MODEL_VARIANT_BADGE_CLASS;
|
||||
|
||||
/** Alias badges beyond this many collapse into a single `+x more` badge. */
|
||||
const MAX_ALIAS_BADGES = 2;
|
||||
@@ -88,7 +92,7 @@
|
||||
|
||||
let uniqueAliases = $derived([...new Set(aliases ?? [])]);
|
||||
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
|
||||
let uniqueDraftSidecars = $derived([...new Set(draftSidecars)].filter((s) => !isAuxSidecar(s)));
|
||||
let uniqueDraftSidecars = $derived(draftSidecars.filter((badge) => !isAuxSidecar(badge.kind)));
|
||||
|
||||
let primaryAlias = $derived(uniqueAliases.length === 1 ? uniqueAliases[0] : null);
|
||||
let displayName = $derived(primaryAlias ?? parsed.modelName ?? modelId);
|
||||
@@ -109,13 +113,19 @@
|
||||
{:else}
|
||||
{#snippet nameAndBadges()}
|
||||
{#if !hideName}
|
||||
<span class="min-w-0 truncate font-medium">
|
||||
<span class="min-w-0 truncate font-medium {stackId ? 'basis-full' : ''}">
|
||||
{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if hasBadges}
|
||||
<span class="inline-flex items-center gap-1 {wrap ? 'flex-wrap' : ''}">
|
||||
<!-- the badges keep their width, so a long id truncates around them instead of
|
||||
a label being cut in half -->
|
||||
<span
|
||||
class="inline-flex min-w-0 items-center gap-1 overflow-hidden {wrap
|
||||
? 'flex-wrap'
|
||||
: 'shrink-0'}"
|
||||
>
|
||||
{#if parsed.sidecar}
|
||||
<span class={variantBadgeClass} title={`${parsed.sidecar.toUpperCase()} draft model`}>
|
||||
{parsed.sidecar}
|
||||
@@ -128,18 +138,14 @@
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#each uniqueDraftSidecars as sidecar (sidecar)}
|
||||
<span class={variantBadgeClass} title={`${sidecar.toUpperCase()} draft model available`}>
|
||||
{sidecar}
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
{#if parsed.quantization && !resolvedHideQuantization}
|
||||
<span class={badgeClass}>
|
||||
{parsed.quantization}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<ModelDraftSidecars {draftSidecars} />
|
||||
|
||||
{#if primaryAlias}
|
||||
{#if primaryAlias !== parsed.modelName}
|
||||
<span class="{badgeClass} max-w-32 truncate" title={parsed.modelName ?? modelId}>
|
||||
@@ -169,14 +175,17 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!-- badges, tags and icons do not shrink, so overflow-hidden bounds the id -->
|
||||
<span
|
||||
class="flex min-w-0 items-center gap-1.5 {wrap ? 'flex-wrap' : ''} {iconsOnNewLine
|
||||
? 'flex-col items-start'
|
||||
: ''} {className}"
|
||||
class="flex min-w-0 items-center gap-1.5 overflow-hidden {wrap
|
||||
? 'flex-wrap'
|
||||
: ''} {iconsOnNewLine ? 'flex-col items-start' : ''} {className}"
|
||||
{title}
|
||||
{...rest}
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-1.5 {wrap ? 'flex-wrap' : ''}">
|
||||
<span
|
||||
class="flex min-w-0 items-center gap-1.5 max-md:gap-0.5 {wrap || stackId ? 'flex-wrap' : ''}"
|
||||
>
|
||||
{#if showRawTooltip}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import { ActionIcon } from '$lib/components/app';
|
||||
import { ICON_CLASS_DEFAULT, MODEL_LOAD_ICONS } from '$lib/constants';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
isFailed?: boolean;
|
||||
isLoaded: boolean;
|
||||
isLoading?: boolean;
|
||||
isSleeping?: boolean;
|
||||
option: ModelOption;
|
||||
/** Table rows keep the load action visible, selector rows reveal it on hover. */
|
||||
revealOnHover?: boolean;
|
||||
/** Renders the state alone, for a caller that moves load and unload elsewhere. */
|
||||
showAction?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
isFailed = false,
|
||||
isLoaded,
|
||||
isLoading = false,
|
||||
isSleeping = false,
|
||||
option,
|
||||
revealOnHover = true,
|
||||
showAction = true
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={['flex w-5 shrink-0 items-center justify-center', className]}>
|
||||
{#if isLoading}
|
||||
<MODEL_LOAD_ICONS.loading class="{ICON_CLASS_DEFAULT} animate-spin text-muted-foreground" />
|
||||
{:else}
|
||||
<!-- the state dot is what the row shows at rest; the action takes its place on hover -->
|
||||
{#if showAction && revealOnHover}
|
||||
{#if isFailed}
|
||||
<MODEL_LOAD_ICONS.failed
|
||||
class="h-3.5 w-3.5 text-red-500 group-hover:hidden [@media(pointer:coarse)]:hidden"
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="h-2 w-2 rounded-full group-hover:hidden [@media(pointer:coarse)]:hidden {isSleeping
|
||||
? 'bg-orange-400'
|
||||
: isLoaded
|
||||
? 'bg-green-500'
|
||||
: 'bg-muted-foreground/50'}"
|
||||
></span>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if isFailed}
|
||||
<MODEL_LOAD_ICONS.failed class="h-3.5 w-3.5 text-red-500" />
|
||||
{:else}
|
||||
<span
|
||||
class="h-2 w-2 rounded-full {isSleeping
|
||||
? 'bg-orange-400'
|
||||
: isLoaded
|
||||
? 'bg-green-500'
|
||||
: 'bg-muted-foreground/50'}"
|
||||
></span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- the wrapper has to pick one display value: flex and hidden together
|
||||
leave the winner to stylesheet order -->
|
||||
<div
|
||||
class={!showAction
|
||||
? 'hidden'
|
||||
: revealOnHover
|
||||
? 'hidden group-hover:flex [@media(pointer:coarse)]:flex'
|
||||
: 'flex'}
|
||||
>
|
||||
{#if isFailed}
|
||||
<ActionIcon
|
||||
class="h-5 w-5 text-red-500 hover:text-foreground"
|
||||
icon={MODEL_LOAD_ICONS.retry}
|
||||
iconSize="h-4 w-4"
|
||||
onclick={() => modelsStore.status.load(option.model)}
|
||||
stopPropagationOnClick
|
||||
tooltip="Retry loading model"
|
||||
tooltipAsTitle
|
||||
/>
|
||||
{:else if isLoaded || isSleeping}
|
||||
<ActionIcon
|
||||
class="h-5 w-5 hover:text-foreground"
|
||||
icon={MODEL_LOAD_ICONS.unload}
|
||||
iconSize="h-4 w-4"
|
||||
onclick={() => modelsStore.status.unload(option.model)}
|
||||
stopPropagationOnClick
|
||||
tooltip="Unload model"
|
||||
tooltipAsTitle
|
||||
/>
|
||||
{:else}
|
||||
<ActionIcon
|
||||
class="h-5 w-5 hover:text-foreground"
|
||||
icon={MODEL_LOAD_ICONS.load}
|
||||
iconSize="h-4 w-4"
|
||||
onclick={() => modelsStore.status.load(option.model)}
|
||||
stopPropagationOnClick
|
||||
tooltip="Load model"
|
||||
tooltipAsTitle
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import { DARK_INVERT_AVATAR_ORGS } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
// Avatars a failed request is cooling down for. A failure hides the image
|
||||
// immediately, but the org gets another chance on a later mount: the cooldown
|
||||
// expires on its own, so a transient network blip is not permanent for the
|
||||
// session. A row remounting inside the cooldown renders the monogram instead
|
||||
// of re-requesting the same failing URL.
|
||||
const AVATAR_RETRY_DELAY_MS = 60_000;
|
||||
const failedAvatarOrgs = new SvelteMap<string, number>();
|
||||
|
||||
/** True while an org's failed avatar is still inside its retry cooldown. */
|
||||
function isAvatarCoolingDown(org: string): boolean {
|
||||
const failedAt = failedAvatarOrgs.get(org);
|
||||
|
||||
// a stale entry reads as not failed and is dropped on the next onerror
|
||||
return failedAt !== undefined && Date.now() - failedAt < AVATAR_RETRY_DELAY_MS;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
org: string;
|
||||
quantOrg?: string;
|
||||
size?: string;
|
||||
baseImageClass?: string;
|
||||
quantImageClass?: string;
|
||||
quantPositionClass?: string;
|
||||
quantSize?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
baseImageClass = '',
|
||||
class: className = '',
|
||||
org,
|
||||
quantImageClass = 'h-full w-full',
|
||||
quantOrg,
|
||||
quantPositionClass = '-bottom-0.75 -right-0.75',
|
||||
quantSize = 'h-4.25 w-4.25',
|
||||
size = 'h-9 w-9'
|
||||
}: Props = $props();
|
||||
|
||||
// With the Hub metadata setting off there is nothing to fetch and nothing to
|
||||
// show: the avatar is hidden entirely instead of falling back to a monogram.
|
||||
let hubEnabled = $derived(HuggingFaceService.isEnabled());
|
||||
let orgAvatarFailed = $derived(isAvatarCoolingDown(org));
|
||||
let quantAvatarFailed = $derived(isAvatarCoolingDown(quantOrg ?? ''));
|
||||
|
||||
let invertAvatar = $derived(DARK_INVERT_AVATAR_ORGS.includes(org));
|
||||
let invertQuant = $derived(DARK_INVERT_AVATAR_ORGS.includes(quantOrg ?? ''));
|
||||
|
||||
// Monogram fallback: org initial on a hue derived from its name, so each org
|
||||
// gets a stable distinct color.
|
||||
let hue = $derived.by(() => {
|
||||
let h = 0;
|
||||
|
||||
for (let i = 0; i < org.length; i++) h = (h * 31 + org.charCodeAt(i)) >>> 0;
|
||||
|
||||
return h % 360;
|
||||
});
|
||||
|
||||
let quantHue = $derived.by(() => {
|
||||
const name = quantOrg ?? '';
|
||||
|
||||
let h = 0;
|
||||
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
|
||||
return h % 360;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if hubEnabled}
|
||||
<span class="relative inline-flex shrink-0 {className}">
|
||||
{#if orgAvatarFailed}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex {size} items-center justify-center rounded-md text-sm font-semibold text-white"
|
||||
style="background-color: hsl({hue} 60% 45%)"
|
||||
>
|
||||
{org.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
{:else}
|
||||
<div class="rounded-md">
|
||||
<!-- the server serves the app under COEP require-corp, so a cross-origin
|
||||
image has to be fetched in CORS mode to be allowed -->
|
||||
<img
|
||||
alt=""
|
||||
class="{size} rounded-md {invertAvatar ? 'dark:invert' : ''} {baseImageClass}"
|
||||
crossorigin="anonymous"
|
||||
loading="lazy"
|
||||
onerror={() => {
|
||||
failedAvatarOrgs.set(org, Date.now());
|
||||
}}
|
||||
src={HuggingFaceService.getAvatarUrl(org)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if quantOrg && quantOrg !== org}
|
||||
<!-- native title instead of a floating tooltip: long model lists mount one badge per row -->
|
||||
<span
|
||||
class="absolute {quantPositionClass} {quantSize} overflow-hidden rounded-full border border-background bg-muted"
|
||||
title={quantOrg}
|
||||
>
|
||||
{#if quantAvatarFailed}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex h-full w-full items-center justify-center rounded-full text-[8px] font-semibold text-white"
|
||||
style="background-color: hsl({quantHue} 60% 45%)"
|
||||
>
|
||||
{quantOrg.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
{:else}
|
||||
<img
|
||||
alt=""
|
||||
class="{quantImageClass} rounded-full {invertQuant ? 'dark:invert' : ''}"
|
||||
crossorigin="anonymous"
|
||||
loading="lazy"
|
||||
onerror={() => {
|
||||
failedAvatarOrgs.set(quantOrg ?? '', Date.now());
|
||||
}}
|
||||
src={HuggingFaceService.getAvatarUrl(quantOrg)}
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import { Heart, HeartOff, Info } from '@lucide/svelte';
|
||||
import { ActionIcon } from '$lib/components/app';
|
||||
import { deviceStore, modelsStore, uiStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
interface Props {
|
||||
isFav: boolean;
|
||||
option: ModelOption;
|
||||
/** Selector rows reveal the actions on hover, table rows keep them visible. */
|
||||
revealOnHover?: boolean;
|
||||
}
|
||||
|
||||
let { isFav, option, revealOnHover = true }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
'flex items-center justify-center gap-1 max-md:gap-2.5',
|
||||
revealOnHover
|
||||
? 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 [@media(pointer:coarse)]:pointer-events-auto [@media(pointer:coarse)]:opacity-100'
|
||||
: ''
|
||||
]}
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
onkeydown={(event) => event.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<ActionIcon
|
||||
class="h-5 w-5 hover:text-foreground"
|
||||
icon={Info}
|
||||
iconSize="h-4 w-4"
|
||||
onclick={() =>
|
||||
// a phone has no manager: its information dialog takes the icon
|
||||
deviceStore.isMobile
|
||||
? uiStore.openModelInformation(option)
|
||||
: uiStore.openModelsManager(option.id)}
|
||||
tooltip="Manage model"
|
||||
tooltipAsTitle
|
||||
/>
|
||||
|
||||
{#if isFav}
|
||||
<span class="flex h-5 w-5 items-center justify-center">
|
||||
<span class="flex group-hover:hidden [@media(pointer:coarse)]:hidden">
|
||||
<ActionIcon
|
||||
class="h-5 w-5 text-rose-500 hover:text-foreground"
|
||||
icon={Heart}
|
||||
iconSize="h-4 w-4"
|
||||
onclick={() => modelsStore.toggleFavorite(option.model)}
|
||||
tooltip="Remove from favorites"
|
||||
tooltipAsTitle
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
|
||||
<ActionIcon
|
||||
class="h-5 w-5 hover:text-foreground"
|
||||
icon={HeartOff}
|
||||
iconSize="h-4 w-4"
|
||||
onclick={() => modelsStore.toggleFavorite(option.model)}
|
||||
tooltip="Remove from favorites"
|
||||
tooltipAsTitle
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
{:else}
|
||||
<ActionIcon
|
||||
class="h-5 w-5 hover:text-foreground"
|
||||
icon={Heart}
|
||||
iconSize="h-4 w-4"
|
||||
onclick={() => modelsStore.toggleFavorite(option.model)}
|
||||
tooltip="Add to favorites"
|
||||
tooltipAsTitle
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,357 @@
|
||||
<script lang="ts">
|
||||
import ModelsManagerModelConfiguration from './ModelsManagerModelConfiguration/ModelsManagerModelConfiguration.svelte';
|
||||
import ModelsManagerModelsTable from './ModelsManagerModelsTable.svelte';
|
||||
import {
|
||||
downloadGroups,
|
||||
groupModelQuants,
|
||||
modelCapability,
|
||||
modelContextLength,
|
||||
type ModelQuantGroup,
|
||||
type ModelsTableGroup,
|
||||
splitHiddenQuants
|
||||
} from './utils';
|
||||
import { LOCAL_BACKEND_ID, type ModalityKey, MODELS_TABLE_GROUP_LABELS } from '$lib/constants';
|
||||
import { ModelCapability, ModelsTableGroupKind } from '$lib/enums';
|
||||
import { conversationsStore, modelsStore, serverStore, uiStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { filterModelOptions } from '$lib/utils';
|
||||
import { type Snippet, untrack } from 'svelte';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
/** Forwarded to the table's toolbar right end. */
|
||||
toolbarEnd?: Snippet;
|
||||
}
|
||||
|
||||
let { class: className, toolbarEnd }: Props = $props();
|
||||
|
||||
let filter = $state('');
|
||||
let contextLimit = $state(0);
|
||||
let modalityFilter = $state<ModalityKey[]>([]);
|
||||
let capabilityFilter = $state<ModelCapability[]>([]);
|
||||
let selectedId = $state<string | null>(null);
|
||||
|
||||
let allModels = $derived(modelsStore.models);
|
||||
|
||||
let selected = $derived(allModels.find((option) => option.id === selectedId) ?? null);
|
||||
// The pane is laid out before it is ever opened, so the first open only slides a
|
||||
// finished panel in. It renders the selection, else the model it last showed.
|
||||
let lastPicked = $state<ModelOption | null>(null);
|
||||
let target = $derived(selected ?? lastPicked ?? allModels[0] ?? null);
|
||||
let shownId = $state<string | null>(null);
|
||||
let isSwapping = $state(false);
|
||||
let fade = $state<'open' | 'swap'>('open');
|
||||
let shownOption = $derived(allModels.find((option) => option.id === shownId) ?? null);
|
||||
|
||||
$effect(() => {
|
||||
const id = selectedId;
|
||||
|
||||
if (!id) return;
|
||||
|
||||
// untracked: the effect must not track the state it writes
|
||||
untrack(() => {
|
||||
lastPicked = allModels.find((option) => option.id === id) ?? null;
|
||||
});
|
||||
});
|
||||
|
||||
/** How long the panel takes to fade out before it swaps to another model. */
|
||||
const SWAP_FADE_MS = 120;
|
||||
|
||||
// Another model fades the panel out, swaps it, then fades it back in.
|
||||
$effect(() => {
|
||||
const next = target?.id ?? null;
|
||||
|
||||
if (!next) return;
|
||||
|
||||
if (shownId === null) {
|
||||
untrack(() => (shownId = next));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (next === shownId) {
|
||||
if (selected) {
|
||||
untrack(() => {
|
||||
isSwapping = false;
|
||||
fade = 'open';
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
isSwapping = true;
|
||||
fade = 'swap';
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
untrack(() => {
|
||||
shownId = next;
|
||||
isSwapping = false;
|
||||
});
|
||||
}, SWAP_FADE_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
let isFavorite = $derived((option: ModelOption) =>
|
||||
modelsStore.favoriteModelIds.has(option.model)
|
||||
);
|
||||
let matching = $derived.by(() => {
|
||||
// the term matches what the selector search matches: name, model, aliases and tags
|
||||
const searched = filterModelOptions(allModels, filter);
|
||||
|
||||
return searched.filter((option) => {
|
||||
// every capability asked for has to be there, but a model whose chat template
|
||||
// has not been read yet stays listed: the row fetches the record as it comes
|
||||
// near the viewport, so hiding it would keep it from ever answering
|
||||
if (
|
||||
capabilityFilter.length > 0 &&
|
||||
!capabilityFilter.every((capability) => modelCapability(option, capability) !== false)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// a model whose modalities are unknown cannot be shown to match
|
||||
if (modalityFilter.length > 0 && !modalityFilter.some((key) => option.modalities?.[key])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (contextLimit === 0) return true;
|
||||
|
||||
// the context of a row arrives with its Hub record, so a model we cannot read
|
||||
// yet stays listed; only a known context below the limit rules it out
|
||||
const context = modelContextLength(option);
|
||||
|
||||
return context === null || context >= contextLimit;
|
||||
});
|
||||
});
|
||||
|
||||
// recently used models lead their section, the rest keep the server's order
|
||||
let rank = $derived.by(() => {
|
||||
const map = new SvelteMap<string, number>();
|
||||
|
||||
modelsStore.recentModelIds.forEach((id, index) => map.set(id, index));
|
||||
|
||||
return map;
|
||||
});
|
||||
|
||||
const rankOf = (entry: ModelQuantGroup) =>
|
||||
Math.min(...entry.quants.map((quant) => rank.get(quant.id) ?? Number.MAX_SAFE_INTEGER));
|
||||
const byRecency = (list: ModelQuantGroup[]) =>
|
||||
rank.size === 0 ? list : [...list].sort((a, b) => rankOf(a) - rankOf(b));
|
||||
// one entry per repo, so a model with several quants takes a single table row
|
||||
let entries = $derived(byRecency(groupModelQuants(matching)));
|
||||
// tracked downloads get their own section, listed like any other model
|
||||
let downloads = $derived(
|
||||
downloadGroups(modelsStore.status.getDownloadEntries(), modelsStore.models)
|
||||
);
|
||||
let groups = $derived.by(() => {
|
||||
// a loaded quant is a model of its own: its repo keeps the quants left behind
|
||||
const isLoaded = (option: ModelOption) => modelsStore.isModelLoaded(option.model);
|
||||
// a tracked download stands in its own section, so it is not listed twice
|
||||
const isDownload = (option: ModelOption) =>
|
||||
modelsStore.status.isDownloadInProgress(option.model) ||
|
||||
modelsStore.status.isDownloadPaused(option.model);
|
||||
const loaded: ModelQuantGroup[] = [];
|
||||
const rest: ModelQuantGroup[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const remaining = entry.quants.filter((quant) => !isLoaded(quant) && !isDownload(quant));
|
||||
|
||||
for (const quant of entry.quants) {
|
||||
if (!isLoaded(quant)) continue;
|
||||
|
||||
loaded.push({ ...entry, base: quant, key: quant.id, quants: [quant] });
|
||||
}
|
||||
|
||||
if (remaining.length > 0) rest.push({ ...entry, base: remaining[0], quants: remaining });
|
||||
}
|
||||
|
||||
const claimed = new SvelteSet<string>();
|
||||
const favorites = rest.filter((entry) =>
|
||||
entry.quants.some((q) => modelsStore.favoriteModelIds.has(q.model))
|
||||
);
|
||||
|
||||
for (const entry of favorites) claimed.add(entry.key);
|
||||
|
||||
const { hidden, local } = splitHiddenQuants(
|
||||
rest.filter((entry) => !claimed.has(entry.key)),
|
||||
(option) => modelsStore.isHidden(option.id)
|
||||
);
|
||||
const ordered: ModelsTableGroup[] = [];
|
||||
// loaded models lead the table, then favorites, then the local block
|
||||
const pushSection = (kind: ModelsTableGroupKind, items: ModelQuantGroup[]): void => {
|
||||
if (items.length === 0) return;
|
||||
|
||||
ordered.push({
|
||||
items,
|
||||
key: kind === ModelsTableGroupKind.LOCAL ? LOCAL_BACKEND_ID : kind,
|
||||
kind,
|
||||
label: MODELS_TABLE_GROUP_LABELS[kind]
|
||||
});
|
||||
};
|
||||
|
||||
pushSection(ModelsTableGroupKind.LOADED, loaded);
|
||||
pushSection(ModelsTableGroupKind.DOWNLOADING, downloads);
|
||||
pushSection(ModelsTableGroupKind.FAVORITES, favorites);
|
||||
pushSection(ModelsTableGroupKind.LOCAL, local);
|
||||
pushSection(ModelsTableGroupKind.HIDDEN, hidden);
|
||||
|
||||
return ordered;
|
||||
});
|
||||
|
||||
// a caller can ask for one model to be revealed, the download rows do
|
||||
$effect(() => {
|
||||
const focus = uiStore.manageModelFocus;
|
||||
|
||||
if (!focus) return;
|
||||
|
||||
const option = allModels.find((model) => model.id === focus || model.model === focus);
|
||||
|
||||
if (option) selectedId = option.id;
|
||||
|
||||
uiStore.manageModelFocus = null;
|
||||
});
|
||||
async function toggleLoad(option: ModelOption): Promise<void> {
|
||||
if (modelsStore.isModelLoaded(option.model)) {
|
||||
await modelsStore.status.unload(option.model);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await modelsStore.status.load(option.model);
|
||||
}
|
||||
|
||||
/** The chat sits behind the dialog, so the dialog closes and the composer takes focus. */
|
||||
function returnToChat(): void {
|
||||
uiStore.manageModelsOpen = false;
|
||||
uiStore.requestComposerFocus();
|
||||
}
|
||||
|
||||
/** Load the model when the server can take load requests, without waiting for it. */
|
||||
function loadInBackground(option: ModelOption): void {
|
||||
// only the built-in server loads on request, and only in router mode
|
||||
if (serverStore.isRouterMode && !modelsStore.isModelLoaded(option.model)) {
|
||||
modelsStore.status
|
||||
.load(option.model)
|
||||
.catch((error) => console.error('Failed to load model:', error));
|
||||
}
|
||||
}
|
||||
|
||||
/** Switch the open chat to this model, the way the desktop model dropdown does. */
|
||||
async function useInChat(option: ModelOption): Promise<void> {
|
||||
// a deliberate pick records the recency; useInNewChat skips it because a new
|
||||
// conversation is where the pick is exercised, not a return to a model the
|
||||
// user went looking for
|
||||
await modelsStore.selectModelById(option.id, { recordRecent: true });
|
||||
|
||||
loadInBackground(option);
|
||||
|
||||
returnToChat();
|
||||
}
|
||||
|
||||
async function useInNewChat(option: ModelOption): Promise<void> {
|
||||
await modelsStore.selectModelById(option.id);
|
||||
await conversationsStore.openNewChat();
|
||||
|
||||
loadInBackground(option);
|
||||
|
||||
returnToChat();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={['relative flex min-h-0 flex-1', className]}>
|
||||
<div class="min-h-0 min-w-0 flex-1">
|
||||
<ModelsManagerModelsTable
|
||||
bind:capabilities={capabilityFilter}
|
||||
bind:contextLimit
|
||||
bind:filter
|
||||
bind:modalities={modalityFilter}
|
||||
{groups}
|
||||
{isFavorite}
|
||||
onSelect={(option) => (selectedId = option.id)}
|
||||
{selectedId}
|
||||
{toolbarEnd}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- on a phone the pane covers the whole dialog: the manager header would only
|
||||
repeat what the pane's own header says -->
|
||||
<div
|
||||
class="pane-drawer max-md:fixed max-md:inset-0 max-md:z-[60] shrink-0"
|
||||
data-open={selected !== null}
|
||||
>
|
||||
<!-- the content box keeps the open width, so it never reflows with the drawer -->
|
||||
<div
|
||||
class="pane-content flex h-full min-h-0 w-[30rem] max-w-[30rem] flex-col border-l border-border/40 max-md:w-full max-md:max-w-none max-md:border-l-0 max-md:bg-background"
|
||||
data-fade={fade}
|
||||
data-visible={selected !== null && !isSwapping}
|
||||
>
|
||||
{#if shownOption}
|
||||
{#key shownId}
|
||||
<ModelsManagerModelConfiguration
|
||||
onClose={() => (selectedId = null)}
|
||||
onToggleLoad={() => void toggleLoad(shownOption)}
|
||||
onUseInChat={() => void useInChat(shownOption)}
|
||||
onUseInNewChat={() => void useInNewChat(shownOption)}
|
||||
option={shownOption}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* The drawer moves by width because the table behind it gets that space back, so
|
||||
* the content box inside holds the open width and only the container changes.
|
||||
* Opening takes the iOS-like drawer curve; closing is the system responding, so
|
||||
* it snaps back on the stronger ease-out.
|
||||
*/
|
||||
.pane-drawer {
|
||||
width: 0;
|
||||
overflow: clip;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
width 120ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
visibility 120ms;
|
||||
}
|
||||
|
||||
.pane-drawer[data-open='true'] {
|
||||
width: 30rem;
|
||||
visibility: visible;
|
||||
transition:
|
||||
width 200ms cubic-bezier(0.32, 0.72, 0, 1),
|
||||
visibility 200ms;
|
||||
}
|
||||
|
||||
.pane-content {
|
||||
opacity: 0;
|
||||
transition: opacity 120ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.pane-content[data-visible='true'] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* opening: the fade waits for the drawer to move */
|
||||
.pane-content[data-visible='true'][data-fade='open'] {
|
||||
transition: opacity 150ms cubic-bezier(0.23, 1, 0.32, 1) 80ms;
|
||||
}
|
||||
|
||||
/* swapping models: out, then in, with no pause */
|
||||
.pane-content[data-visible='true'][data-fade='swap'] {
|
||||
transition: opacity 120ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
/* a phone has no room beside the table: the pane covers it instead */
|
||||
@media (max-width: 767px) {
|
||||
.pane-drawer[data-open='true'] {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { downloadProgressFor } from './utils';
|
||||
import { ActionIcon } from '$lib/components/app';
|
||||
import { MODEL_DOWNLOAD_ICONS } from '$lib/constants';
|
||||
import { ModelRowDownloadState } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
option: ModelOption;
|
||||
/** Download state the row stands for. */
|
||||
state: ModelRowDownloadState;
|
||||
}
|
||||
|
||||
let { class: className = '', option, state }: Props = $props();
|
||||
|
||||
let isPaused = $derived(state === ModelRowDownloadState.PAUSED);
|
||||
let progress = $derived(downloadProgressFor(option.model));
|
||||
let percent = $derived(
|
||||
progress && progress.totalBytes > 0
|
||||
? Math.round((progress.downloadedBytes / progress.totalBytes) * 100)
|
||||
: null
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- the percent is what the row shows at rest; the action takes its place on hover,
|
||||
the way the load control does in the other sections -->
|
||||
<div class={['flex items-center justify-center', className]}>
|
||||
<span
|
||||
class="text-xs text-muted-foreground tabular-nums group-hover:hidden [@media(pointer:coarse)]:hidden"
|
||||
>
|
||||
{percent !== null ? `${percent}%` : isPaused ? 'Paused' : ''}
|
||||
</span>
|
||||
|
||||
<div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
|
||||
<ActionIcon
|
||||
class="h-5 w-5 hover:text-foreground"
|
||||
icon={isPaused ? MODEL_DOWNLOAD_ICONS.resume : MODEL_DOWNLOAD_ICONS.pause}
|
||||
iconSize="h-4 w-4"
|
||||
onclick={() =>
|
||||
void (isPaused
|
||||
? modelsStore.status.downloadModel(option.model)
|
||||
: modelsStore.status.pauseDownload(option.model))}
|
||||
stopPropagationOnClick
|
||||
tooltip={isPaused ? 'Resume downloading' : 'Pause downloading'}
|
||||
tooltipAsTitle
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import { ScrollCarousel } from '$lib/components/app';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import * as ToggleGroup from '$lib/components/ui/toggle-group';
|
||||
import {
|
||||
CAPABILITY_ICONS,
|
||||
CAPABILITY_LABELS,
|
||||
FILTER_TOGGLE_ITEM_CLASS,
|
||||
FILTER_TRIGGER_CLASS,
|
||||
MODALITY_FLAG_KEYS,
|
||||
MODALITY_ICONS,
|
||||
MODALITY_KEYS,
|
||||
MODALITY_LABELS,
|
||||
MODALITY_ORDER,
|
||||
type ModalityKey
|
||||
} from '$lib/constants';
|
||||
import { ModelCapability } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
/** Capabilities a model must have every one of. */
|
||||
capabilities?: ModelCapability[];
|
||||
/** Smallest context a model must support; 0 keeps every model. */
|
||||
contextLimit?: number;
|
||||
/** Modalities a model must support at least one of. */
|
||||
modalities?: ModalityKey[];
|
||||
}
|
||||
|
||||
let {
|
||||
capabilities = $bindable<ModelCapability[]>([]),
|
||||
contextLimit = $bindable(0),
|
||||
modalities = $bindable<ModalityKey[]>([])
|
||||
}: Props = $props();
|
||||
|
||||
const CONTEXT_STEPS: { label: string; value: number }[] = [
|
||||
{ label: 'Any context', value: 0 },
|
||||
{ label: '8K or more', value: 8192 },
|
||||
{ label: '32K or more', value: 32768 },
|
||||
{ label: '128K or more', value: 131_072 },
|
||||
{ label: '256K or more', value: 262_144 },
|
||||
{ label: '1M or more', value: 1_048_576 }
|
||||
];
|
||||
|
||||
const CAPABILITY_TOGGLES = [ModelCapability.TOOL_USE, ModelCapability.REASONING].map((value) => ({
|
||||
icon: CAPABILITY_ICONS[value],
|
||||
label: CAPABILITY_LABELS[value],
|
||||
value
|
||||
}));
|
||||
|
||||
// only the modalities the models at hand actually carry: a toggle for something
|
||||
// none of them supports could only ever empty the table
|
||||
let detectedModalities = $derived.by(() => {
|
||||
const keys = new SvelteSet<ModalityKey>();
|
||||
|
||||
for (const option of modelsStore.models) {
|
||||
for (const key of MODALITY_KEYS) {
|
||||
if (option.modalities?.[key]) keys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
});
|
||||
let modalityToggles = $derived(
|
||||
MODALITY_ORDER.filter((modality) => detectedModalities.has(MODALITY_FLAG_KEYS[modality])).map(
|
||||
(modality) => ({
|
||||
icon: MODALITY_ICONS[modality],
|
||||
label: MODALITY_LABELS[modality],
|
||||
value: MODALITY_FLAG_KEYS[modality]
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// one group holds what a model can do and what it can accept
|
||||
let toggles = $derived([...CAPABILITY_TOGGLES, ...modalityToggles]);
|
||||
const CAPABILITY_VALUES = new Set<string>(CAPABILITY_TOGGLES.map((entry) => entry.value));
|
||||
// every flag value, so a choice made before a model disappeared still round-trips
|
||||
const MODALITY_VALUES = new Set<string>(MODALITY_KEYS);
|
||||
|
||||
// the group holds one flat list, so a change splits back into the two filters
|
||||
function setToggles(values: string[]): void {
|
||||
capabilities = values.filter((value): value is ModelCapability => CAPABILITY_VALUES.has(value));
|
||||
modalities = values.filter((value): value is ModalityKey => MODALITY_VALUES.has(value));
|
||||
}
|
||||
|
||||
let contextLabel = $derived(
|
||||
CONTEXT_STEPS.find((step) => step.value === contextLimit)?.label ?? CONTEXT_STEPS[0].label
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- below md the carousel keeps a row of its own: a toolbar that also holds a call to
|
||||
action would otherwise squeeze the filters out of sight -->
|
||||
<ScrollCarousel
|
||||
alwaysShowArrows
|
||||
class="min-w-0 flex-1 max-md:w-full max-md:flex-none"
|
||||
gapSize="2"
|
||||
innerClass="items-center"
|
||||
>
|
||||
<Select.Root
|
||||
onValueChange={(value) => (contextLimit = Number(value))}
|
||||
type="single"
|
||||
value={String(contextLimit)}
|
||||
>
|
||||
<Select.Trigger class={FILTER_TRIGGER_CLASS} size="sm">
|
||||
<span class="text-muted-foreground">Context:</span>
|
||||
|
||||
{contextLabel}
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Content>
|
||||
{#each CONTEXT_STEPS as step (step.value)}
|
||||
<Select.Item label={step.label} value={String(step.value)}>{step.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
||||
<ToggleGroup.Root
|
||||
class="border border-border/30 bg-muted/60 shadow-sm dark:border-border/20 dark:bg-muted/75"
|
||||
onValueChange={setToggles}
|
||||
type="multiple"
|
||||
value={[...capabilities, ...modalities]}
|
||||
variant="outline"
|
||||
>
|
||||
{#each toggles as toggle (toggle.value)}
|
||||
<ToggleGroup.Item
|
||||
aria-label={toggle.label}
|
||||
class={FILTER_TOGGLE_ITEM_CLASS}
|
||||
title={toggle.label}
|
||||
value={toggle.value}
|
||||
>
|
||||
<toggle.icon class="h-3.5 w-3.5" />
|
||||
</ToggleGroup.Item>
|
||||
{/each}
|
||||
</ToggleGroup.Root>
|
||||
</ScrollCarousel>
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import ModelsManagerModelConfigurationActions from './ModelsManagerModelConfigurationActions.svelte';
|
||||
import ModelsManagerModelConfigurationHeader from './ModelsManagerModelConfigurationHeader.svelte';
|
||||
import ModelsManagerModelConfigurationInformation from './ModelsManagerModelConfigurationInformation.svelte';
|
||||
import { Trash2 } from '@lucide/svelte';
|
||||
import { DialogConfirmDownload } from '$lib/components/app/dialogs';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ModelDownloadConfirmAction, ServerModelStatus } from '$lib/enums';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { deviceStore, modelsStore } from '$lib/stores';
|
||||
import type { HfModelDetailInfo } from '$lib/types/huggingface';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { repoOf } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onToggleLoad: () => void;
|
||||
onUseInChat: () => void;
|
||||
onUseInNewChat: () => void;
|
||||
option: ModelOption;
|
||||
}
|
||||
|
||||
let { onClose, onToggleLoad, onUseInChat, onUseInNewChat, option }: Props = $props();
|
||||
|
||||
// the same removal the table row offers, confirmed by the same dialog
|
||||
let deleteOpen = $state(false);
|
||||
|
||||
// the props cache is a plain Map, so the read has to name its version to stay reactive
|
||||
let serverProps = $derived.by(() => {
|
||||
void modelsStore.props.cacheVersion;
|
||||
|
||||
return modelsStore.props.getModelProps(option.model);
|
||||
});
|
||||
let status = $derived(modelsStore.getModelStatus(option.model));
|
||||
let isLoaded = $derived(modelsStore.isModelRunning(option.model));
|
||||
|
||||
// The server reports the full metadata only once a model is loaded, which loads it.
|
||||
// With the Hub enabled, it fills those gaps instead.
|
||||
let hubDetails = $state<HfModelDetailInfo | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const repo = repoOf(option.model);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
hubDetails = null;
|
||||
|
||||
if (!HuggingFaceService.isEnabled() || !repo.includes('/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
void HuggingFaceService.getDetails(repo)
|
||||
.then((details) => {
|
||||
if (!cancelled) hubDetails = details;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<ModelsManagerModelConfigurationHeader
|
||||
{isLoaded}
|
||||
{onClose}
|
||||
{onToggleLoad}
|
||||
{onUseInChat}
|
||||
{onUseInNewChat}
|
||||
{option}
|
||||
{status}
|
||||
/>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto py-4 pl-4 max-md:pr-4">
|
||||
<ModelsManagerModelConfigurationInformation hub={hubDetails} {option} {serverProps} />
|
||||
|
||||
<div class="mt-4 border-t border-border/30 pt-3 pb-2">
|
||||
<Button
|
||||
class="w-full justify-start gap-2 text-destructive hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/20"
|
||||
onclick={() => (deleteOpen = true)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Delete this model from disk
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- a phone keeps the actions at the bottom of its screen, in reach of the thumb -->
|
||||
{#if deviceStore.isMobile}
|
||||
<div class="shrink-0 border-t border-border/40 px-4 py-3">
|
||||
<ModelsManagerModelConfigurationActions
|
||||
{isLoaded}
|
||||
isLoading={status === ServerModelStatus.LOADING}
|
||||
{onToggleLoad}
|
||||
{onUseInChat}
|
||||
{onUseInNewChat}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<DialogConfirmDownload
|
||||
action={ModelDownloadConfirmAction.DELETE}
|
||||
onClose={() => (deleteOpen = false)}
|
||||
open={deleteOpen}
|
||||
repoWithTag={option.model}
|
||||
/>
|
||||
</div>
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { Eject, Loader2, MessageSquare, Power, SquarePen } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { conversationsStore, deviceStore } from '$lib/stores';
|
||||
|
||||
interface Props {
|
||||
/** A provider that cannot load or unload has no button to offer. */
|
||||
canToggleLoad?: boolean;
|
||||
isLoaded: boolean;
|
||||
isLoading: boolean;
|
||||
onToggleLoad: () => void;
|
||||
onUseInChat: () => void;
|
||||
onUseInNewChat: () => void;
|
||||
/** The header gives the actions room to breathe, the phone bar keeps them small. */
|
||||
size?: 'sm' | 'default';
|
||||
}
|
||||
|
||||
let {
|
||||
canToggleLoad = true,
|
||||
isLoaded,
|
||||
isLoading,
|
||||
onToggleLoad,
|
||||
onUseInChat,
|
||||
onUseInNewChat,
|
||||
size = 'sm'
|
||||
}: Props = $props();
|
||||
|
||||
// a chat with anything in it takes the model, a new chat is not what its user asked for
|
||||
let hasChat = $derived(
|
||||
conversationsStore.activeConversation !== null ||
|
||||
(conversationsStore.activeMessages as DatabaseMessage[]).length > 0
|
||||
);
|
||||
let isMobile = $derived(deviceStore.isMobile);
|
||||
</script>
|
||||
|
||||
<div class="my-3 flex flex-col gap-2">
|
||||
{#if isMobile}
|
||||
<!-- a phone picks its model in the manager, so pointing this chat at it leads -->
|
||||
<Button class="w-full gap-1.5" onclick={hasChat ? onUseInChat : onUseInNewChat} {size}>
|
||||
{#if hasChat}
|
||||
<MessageSquare class="h-3.5 w-3.5" />
|
||||
|
||||
Use in this chat
|
||||
{:else}
|
||||
<SquarePen class="h-3.5 w-3.5" />
|
||||
|
||||
Start a new chat
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3 mt-2">
|
||||
{#if !isMobile}
|
||||
<Button class="flex-1 gap-1.5" onclick={onUseInNewChat} {size}>
|
||||
<SquarePen class="h-3.5 w-3.5" />
|
||||
|
||||
Start a new chat
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if canToggleLoad}
|
||||
<Button
|
||||
class="flex-1 gap-1.5"
|
||||
disabled={isLoading}
|
||||
onclick={onToggleLoad}
|
||||
{size}
|
||||
variant="secondary"
|
||||
>
|
||||
{#if isLoading}
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
|
||||
Loading...
|
||||
{:else if isLoaded}
|
||||
<Eject class="h-3.5 w-3.5" />
|
||||
|
||||
Unload model
|
||||
{:else}
|
||||
<Power class="h-3.5 w-3.5" />
|
||||
|
||||
Load model
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import ModelsManagerModelConfigurationActions from './ModelsManagerModelConfigurationActions.svelte';
|
||||
import { X } from '@lucide/svelte';
|
||||
import { ModelAvatar, ModelId } from '$lib/components/app';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import { deviceStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
interface Props {
|
||||
isLoaded: boolean;
|
||||
onClose: () => void;
|
||||
onToggleLoad: () => void;
|
||||
onUseInChat: () => void;
|
||||
onUseInNewChat: () => void;
|
||||
option: ModelOption;
|
||||
/** Load state reported by the server, null when it does not report one. */
|
||||
status: ServerModelStatus | null;
|
||||
}
|
||||
|
||||
let { isLoaded, onClose, onToggleLoad, onUseInChat, onUseInNewChat, option, status }: Props =
|
||||
$props();
|
||||
|
||||
let isMobile = $derived(deviceStore.isMobile);
|
||||
let isLoading = $derived(status === ServerModelStatus.LOADING);
|
||||
|
||||
let statusLabel = $derived.by(() => {
|
||||
if (status === ServerModelStatus.LOADING) return 'Loading';
|
||||
|
||||
if (status === ServerModelStatus.FAILED) return 'Failed to load';
|
||||
|
||||
if (status === ServerModelStatus.SLEEPING) return 'Sleeping';
|
||||
|
||||
if (isLoaded) return 'Loaded';
|
||||
|
||||
return 'Not loaded';
|
||||
});
|
||||
|
||||
let statusDot = $derived.by(() => {
|
||||
if (status === ServerModelStatus.FAILED) return 'bg-red-500';
|
||||
|
||||
if (status === ServerModelStatus.LOADING) return 'bg-muted-foreground/50 animate-pulse';
|
||||
|
||||
if (status === ServerModelStatus.SLEEPING) return 'bg-orange-400';
|
||||
|
||||
if (isLoaded) return 'bg-green-500';
|
||||
|
||||
return 'bg-muted-foreground/50';
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- only the desktop pane leaves the right edge to the panel border: on a phone the
|
||||
pane covers the screen, so its two sides carry the same padding, and it takes a
|
||||
little more room around the model it names -->
|
||||
<header class="space-y-2.5 pt-3 pl-4 max-md:space-y-4 max-md:pt-4 max-md:pr-4 max-md:pb-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<!-- same geometry as the discover details header: base org, quant org badge -->
|
||||
<ModelAvatar
|
||||
{option}
|
||||
quantPositionClass="-bottom-1.5 -right-1.5"
|
||||
quantSize="h-6 w-6"
|
||||
showBaseModelAvatar
|
||||
size="h-12 w-12"
|
||||
/>
|
||||
|
||||
<div class="min-w-0">
|
||||
<!-- a phone gives the id a row of its own, with its badges and icons under it -->
|
||||
<ModelId
|
||||
aliases={option.aliases}
|
||||
class="min-w-0"
|
||||
modalities={option.modalities}
|
||||
modelId={option.model}
|
||||
stackId={deviceStore.isMobile}
|
||||
tags={option.tags}
|
||||
title={option.model}
|
||||
/>
|
||||
|
||||
<p class="mt-1 flex items-center gap-x-2 text-xs text-muted-foreground">
|
||||
<span class="flex shrink-0 items-center gap-1">
|
||||
<span class="h-2 w-2 shrink-0 rounded-full {statusDot}"></span>
|
||||
|
||||
{statusLabel}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
aria-label="Close details"
|
||||
class="h-7 w-7 max-md:-mt-3 max-md:-mr-3 max-md:h-10 max-md:w-10 max-md:rounded-full"
|
||||
onclick={onClose}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- a phone floats the actions at the bottom of the pane, where the thumb is -->
|
||||
{#if !isMobile}
|
||||
<ModelsManagerModelConfigurationActions
|
||||
{isLoaded}
|
||||
{isLoading}
|
||||
{onToggleLoad}
|
||||
{onUseInChat}
|
||||
{onUseInNewChat}
|
||||
size="default"
|
||||
/>
|
||||
{/if}
|
||||
</header>
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ActionIconCopyToClipboard,
|
||||
BadgesModality,
|
||||
CollapsibleSection
|
||||
} from '$lib/components/app';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import type { ApiLlamaCppServerProps } from '$lib/types/api';
|
||||
import type { HfModelDetailInfo } from '$lib/types/huggingface';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { formatFileSize, formatNumber, formatParameters } from '$lib/utils/formatters';
|
||||
|
||||
interface Props {
|
||||
/** Hub details, when the Hub is enabled and the server reports none. */
|
||||
hub?: HfModelDetailInfo | null;
|
||||
option: ModelOption;
|
||||
serverProps: ApiLlamaCppServerProps | null | undefined;
|
||||
}
|
||||
|
||||
let { hub = null, option, serverProps }: Props = $props();
|
||||
|
||||
let meta = $derived(option.meta);
|
||||
let gguf = $derived(hub?.gguf ?? null);
|
||||
let quant = $derived(option.parsedId?.quantization ?? null);
|
||||
// a GGUF carries the count in its metadata, a transformers repo in its SafeTensors index
|
||||
let hubParameters = $derived(HuggingFaceService.parameterCount(hub));
|
||||
let draftSidecars = $derived(option.draftSidecars ?? []);
|
||||
|
||||
let modalities = $derived.by(() => {
|
||||
void modelsStore.props.cacheVersion;
|
||||
|
||||
return modelsStore.props.getModelModalitiesArray(option.id);
|
||||
});
|
||||
|
||||
let rows = $derived([
|
||||
{
|
||||
isCopyable: true,
|
||||
isMono: true,
|
||||
label: 'File Path',
|
||||
value: serverProps?.model_path ?? null
|
||||
},
|
||||
{
|
||||
label: 'Context Size',
|
||||
// the server reports the context it runs with once the model is loaded; until
|
||||
// then the listing, or the Hub, says what the model can take
|
||||
value: serverProps
|
||||
? `${formatNumber(serverProps.default_generation_settings?.n_ctx ?? 0)} tokens`
|
||||
: option.contextLength
|
||||
? `${formatNumber(option.contextLength)} tokens`
|
||||
: gguf?.context_length
|
||||
? `${formatNumber(gguf.context_length)} tokens`
|
||||
: null
|
||||
},
|
||||
{ label: 'Model Size', value: meta?.size ? formatFileSize(meta.size) : null },
|
||||
{
|
||||
label: 'Parameters',
|
||||
value: meta?.n_params
|
||||
? formatParameters(meta.n_params)
|
||||
: (option.parsedId?.params ?? (hubParameters ? formatParameters(hubParameters) : null))
|
||||
},
|
||||
{ label: 'Embedding Size', value: meta?.n_embd ? formatNumber(meta.n_embd) : null },
|
||||
{
|
||||
label: 'Vocabulary Size',
|
||||
value: meta?.n_vocab ? `${formatNumber(meta.n_vocab)} tokens` : null
|
||||
},
|
||||
{ isBadge: true, label: 'Quantization', value: quant },
|
||||
{
|
||||
isBadge: true,
|
||||
label: 'Architecture',
|
||||
value: (meta?.architecture as string) ?? gguf?.architecture ?? null
|
||||
},
|
||||
{
|
||||
isBadge: true,
|
||||
label: 'Draft sidecars',
|
||||
value:
|
||||
draftSidecars.length > 0
|
||||
? draftSidecars.map((badge) => badge.kind.toUpperCase()).join(', ')
|
||||
: null
|
||||
},
|
||||
{
|
||||
isCopyable: true,
|
||||
label: 'Draft repo',
|
||||
value: draftSidecars[0]?.repo ?? null
|
||||
},
|
||||
{
|
||||
isBadge: true,
|
||||
label: 'Draft quant',
|
||||
value: draftSidecars[0]?.quant ?? null
|
||||
},
|
||||
{
|
||||
label: 'Parallel Slots',
|
||||
value: serverProps?.total_slots != null ? String(serverProps.total_slots) : null
|
||||
},
|
||||
{ isMono: true, label: 'Build Info', value: serverProps?.build_info ?? null }
|
||||
] satisfies Array<{
|
||||
isBadge?: boolean;
|
||||
isCopyable?: boolean;
|
||||
isMono?: boolean;
|
||||
label: string;
|
||||
value: string | null;
|
||||
}>);
|
||||
|
||||
// rows without a value say nothing; a loaded model fills the rest in from /props
|
||||
let visibleRows = $derived(rows.filter((row) => row.value !== null));
|
||||
|
||||
const sectionTrigger = 'flex w-full cursor-pointer items-center gap-2 py-2 text-left';
|
||||
</script>
|
||||
|
||||
<div class="space-y-0">
|
||||
<div class="flex items-center gap-3 border-b border-border/30 py-2.5">
|
||||
<span class="text-sm text-muted-foreground">Model</span>
|
||||
|
||||
<span class="ml-auto flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="min-w-0 flex-1 overflow-x-auto font-mono text-xs whitespace-nowrap">
|
||||
{option.model}
|
||||
</span>
|
||||
|
||||
<ActionIconCopyToClipboard
|
||||
ariaLabel="Copy model name to clipboard"
|
||||
canCopy
|
||||
text={option.model}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#each visibleRows as row (row.label)}
|
||||
<div class="flex items-center gap-3 border-b border-border/30 py-2.5 last:border-b-0">
|
||||
<span class="text-sm text-muted-foreground">{row.label}</span>
|
||||
|
||||
<span class="ml-auto flex min-w-0 items-center gap-2 {row.isCopyable ? 'flex-1' : ''}">
|
||||
{#if row.value === null}
|
||||
<span class="text-muted-foreground">-</span>
|
||||
{:else}
|
||||
<span
|
||||
class={[
|
||||
'min-w-0',
|
||||
// a copyable value scrolls instead of clipping, the icon stays put
|
||||
row.isCopyable ? 'flex-1 overflow-x-auto whitespace-nowrap' : 'truncate',
|
||||
row.isBadge ? '' : 'text-sm',
|
||||
row.isMono ? 'font-mono text-xs' : ''
|
||||
]}
|
||||
>
|
||||
{#if row.isBadge}
|
||||
<Badge class="h-5 px-1.5 text-[10px]" variant="secondary">{row.value}</Badge>
|
||||
{:else}
|
||||
{row.value}
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if row.isCopyable}
|
||||
<ActionIconCopyToClipboard
|
||||
ariaLabel="Copy value to clipboard"
|
||||
canCopy
|
||||
text={row.value}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if visibleRows.length === 0}
|
||||
<p class="py-2.5 text-sm text-muted-foreground">
|
||||
The model reports its metadata once it is loaded.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if modalities.length > 0}
|
||||
<div class="flex items-center gap-3 border-b border-border/30 py-2.5 last:border-b-0">
|
||||
<span class="text-sm text-muted-foreground">Modalities</span>
|
||||
|
||||
<span class="ml-auto flex flex-wrap gap-1"><BadgesModality {modalities} /></span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !serverProps}
|
||||
<p class="pt-2 text-xs text-muted-foreground">
|
||||
/props values - file path, slots, build info - appear once the model is loaded. Reading this
|
||||
page never loads a model.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4">
|
||||
<CollapsibleSection triggerClass={sectionTrigger}>
|
||||
{#snippet trigger()}
|
||||
<span class="text-sm font-medium">Chat Template</span>
|
||||
{/snippet}
|
||||
|
||||
<pre
|
||||
class="mt-1 rounded-md bg-muted/50 p-2 text-xs whitespace-pre-wrap">{serverProps?.chat_template ??
|
||||
gguf?.chat_template ??
|
||||
'Shown once the model is loaded.'}</pre>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import ModelAvatar from '../ModelAvatar.svelte';
|
||||
import ModelCapabilities from '../ModelCapabilities.svelte';
|
||||
import ModelContext from '../ModelContext.svelte';
|
||||
import ModelDownloadProgressBar from '../ModelDownloadProgressBar.svelte';
|
||||
import ModelId from '../ModelId.svelte';
|
||||
import ModelsManagerStatusCell from './ModelsManagerStatusCell.svelte';
|
||||
import { modelRowActions } from './row-actions';
|
||||
import { configuredContext, downloadProgressFor } from './utils';
|
||||
import { MoreHorizontal } from '@lucide/svelte';
|
||||
import { DropdownMenuActions } from '$lib/components/app';
|
||||
import { MODEL_ROW_GRID_CLASS, MODEL_ROW_TRAILING_CELL_CLASS } from '$lib/constants';
|
||||
import { ModelRowDownloadState } from '$lib/enums';
|
||||
import { deviceStore, modelsStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
interface Props {
|
||||
isFavorite: (option: ModelOption) => boolean;
|
||||
option: ModelOption;
|
||||
onDelete: (option: ModelOption) => void;
|
||||
onSelect: (option: ModelOption) => void;
|
||||
selected: boolean;
|
||||
/** Left padding in px, from the nesting depth. */
|
||||
indent?: number;
|
||||
}
|
||||
|
||||
let { indent = 0, isFavorite, onDelete, onSelect, option, selected }: Props = $props();
|
||||
|
||||
let favorite = $derived(isFavorite(option));
|
||||
let isHidden = $derived(modelsStore.isHidden(option.id));
|
||||
// live while the download runs, frozen while it is paused
|
||||
let downloadProgress = $derived(downloadProgressFor(option.model));
|
||||
// a tracked download takes over the status column while it runs
|
||||
let download = $derived(
|
||||
modelsStore.status.isDownloadPaused(option.model)
|
||||
? ModelRowDownloadState.PAUSED
|
||||
: modelsStore.status.isDownloadInProgress(option.model)
|
||||
? ModelRowDownloadState.DOWNLOADING
|
||||
: null
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
MODEL_ROW_GRID_CLASS,
|
||||
'group relative rounded-md px-2 py-3 transition max-md:px-3 max-md:py-4',
|
||||
isHidden && 'opacity-60',
|
||||
selected ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/40'
|
||||
]}
|
||||
>
|
||||
<!-- the row holds a load control and an actions menu, so only the model itself is
|
||||
the button: a button nested in a role="button" row is invalid -->
|
||||
<button
|
||||
aria-pressed={selected}
|
||||
class="flex min-w-0 cursor-pointer items-center gap-3 text-left max-md:gap-2 max-md:pr-9"
|
||||
onclick={() => onSelect(option)}
|
||||
style="padding-left: {indent}px"
|
||||
type="button"
|
||||
>
|
||||
<ModelAvatar {option} size="size-7 md:size-9" />
|
||||
|
||||
<span class="flex min-w-0 items-center gap-1.25">
|
||||
<ModelId
|
||||
aliases={option.aliases}
|
||||
class="min-w-0 flex-1 max-md:text-sm"
|
||||
draftSidecars={option.draftSidecars}
|
||||
hideCapabilities
|
||||
hideModalities
|
||||
modalities={option.modalities}
|
||||
modelId={option.model}
|
||||
tags={option.tags}
|
||||
title={option.model}
|
||||
/>
|
||||
|
||||
<!-- a phone has no width for the modality icons, the id needs it more -->
|
||||
<ModelCapabilities hideModalities={deviceStore.isMobile} {option} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<ModelContext
|
||||
class="justify-self-end max-md:hidden"
|
||||
configured={configuredContext(option)}
|
||||
{option}
|
||||
/>
|
||||
|
||||
<ModelsManagerStatusCell class="max-md:hidden" {download} {option} />
|
||||
|
||||
{#if download}
|
||||
<ModelDownloadProgressBar
|
||||
downloadedBytes={downloadProgress?.downloadedBytes ?? 0}
|
||||
overlay
|
||||
totalBytes={downloadProgress?.totalBytes ?? 0}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-center justify-self-center {MODEL_ROW_TRAILING_CELL_CLASS}">
|
||||
<DropdownMenuActions
|
||||
actions={modelRowActions(option, favorite, isHidden, onDelete, download)}
|
||||
align="end"
|
||||
triggerIcon={MoreHorizontal}
|
||||
triggerTooltip="Model actions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,422 @@
|
||||
<script lang="ts">
|
||||
import ModelsManagerModelRow from './ModelsManagerModelRow.svelte';
|
||||
import ModelsManagerQuantRow from './ModelsManagerQuantRow.svelte';
|
||||
import ModelsManagerRepoRow from './ModelsManagerRepoRow.svelte';
|
||||
import ModelsManagerTableToolbar from './ModelsManagerTableToolbar.svelte';
|
||||
import {
|
||||
hasActiveFilters,
|
||||
modelContextLength,
|
||||
type ModelQuantGroup,
|
||||
type ModelsTableGroup,
|
||||
statusRank
|
||||
} from './utils';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Download,
|
||||
EyeOff,
|
||||
Heart,
|
||||
Power
|
||||
} from '@lucide/svelte';
|
||||
import {
|
||||
CollapsibleRegion,
|
||||
GroupedList,
|
||||
type GroupedListGroup,
|
||||
GroupedListUnit,
|
||||
Logo,
|
||||
ModelAvatar,
|
||||
ModelsSection
|
||||
} from '$lib/components/app';
|
||||
import { DialogConfirmDownload } from '$lib/components/app/dialogs';
|
||||
import {
|
||||
FAMILY_ROW_WINDOW,
|
||||
type ModalityKey,
|
||||
MODEL_ROW_GRID_CLASS,
|
||||
MODEL_ROW_TRAILING_CELL_CLASS,
|
||||
MODEL_ROW_WINDOW,
|
||||
MODELS_TABLE_GROUP_STICKY_OFFSET,
|
||||
SETTINGS_KEYS
|
||||
} from '$lib/constants';
|
||||
import {
|
||||
KeyboardKey,
|
||||
ModelCapability,
|
||||
ModelDownloadConfirmAction,
|
||||
ModelsTableGroupKind,
|
||||
ModelsTableSortKey
|
||||
} from '$lib/enums';
|
||||
import { modelsStore, settingsStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { groupModelFamilies, type ModelFamilyGroup } from '$lib/utils/model-families';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
/** Capabilities a model must have every one of. */
|
||||
capabilities?: ModelCapability[];
|
||||
/** Smallest context a model must support; 0 keeps every model. */
|
||||
contextLimit?: number;
|
||||
filter?: string;
|
||||
groups: ModelsTableGroup[];
|
||||
isFavorite: (option: ModelOption) => boolean;
|
||||
onSelect: (option: ModelOption) => void;
|
||||
/** Modalities a model must support at least one of. */
|
||||
modalities?: ModalityKey[];
|
||||
selectedId: string | null;
|
||||
/** Rendered at the toolbar's right end, past the filters. */
|
||||
toolbarEnd?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
capabilities = $bindable<ModelCapability[]>([]),
|
||||
contextLimit = $bindable(0),
|
||||
filter = $bindable(''),
|
||||
groups,
|
||||
isFavorite,
|
||||
modalities = $bindable<ModalityKey[]>([]),
|
||||
onSelect,
|
||||
selectedId,
|
||||
toolbarEnd
|
||||
}: Props = $props();
|
||||
|
||||
let isEmpty = $derived(groups.every((group) => group.items.length === 0));
|
||||
let hasFilters = $derived(hasActiveFilters(contextLimit, modalities, capabilities));
|
||||
|
||||
/** Noun the show-more row counts in, per unit of the grouped list. */
|
||||
const SHOW_MORE_NOUNS: Record<GroupedListUnit, string> = {
|
||||
[GroupedListUnit.ENTRIES]: 'models',
|
||||
[GroupedListUnit.GROUPS]: 'families'
|
||||
};
|
||||
let pendingDelete = $state('');
|
||||
let deleteOpen = $state(false);
|
||||
/** Repos whose quants are folded away; the rest show them. */
|
||||
const collapsedQuants = new SvelteSet<string>();
|
||||
/** Sections that list their models straight, without folding them into families. */
|
||||
const FLAT_SECTIONS = new Set<ModelsTableGroupKind>([
|
||||
ModelsTableGroupKind.DOWNLOADING,
|
||||
ModelsTableGroupKind.FAVORITES,
|
||||
ModelsTableGroupKind.LOADED
|
||||
]);
|
||||
let sections = $derived(
|
||||
groups.map((group) => {
|
||||
// a flat section lists its models straight, families or not
|
||||
const flat =
|
||||
FLAT_SECTIONS.has(group.kind) ||
|
||||
!settingsStore.config[SETTINGS_KEYS.GROUP_MODELS_BY_FAMILY];
|
||||
// downloads keep the feed's order: their progress, not their name, moves
|
||||
const items =
|
||||
group.kind === ModelsTableGroupKind.DOWNLOADING ? group.items : sortEntries(group.items);
|
||||
|
||||
return {
|
||||
...group,
|
||||
families: flat ? [] : groupModelFamilies(items, (entry) => entry.base.model),
|
||||
flat,
|
||||
items
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
function familyGroups(
|
||||
group: (typeof sections)[number]
|
||||
): GroupedListGroup<ModelFamilyGroup<ModelQuantGroup>, ModelQuantGroup>[] {
|
||||
return group.families.map((family) => ({
|
||||
entries: family.entries,
|
||||
group: family,
|
||||
key: `${group.key}::${family.key}`
|
||||
}));
|
||||
}
|
||||
|
||||
// cancel is confirmed once for the whole list, so one dialog serves every row
|
||||
function requestDelete(option: ModelOption): void {
|
||||
pendingDelete = option.model;
|
||||
deleteOpen = true;
|
||||
}
|
||||
|
||||
function toggleQuants(key: string): void {
|
||||
if (collapsedQuants.has(key)) {
|
||||
collapsedQuants.delete(key);
|
||||
} else {
|
||||
collapsedQuants.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Column the table is ordered by; unset keeps the manager's own order. */
|
||||
let sortKey = $state<ModelsTableSortKey | null>(null);
|
||||
let sortAsc = $state(true);
|
||||
|
||||
function compareEntries(left: ModelQuantGroup, right: ModelQuantGroup): number {
|
||||
const a = left.base;
|
||||
const b = right.base;
|
||||
|
||||
switch (sortKey) {
|
||||
case ModelsTableSortKey.CONTEXT:
|
||||
// a context we cannot read yet sorts with the smallest ones
|
||||
return (modelContextLength(a) ?? 0) - (modelContextLength(b) ?? 0);
|
||||
case ModelsTableSortKey.NAME:
|
||||
return a.model.localeCompare(b.model);
|
||||
case ModelsTableSortKey.STATUS:
|
||||
// a running model leads, then one that is being worked on (loading,
|
||||
// sleeping), then the rest; the reported status sorts the row's own cell
|
||||
return statusRank(b) - statusRank(a);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function sortEntries(entries: ModelQuantGroup[]): ModelQuantGroup[] {
|
||||
if (!sortKey) return entries;
|
||||
|
||||
const direction = sortAsc ? 1 : -1;
|
||||
|
||||
return [...entries].sort((a, b) => direction * compareEntries(a, b));
|
||||
}
|
||||
|
||||
/** A click on a new column sorts lowest first, then highest first, then clears. */
|
||||
function toggleSort(key: ModelsTableSortKey): void {
|
||||
if (sortKey !== key) {
|
||||
sortKey = key;
|
||||
sortAsc = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (sortAsc) {
|
||||
sortAsc = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
sortKey = null;
|
||||
}
|
||||
|
||||
function sortTitle(key: ModelsTableSortKey, label: string): string {
|
||||
const name = label.toLowerCase();
|
||||
|
||||
if (sortKey !== key) return `Sort by ${name}, lowest first`;
|
||||
|
||||
return sortAsc ? `Sort by ${name}, highest first` : `Stop sorting by ${name}`;
|
||||
}
|
||||
|
||||
function handleFamilyKeydown(event: KeyboardEvent, toggle: () => void): void {
|
||||
if (event.key === KeyboardKey.SPACE) event.preventDefault();
|
||||
|
||||
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) toggle();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet sortHeader(key: ModelsTableSortKey, label: string)}
|
||||
<button
|
||||
class="inline-flex cursor-pointer items-center gap-1 uppercase transition hover:text-foreground focus:outline-none"
|
||||
onclick={() => toggleSort(key)}
|
||||
title={sortTitle(key, label)}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
|
||||
{#if sortKey === key}
|
||||
{#if sortAsc}
|
||||
<ArrowUp class="h-3 w-3" />
|
||||
{:else}
|
||||
<ArrowDown class="h-3 w-3" />
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet entryTree(entry: ModelQuantGroup, indent = 0)}
|
||||
{#if entry.quants.length > 1}
|
||||
<ModelsManagerRepoRow
|
||||
{entry}
|
||||
expanded={!collapsedQuants.has(entry.key)}
|
||||
{indent}
|
||||
onToggle={() => toggleQuants(entry.key)}
|
||||
/>
|
||||
|
||||
<CollapsibleRegion open={!collapsedQuants.has(entry.key)}>
|
||||
{#each entry.quants as quant (quant.id)}
|
||||
<ModelsManagerQuantRow
|
||||
indent={indent + 24}
|
||||
{isFavorite}
|
||||
onDelete={requestDelete}
|
||||
{onSelect}
|
||||
option={quant}
|
||||
selected={selectedId === quant.id}
|
||||
/>
|
||||
{/each}
|
||||
</CollapsibleRegion>
|
||||
{:else}
|
||||
<ModelsManagerModelRow
|
||||
{indent}
|
||||
{isFavorite}
|
||||
onDelete={requestDelete}
|
||||
{onSelect}
|
||||
option={entry.base}
|
||||
selected={selectedId === entry.base.id}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet familyRow({
|
||||
expanded,
|
||||
group: family,
|
||||
toggle
|
||||
}: {
|
||||
expanded: boolean;
|
||||
group: ModelFamilyGroup<ModelQuantGroup>;
|
||||
toggle: () => void;
|
||||
})}
|
||||
{@const countLabel = `${family.entries.length} model${family.entries.length === 1 ? '' : 's'}`}
|
||||
|
||||
<div
|
||||
class="{MODEL_ROW_GRID_CLASS} relative group cursor-pointer rounded-md px-2 py-1 transition hover:bg-muted/40 max-md:px-3 max-md:py-2.5"
|
||||
onclick={toggle}
|
||||
onkeydown={(event) => handleFamilyKeydown(event, toggle)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-3 max-md:pr-9">
|
||||
<ModelAvatar
|
||||
option={family.entries[0].base}
|
||||
showBaseModelAvatar
|
||||
showQuantBadge={false}
|
||||
size="size-6"
|
||||
/>
|
||||
|
||||
<span class="truncate text-sm font-medium">{family.label}</span>
|
||||
|
||||
<span class="text-sm text-muted-foreground">{countLabel}</span>
|
||||
</span>
|
||||
|
||||
<!-- the context and status columns stay empty here: the fold control is this
|
||||
row's last cell, which a phone floats at the row's end -->
|
||||
<span class="max-md:hidden"></span>
|
||||
|
||||
<span class="max-md:hidden"></span>
|
||||
|
||||
<span
|
||||
class="flex justify-center {MODEL_ROW_TRAILING_CELL_CLASS} {expanded
|
||||
? 'opacity-0 group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100'
|
||||
: ''}"
|
||||
>
|
||||
{#if expanded}
|
||||
<ChevronUp class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{:else}
|
||||
<ChevronDown class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet listItem({ depth, entry }: { depth: number; entry: ModelQuantGroup })}
|
||||
{@render entryTree(entry, depth > 0 ? 16 : 0)}
|
||||
{/snippet}
|
||||
|
||||
{#snippet showMore({
|
||||
count,
|
||||
onMore,
|
||||
unit
|
||||
}: {
|
||||
count: number;
|
||||
onMore: () => void;
|
||||
unit: GroupedListUnit;
|
||||
})}
|
||||
<div class="px-2 max-md:px-3">
|
||||
<button
|
||||
class="w-full cursor-pointer rounded-md px-2 py-2 text-left text-xs text-muted-foreground transition hover:bg-muted/40"
|
||||
onclick={onMore}
|
||||
type="button"
|
||||
>
|
||||
Show {count} more {SHOW_MORE_NOUNS[unit]}
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<DialogConfirmDownload
|
||||
action={ModelDownloadConfirmAction.DELETE}
|
||||
onClose={() => (deleteOpen = false)}
|
||||
open={deleteOpen}
|
||||
repoWithTag={pendingDelete}
|
||||
/>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<ModelsManagerTableToolbar
|
||||
bind:capabilities
|
||||
bind:contextLimit
|
||||
bind:filter
|
||||
bind:modalities
|
||||
{toolbarEnd}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="{MODEL_ROW_GRID_CLASS} shrink-0 border-y border-border/40 px-2 py-2 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase max-md:px-3"
|
||||
>
|
||||
<span>{@render sortHeader(ModelsTableSortKey.NAME, 'Model')}</span>
|
||||
|
||||
<span class="text-right whitespace-nowrap max-md:hidden">
|
||||
{@render sortHeader(ModelsTableSortKey.CONTEXT, 'Context')}
|
||||
</span>
|
||||
|
||||
<span class="justify-self-center max-md:hidden">
|
||||
{@render sortHeader(ModelsTableSortKey.STATUS, 'Status')}
|
||||
</span>
|
||||
|
||||
<span class="text-center max-md:hidden">Actions</span>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
{#each sections as group (group.key)}
|
||||
{#if group.items.length > 0}
|
||||
{#snippet groupIcon()}
|
||||
{#if group.kind === ModelsTableGroupKind.DOWNLOADING}
|
||||
<Download class="h-3.5 w-3.5 shrink-0" />
|
||||
{:else if group.kind === ModelsTableGroupKind.FAVORITES}
|
||||
<Heart class="h-3.5 w-3.5 shrink-0" />
|
||||
{:else if group.kind === ModelsTableGroupKind.LOADED}
|
||||
<Power class="h-3.5 w-3.5 shrink-0" />
|
||||
{:else if group.kind === ModelsTableGroupKind.HIDDEN}
|
||||
<EyeOff class="h-3.5 w-3.5 shrink-0" />
|
||||
{:else if group.kind === ModelsTableGroupKind.LOCAL}
|
||||
<Logo class="shrink-0" style="--size: 0.875rem" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<ModelsSection
|
||||
chevronClass="mr-7"
|
||||
count={group.items.length}
|
||||
defaultOpen={group.kind !== ModelsTableGroupKind.HIDDEN}
|
||||
icon={groupIcon}
|
||||
label={group.label}
|
||||
persistKey={group.key}
|
||||
revealChevronOnHover
|
||||
sectionHeaderClass="m-0 px-2 py-2 text-[13px] font-semibold text-muted-foreground select-none max-md:px-3"
|
||||
sticky
|
||||
stickyClass="sticky z-10 bg-muted/90 backdrop-blur-lg"
|
||||
>
|
||||
<GroupedList
|
||||
group={familyRow}
|
||||
groupWindow={FAMILY_ROW_WINDOW}
|
||||
groups={group.flat ? null : familyGroups(group)}
|
||||
initialCollapsed={modelsStore.collapsedGroupsUnder(group.key)}
|
||||
item={listItem}
|
||||
items={group.flat ? group.items : []}
|
||||
keyOf={(entry) => entry.key}
|
||||
more={showMore}
|
||||
onCollapsedChange={(key, collapsed) =>
|
||||
modelsStore.setGroupCollapsed(group.key, key, collapsed)}
|
||||
sectionWindow={MODEL_ROW_WINDOW}
|
||||
stickyClass="bg-muted/30 backdrop-blur-lg"
|
||||
stickyStyle={MODELS_TABLE_GROUP_STICKY_OFFSET}
|
||||
weightOf={(entry) => entry.quants.length}
|
||||
/>
|
||||
</ModelsSection>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if isEmpty}
|
||||
<p class="px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
{hasFilters ? 'No models match these filters.' : 'No models found.'}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import ModelContext from '../ModelContext.svelte';
|
||||
import ModelDraftSidecars from '../ModelDraftSidecars.svelte';
|
||||
import ModelsManagerStatusCell from './ModelsManagerStatusCell.svelte';
|
||||
import { modelRowActions } from './row-actions';
|
||||
import { configuredContext } from './utils';
|
||||
import { MoreHorizontal } from '@lucide/svelte';
|
||||
import { DropdownMenuActions } from '$lib/components/app';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { MODEL_ROW_GRID_CLASS, MODEL_ROW_TRAILING_CELL_CLASS } from '$lib/constants';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
interface Props {
|
||||
isFavorite: (option: ModelOption) => boolean;
|
||||
option: ModelOption;
|
||||
onDelete: (option: ModelOption) => void;
|
||||
onSelect: (option: ModelOption) => void;
|
||||
selected: boolean;
|
||||
/** Left padding in px, from the nesting depth. */
|
||||
indent?: number;
|
||||
}
|
||||
|
||||
let { indent = 0, isFavorite, onDelete, onSelect, option, selected }: Props = $props();
|
||||
|
||||
let favorite = $derived(isFavorite(option));
|
||||
let isHidden = $derived(modelsStore.isHidden(option.id));
|
||||
let quant = $derived(option.parsedId?.quantization ?? option.model);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
MODEL_ROW_GRID_CLASS,
|
||||
'group relative rounded-md px-2 py-2 transition max-md:px-3 max-md:py-3',
|
||||
isHidden && 'opacity-60',
|
||||
selected ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/40'
|
||||
]}
|
||||
>
|
||||
<!-- the row holds a load control and an actions menu, so only the quant itself is
|
||||
the button: a button nested in a role="button" row is invalid -->
|
||||
<button
|
||||
aria-pressed={selected}
|
||||
class="flex min-w-0 cursor-pointer items-center gap-3 text-left max-md:pr-9"
|
||||
onclick={() => onSelect(option)}
|
||||
style="padding-left: {indent}px"
|
||||
type="button"
|
||||
>
|
||||
<Badge class="h-5 shrink-0 px-1.5 text-[10px]" variant="secondary">{quant}</Badge>
|
||||
|
||||
<ModelDraftSidecars draftSidecars={option.draftSidecars} />
|
||||
|
||||
<span class="truncate text-sm text-muted-foreground">{option.model}</span>
|
||||
</button>
|
||||
|
||||
<ModelContext
|
||||
class="justify-self-end max-md:hidden"
|
||||
configured={configuredContext(option)}
|
||||
{option}
|
||||
/>
|
||||
|
||||
<ModelsManagerStatusCell class="max-md:hidden" {option} />
|
||||
|
||||
<div class="flex items-center justify-center justify-self-center {MODEL_ROW_TRAILING_CELL_CLASS}">
|
||||
<DropdownMenuActions
|
||||
actions={modelRowActions(option, favorite, isHidden, onDelete)}
|
||||
align="end"
|
||||
triggerIcon={MoreHorizontal}
|
||||
triggerTooltip="Model actions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import ModelAvatar from '../ModelAvatar.svelte';
|
||||
import ModelCapabilities from '../ModelCapabilities.svelte';
|
||||
import ModelContext from '../ModelContext.svelte';
|
||||
import ModelId from '../ModelId.svelte';
|
||||
import type { ModelQuantGroup } from './utils';
|
||||
import { configuredContext } from './utils';
|
||||
import { ChevronDown, ChevronUp } from '@lucide/svelte';
|
||||
import { MODEL_ROW_GRID_CLASS, MODEL_ROW_TRAILING_CELL_CLASS } from '$lib/constants';
|
||||
import { KeyboardKey, ModelGroupKind } from '$lib/enums';
|
||||
import { deviceStore, modelsStore } from '$lib/stores';
|
||||
|
||||
interface Props {
|
||||
entry: ModelQuantGroup;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
/** Left padding in px, from the nesting depth. */
|
||||
indent?: number;
|
||||
}
|
||||
|
||||
let { entry, expanded, indent = 0, onToggle }: Props = $props();
|
||||
|
||||
let groupLabel = $derived(
|
||||
entry.kind === ModelGroupKind.VARIANTS
|
||||
? `${entry.quants.length} variants`
|
||||
: `${entry.quants.length} quants available`
|
||||
);
|
||||
let anyLoaded = $derived(entry.quants.some((quant) => modelsStore.isModelRunning(quant.model)));
|
||||
// a repo row stands for its quants, so it reports what they agree on
|
||||
let contextSource = $derived(entry.quants.find((quant) => quant.contextLength) ?? entry.base);
|
||||
let mediaSource = $derived(entry.quants.find((quant) => quant.modalities) ?? entry.base);
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === KeyboardKey.SPACE) event.preventDefault();
|
||||
|
||||
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) onToggle();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
MODEL_ROW_GRID_CLASS,
|
||||
'relative cursor-pointer rounded-md px-2 py-2.5 transition hover:bg-muted/40 max-md:px-3 max-md:py-3.5'
|
||||
]}
|
||||
onclick={onToggle}
|
||||
onkeydown={handleKeydown}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-3 max-md:pr-9" style="padding-left: {indent}px">
|
||||
<ModelAvatar option={entry.base} size="size-9" />
|
||||
|
||||
<span class="min-w-0">
|
||||
<span class="flex min-w-0 items-center gap-1.25">
|
||||
<ModelId
|
||||
aliases={entry.base.aliases}
|
||||
class="min-w-0"
|
||||
hideCapabilities
|
||||
hideModalities
|
||||
hideQuantization
|
||||
modalities={mediaSource.modalities}
|
||||
modelId={entry.base.model}
|
||||
tags={entry.base.tags}
|
||||
title={entry.base.model}
|
||||
/>
|
||||
|
||||
<ModelCapabilities hideModalities={deviceStore.isMobile} option={entry.base} />
|
||||
</span>
|
||||
|
||||
<span class="block text-xs text-muted-foreground">{groupLabel}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<ModelContext
|
||||
class="justify-self-end max-md:hidden"
|
||||
configured={configuredContext(contextSource)}
|
||||
option={contextSource}
|
||||
/>
|
||||
|
||||
<span class="justify-self-center max-md:hidden">
|
||||
<span
|
||||
class="block h-2.5 w-2.5 rounded-full {anyLoaded
|
||||
? 'bg-emerald-500'
|
||||
: 'border border-muted-foreground/50'}"
|
||||
></span>
|
||||
</span>
|
||||
|
||||
<span class="flex justify-center {MODEL_ROW_TRAILING_CELL_CLASS}">
|
||||
{#if expanded}
|
||||
<ChevronUp class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{:else}
|
||||
<ChevronDown class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import ModelLoadControl from '../ModelLoadControl.svelte';
|
||||
import ModelsManagerDownloadControl from './ModelsManagerDownloadControl.svelte';
|
||||
import type { ModelRowDownloadState } from '$lib/enums';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import { modelsStore, serverStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
/** Download state, when the row stands for a tracked download. */
|
||||
download?: ModelRowDownloadState | null;
|
||||
option: ModelOption;
|
||||
}
|
||||
|
||||
let { class: className = '', download = null, option }: Props = $props();
|
||||
|
||||
let status = $derived(modelsStore.getModelStatus(option.model));
|
||||
let isOperationInProgress = $derived(modelsStore.status.isOperationInProgress(option.model));
|
||||
let isLoaded = $derived(modelsStore.isModelRunning(option.model));
|
||||
// a MODEL server serves the model it was started with and answers no load route
|
||||
let isRouter = $derived(serverStore.isRouterMode);
|
||||
</script>
|
||||
|
||||
{#if download}
|
||||
<ModelsManagerDownloadControl class="justify-self-center {className}" {option} state={download} />
|
||||
{:else if isRouter}
|
||||
<ModelLoadControl
|
||||
class="justify-self-center {className}"
|
||||
isFailed={status === ServerModelStatus.FAILED}
|
||||
{isLoaded}
|
||||
isLoading={status === ServerModelStatus.LOADING || isOperationInProgress}
|
||||
isSleeping={status === ServerModelStatus.SLEEPING}
|
||||
{option}
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="justify-self-center text-sm text-muted-foreground {className}"
|
||||
title="Served by this server"
|
||||
>
|
||||
-
|
||||
</span>
|
||||
{/if}
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import ModelsManagerFilters from './ModelsManagerFilters.svelte';
|
||||
import { hasActiveFilters } from './utils';
|
||||
import { X } from '@lucide/svelte';
|
||||
import { SearchInput } from '$lib/components/app/forms';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { type ModalityKey } from '$lib/constants';
|
||||
import { ModelCapability } from '$lib/enums';
|
||||
import { deviceStore, uiStore } from '$lib/stores';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/** Capabilities a model must have every one of. */
|
||||
capabilities?: ModelCapability[];
|
||||
/** Smallest context a model must support; 0 keeps every model. */
|
||||
contextLimit?: number;
|
||||
filter?: string;
|
||||
/** Modalities a model must support at least one of. */
|
||||
modalities?: ModalityKey[];
|
||||
/** Rendered at the toolbar's right end, past the filters. */
|
||||
toolbarEnd?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
capabilities = $bindable<ModelCapability[]>([]),
|
||||
contextLimit = $bindable(0),
|
||||
filter = $bindable(''),
|
||||
modalities = $bindable<ModalityKey[]>([]),
|
||||
toolbarEnd
|
||||
}: Props = $props();
|
||||
|
||||
let hasFilters = $derived(hasActiveFilters(contextLimit, modalities, capabilities));
|
||||
let filterInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
// The search takes the focus the dialog would give its first control: the input
|
||||
// mounts a frame or two after the dialog opens (the toolbar renders below the
|
||||
// table in the DOM), so the poll retries until the ref lands or gives up.
|
||||
$effect(() => {
|
||||
if (!uiStore.manageModelsOpen) return;
|
||||
|
||||
let frames = 0;
|
||||
let handle = requestAnimationFrame(function focusFilter() {
|
||||
if (filterInput) {
|
||||
filterInput.focus({ preventScroll: true });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (frames++ < 20) handle = requestAnimationFrame(focusFilter);
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(handle);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Below md the search takes a row of its own and the filters follow it. -->
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2 pb-4 max-md:gap-3 md:flex-nowrap">
|
||||
<SearchInput
|
||||
bind:ref={filterInput}
|
||||
bind:value={filter}
|
||||
class="w-full md:w-auto md:max-w-64"
|
||||
placeholder="Search your models"
|
||||
size={deviceStore.isMobile ? 'default' : 'sm'}
|
||||
/>
|
||||
|
||||
<ModelsManagerFilters bind:capabilities bind:contextLimit bind:modalities />
|
||||
|
||||
{#if hasFilters}
|
||||
<Button
|
||||
class="gap-1.5 text-muted-foreground"
|
||||
onclick={() => {
|
||||
contextLimit = 0;
|
||||
modalities = [];
|
||||
capabilities = [];
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
|
||||
Clear filters
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
{@render toolbarEnd?.()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Eye, EyeOff, Heart, HeartOff, Trash2 } from '@lucide/svelte';
|
||||
import { MODEL_DOWNLOAD_ICONS } from '$lib/constants';
|
||||
import { ModelRowDownloadState } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
/** Row actions follow the app's dropdown pattern: icon, label, separators, variants. */
|
||||
export function modelRowActions(
|
||||
option: ModelOption,
|
||||
favorite: boolean,
|
||||
isHidden: boolean,
|
||||
onDelete: (option: ModelOption) => void,
|
||||
/** Download state, when the row stands for a tracked download. */
|
||||
download?: ModelRowDownloadState | null
|
||||
) {
|
||||
// a tracked download is paused, resumed or dropped: it is not loaded, hidden or
|
||||
// drafted. Deleting it stops the download and removes what is on disk, which is
|
||||
// what the same wording offers on a cached model
|
||||
if (download) {
|
||||
const isPaused = download === ModelRowDownloadState.PAUSED;
|
||||
|
||||
return [
|
||||
{
|
||||
icon: isPaused ? MODEL_DOWNLOAD_ICONS.resume : MODEL_DOWNLOAD_ICONS.pause,
|
||||
label: isPaused ? 'Resume downloading' : 'Pause downloading',
|
||||
onclick: () =>
|
||||
void (isPaused
|
||||
? modelsStore.status.downloadModel(option.model)
|
||||
: modelsStore.status.pauseDownload(option.model))
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
label: 'Delete from disk',
|
||||
onclick: () => onDelete(option),
|
||||
separator: true,
|
||||
variant: 'destructive' as const
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
icon: favorite ? HeartOff : Heart,
|
||||
label: favorite ? 'Remove from favorites' : 'Add to favorites',
|
||||
onclick: () => modelsStore.toggleFavorite(option.model)
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
label: 'Delete from disk',
|
||||
onclick: () => onDelete(option),
|
||||
separator: true,
|
||||
variant: 'destructive' as const
|
||||
},
|
||||
{
|
||||
icon: isHidden ? Eye : EyeOff,
|
||||
label: isHidden ? 'Unhide model' : 'Hide model',
|
||||
onclick: () => modelsStore.toggleHidden(option.id),
|
||||
separator: true
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { LOCAL_BACKEND_ID, type ModalityKey } from '$lib/constants';
|
||||
import {
|
||||
ModelCapability,
|
||||
ModelGroupKind,
|
||||
ModelsTableGroupKind,
|
||||
ServerModelStatus
|
||||
} from '$lib/enums';
|
||||
import { HuggingFaceService, ModelsService } from '$lib/services';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import type { ModelDownloadEntry, ModelDownloadProgress, ModelOption } from '$lib/types/models';
|
||||
import { detectThinkingSupport, detectToolUseSupport, repoOf } from '$lib/utils';
|
||||
|
||||
/** One repo of the table, with the rows it ships as. */
|
||||
export interface ModelQuantGroup {
|
||||
base: ModelOption;
|
||||
key: string;
|
||||
kind: ModelGroupKind;
|
||||
quants: ModelOption[];
|
||||
}
|
||||
|
||||
/** True when any of the manager's filters narrows the table. */
|
||||
export function hasActiveFilters(
|
||||
contextLimit: number,
|
||||
modalities: ModalityKey[],
|
||||
capabilities: ModelCapability[]
|
||||
): boolean {
|
||||
return contextLimit > 0 || modalities.length > 0 || capabilities.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order a status sorts behind: loaded first, then a model being worked on
|
||||
* (loading, sleeping), then the rest.
|
||||
*/
|
||||
export function statusRank(option: ModelOption): number {
|
||||
const status = modelsStore.getModelStatus(option.model);
|
||||
|
||||
if (modelsStore.isModelRunning(option.model)) return 2;
|
||||
|
||||
if (status === ServerModelStatus.LOADING || status === ServerModelStatus.SLEEPING) return 1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Byte counts of a tracked download: live while it runs, frozen while paused. */
|
||||
export function downloadProgressFor(repoWithTag: string): ModelDownloadProgress | null {
|
||||
return (
|
||||
modelsStore.status.getDownloadProgress(repoWithTag) ??
|
||||
modelsStore.status.getPausedDownloadProgress(repoWithTag)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One table row per tracked download, so the table lists them the way it lists
|
||||
* any other model. A paused download is already in the router's listing, so its
|
||||
* own option is reused; a fresh one stands for its tag alone.
|
||||
*/
|
||||
export function downloadGroups(
|
||||
entries: ModelDownloadEntry[],
|
||||
models: ModelOption[]
|
||||
): ModelQuantGroup[] {
|
||||
return entries.map((entry) => {
|
||||
const option = models.find((candidate) => candidate.model === entry.repoWithTag) ?? {
|
||||
backendId: LOCAL_BACKEND_ID,
|
||||
capabilities: [],
|
||||
id: entry.repoWithTag,
|
||||
model: entry.repoWithTag,
|
||||
name: entry.repoWithTag
|
||||
};
|
||||
|
||||
return {
|
||||
base: option,
|
||||
key: `download::${entry.repoWithTag}`,
|
||||
kind: ModelGroupKind.QUANTS,
|
||||
quants: [option]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** One collapsible block of the manager's table. */
|
||||
export interface ModelsTableGroup {
|
||||
/** One entry per repo, its quants hanging off it. */
|
||||
items: ModelQuantGroup[];
|
||||
key: string;
|
||||
kind: ModelsTableGroupKind;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Context the model runs with: what a loaded model reports. */
|
||||
export function configuredContext(option: ModelOption): number | null {
|
||||
return modelsStore.isModelRunning(option.model)
|
||||
? modelsStore.props.getModelContextSize(option.model)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability a model reports: true or false once its listing or its chat template
|
||||
* answers, null while the Hub record that carries the template is not read yet.
|
||||
*/
|
||||
export function modelCapability(option: ModelOption, capability: ModelCapability): boolean | null {
|
||||
if (option.capabilities.includes(capability)) return true;
|
||||
|
||||
const details = HuggingFaceService.cachedDetails(option.model);
|
||||
|
||||
// only the chat template answers this, and it arrives with the Hub record
|
||||
if (details === undefined) return null;
|
||||
|
||||
const template = details?.gguf?.chat_template ?? '';
|
||||
|
||||
return capability === ModelCapability.TOOL_USE
|
||||
? detectToolUseSupport(template)
|
||||
: detectThinkingSupport(template);
|
||||
}
|
||||
|
||||
/**
|
||||
* Context a model reports: the provider listing first, then the cached Hub record.
|
||||
* Null while neither of them has answered.
|
||||
*/
|
||||
export function modelContextLength(option: ModelOption): number | null {
|
||||
return (
|
||||
option.contextLength ??
|
||||
HuggingFaceService.cachedDetails(option.model)?.gguf?.context_length ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/** Fold the quants of one repo into a single entry, so the table shows one row per model. */
|
||||
export function groupModelQuants(models: ModelOption[]): ModelQuantGroup[] {
|
||||
const groups = new Map<string, ModelQuantGroup>();
|
||||
|
||||
for (const option of models) {
|
||||
const key = repoOf(option.model);
|
||||
const group = groups.get(key);
|
||||
|
||||
if (group) {
|
||||
group.quants.push(option);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.set(key, { base: option, key, kind: ModelGroupKind.QUANTS, quants: [option] });
|
||||
}
|
||||
|
||||
return Array.from(groups.values()).flatMap((group) => {
|
||||
const kind = groupKind(group.quants);
|
||||
const modelIds = group.quants.map((option) => option.model);
|
||||
|
||||
// the very same id twice is not a quant set; keep those rows apart
|
||||
if (modelIds.length > 1 && modelIds.every((model) => model === modelIds[0])) {
|
||||
return group.quants.map((option) => ({
|
||||
...group,
|
||||
base: option,
|
||||
key: `${group.key}::${option.id}`,
|
||||
kind: ModelGroupKind.VARIANTS,
|
||||
quants: [option]
|
||||
}));
|
||||
}
|
||||
|
||||
return [{ ...group, kind }];
|
||||
});
|
||||
}
|
||||
|
||||
/** What a group folds: quants or variants of one repo. */
|
||||
function groupKind(quants: ModelOption[]): ModelGroupKind {
|
||||
const isQuant = quants.every(
|
||||
(option) => (option.parsedId ?? ModelsService.parseModelId(option.model)).quantization
|
||||
);
|
||||
|
||||
return isQuant ? ModelGroupKind.QUANTS : ModelGroupKind.VARIANTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the repos of a section into the quants that are hidden and the ones that
|
||||
* are not, so a partly hidden repo lists in both blocks instead of dragging its
|
||||
* visible quants into the hidden one.
|
||||
*/
|
||||
export function splitHiddenQuants(
|
||||
entries: ModelQuantGroup[],
|
||||
isHidden: (option: ModelOption) => boolean
|
||||
): { hidden: ModelQuantGroup[]; local: ModelQuantGroup[] } {
|
||||
const hidden: ModelQuantGroup[] = [];
|
||||
const local: ModelQuantGroup[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const hiddenQuants = entry.quants.filter((quant) => isHidden(quant));
|
||||
const localQuants = entry.quants.filter((quant) => !isHidden(quant));
|
||||
|
||||
if (hiddenQuants.length > 0) {
|
||||
hidden.push({
|
||||
...entry,
|
||||
base: hiddenQuants[0],
|
||||
key: `${entry.key}::hidden`,
|
||||
quants: hiddenQuants
|
||||
});
|
||||
}
|
||||
|
||||
if (localQuants.length > 0) {
|
||||
local.push({ ...entry, base: localQuants[0], quants: localQuants });
|
||||
}
|
||||
}
|
||||
|
||||
return { hidden, local };
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import { ChevronLeft, CircleAlert, Loader2 } from '@lucide/svelte';
|
||||
import { CollapsibleSection } from '$lib/components/app';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
/** Extra classes for the chevron, to line it up with a row's own control. */
|
||||
chevronClass?: string;
|
||||
/** Number shown next to the label, omitted when undefined. */
|
||||
count?: number;
|
||||
error?: boolean;
|
||||
/** Overrides the backend logo, used for sections without a backend. */
|
||||
icon?: Snippet;
|
||||
label: string;
|
||||
loading?: boolean;
|
||||
/** Renders the back control, for a drilled-in provider. */
|
||||
onBack?: () => void;
|
||||
/** Persists the open state under this id, e.g. a section key. */
|
||||
persistKey?: string;
|
||||
/** Start expanded; the manager collapses its hidden block. */
|
||||
defaultOpen?: boolean;
|
||||
revealChevronOnHover?: boolean;
|
||||
sectionHeaderClass?: string;
|
||||
/** Sticky class of the header, so a surface can shade it differently. */
|
||||
stickyClass?: string;
|
||||
sticky?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
chevronClass = '',
|
||||
children,
|
||||
count,
|
||||
defaultOpen = true,
|
||||
error = false,
|
||||
icon,
|
||||
label,
|
||||
loading = false,
|
||||
onBack,
|
||||
persistKey,
|
||||
revealChevronOnHover = false,
|
||||
sectionHeaderClass = 'm-0 px-2 py-2 text-[13px] font-semibold text-muted-foreground select-none',
|
||||
sticky = false,
|
||||
stickyClass = 'sticky z-10 bg-popover'
|
||||
}: Props = $props();
|
||||
|
||||
let triggerClass = $derived(
|
||||
`${sectionHeaderClass} flex w-full cursor-pointer items-center gap-1.5 text-left${sticky ? ` ${stickyClass}` : ''}`
|
||||
);
|
||||
// surfaces without a search block fall back to a 0 sticky height
|
||||
let triggerStyle = $derived(sticky ? 'top: var(--dropdown-sticky-height, 0px)' : '');
|
||||
</script>
|
||||
|
||||
<CollapsibleSection
|
||||
{chevronClass}
|
||||
defaultOpen={persistKey ? modelsStore.isGroupOpen(persistKey, defaultOpen) : defaultOpen}
|
||||
ontoggle={persistKey ? (open: boolean) => modelsStore.setGroupOpen(persistKey, open) : null}
|
||||
{revealChevronOnHover}
|
||||
{triggerClass}
|
||||
{triggerStyle}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
{#if onBack}
|
||||
<button
|
||||
aria-label="Back to all providers"
|
||||
class="-ml-1 inline-flex shrink-0 cursor-pointer items-center rounded-sm p-0.5 text-muted-foreground transition hover:bg-muted/60 hover:text-foreground"
|
||||
onclick={(event) => {
|
||||
// the back control must not collapse the list it is leaving
|
||||
event.stopPropagation();
|
||||
onBack?.();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if icon}
|
||||
{@render icon()}
|
||||
{/if}
|
||||
|
||||
<span class="truncate">{label}</span>
|
||||
|
||||
{#if loading}
|
||||
<Loader2 class="h-3 w-3 shrink-0 animate-spin" />
|
||||
{:else if error}
|
||||
<CircleAlert class="h-3 w-3 shrink-0 text-destructive" />
|
||||
{/if}
|
||||
|
||||
{#if count !== undefined}
|
||||
<span class="shrink-0 text-xs text-muted-foreground/70">{count}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{@render children()}
|
||||
</CollapsibleSection>
|
||||
@@ -0,0 +1,405 @@
|
||||
<script lang="ts">
|
||||
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
|
||||
import { ChevronDown, Loader2 } from '@lucide/svelte';
|
||||
import {
|
||||
ModelId,
|
||||
ModelsSelectorDrawer,
|
||||
ModelsSelectorDropdown,
|
||||
ModelsSelectorList,
|
||||
ModelsSelectorOption,
|
||||
ModelsSelectorTriggerIcon
|
||||
} from '$lib/components/app';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { DROPDOWN_MENU_CONTENT_SEARCH_SELECTOR, MODEL_ICON, SETTINGS_KEYS } from '$lib/constants';
|
||||
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
|
||||
import { useChatFormModel } from '$lib/hooks/use-chat-form-model.svelte';
|
||||
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
|
||||
import { deviceStore, modelsStore, serverStore, settingsStore } from '$lib/stores';
|
||||
import { type ModelItem, modelLoadFraction } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
/** Model to show, when the caller owns it, e.g. the model of one message. */
|
||||
currentModel?: string | null;
|
||||
disabled?: boolean;
|
||||
forceForegroundText?: boolean;
|
||||
onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
|
||||
useGlobalSelection?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
currentModel = null,
|
||||
disabled = false,
|
||||
forceForegroundText = false,
|
||||
onModelChange,
|
||||
useGlobalSelection = false
|
||||
}: Props = $props();
|
||||
|
||||
// one setting for every model id in the selector: the trigger and the rows
|
||||
let showOrgName = $derived(settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME] ?? true);
|
||||
|
||||
// a phone opens the picker in a drawer, a desktop keeps the anchored dropdown
|
||||
let isMobile = $derived(deviceStore.isMobile);
|
||||
let isOffline = $derived(!!serverStore.error);
|
||||
|
||||
let isOpen = $state(false);
|
||||
let highlightedId = $state<string | null>(null);
|
||||
|
||||
const formModel = useChatFormModel();
|
||||
|
||||
// the store's pick wins while it differs from the conversation's model: the
|
||||
// trigger follows the model the user just picked, whose load state and
|
||||
// progress it reports, not the one the last answer came from
|
||||
let selectorModel = $derived.by(() => {
|
||||
const storeModel = modelsStore.selectedModelName;
|
||||
|
||||
if (storeModel && storeModel !== formModel.conversationModel) {
|
||||
return storeModel;
|
||||
}
|
||||
|
||||
return formModel.conversationModel;
|
||||
});
|
||||
|
||||
// the caller's model comes first, then the pick, then the conversation's
|
||||
let displayModel = $derived(currentModel ?? selectorModel);
|
||||
|
||||
const ms = useModelsSelector({
|
||||
currentModel: () => displayModel,
|
||||
onModelChange: () => onModelChange,
|
||||
onOpenChange: (open) => {
|
||||
isOpen = open;
|
||||
highlightedId = null;
|
||||
},
|
||||
useGlobalSelection: () => useGlobalSelection
|
||||
});
|
||||
|
||||
const selectedOption = $derived(ms.getDisplayOption());
|
||||
const triggerModel = $derived(selectedOption?.model ?? null);
|
||||
|
||||
let triggerStatus = $derived(
|
||||
triggerModel
|
||||
? modelsStore.routerModels.find((m) => m.id === triggerModel)?.status?.value
|
||||
: undefined
|
||||
);
|
||||
let triggerLoading = $derived(
|
||||
!!triggerModel &&
|
||||
(triggerStatus === ServerModelStatus.LOADING ||
|
||||
modelsStore.status.isOperationInProgress(triggerModel))
|
||||
);
|
||||
let triggerLoadPercent = $derived(
|
||||
triggerModel && triggerLoading
|
||||
? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
|
||||
: 0
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
void ms.searchTerm;
|
||||
highlightedId = null;
|
||||
});
|
||||
|
||||
// Keyboard navigation follows the on-screen row order, not the flat option list order.
|
||||
let visualOrder = $derived.by(() => {
|
||||
const order: string[] = [];
|
||||
|
||||
for (const item of ms.favoriteItems) order.push(item.option.id);
|
||||
for (const group of ms.groupedFilteredOptions.available) {
|
||||
for (const item of group.items) order.push(item.option.id);
|
||||
}
|
||||
|
||||
return order;
|
||||
});
|
||||
|
||||
let highlightedIndex = $derived(highlightedId ? visualOrder.indexOf(highlightedId) : -1);
|
||||
|
||||
function moveHighlight(direction: 1 | -1) {
|
||||
const len = visualOrder.length;
|
||||
|
||||
if (len === 0) {
|
||||
highlightedId = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let index = highlightedIndex;
|
||||
|
||||
if (index === -1) {
|
||||
index = direction === 1 ? 0 : len - 1;
|
||||
} else {
|
||||
index = (index + direction + len) % len;
|
||||
}
|
||||
|
||||
highlightedId = visualOrder[index];
|
||||
}
|
||||
|
||||
// Alt+Enter only unloads and keeps the picker open.
|
||||
async function handleModelKeyAction(modelId: string, unload: boolean) {
|
||||
if (!unload) {
|
||||
void ms.handleSelect(modelId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const status = modelsStore.getModelStatus(modelId);
|
||||
|
||||
if (status === ServerModelStatus.LOADING) return;
|
||||
|
||||
await modelsStore.status.unload(modelId);
|
||||
}
|
||||
|
||||
function handleSearchKeyDown(event: KeyboardEvent) {
|
||||
if (event.isComposing) return;
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||
event.preventDefault();
|
||||
moveHighlight(1);
|
||||
} else if (event.key === KeyboardKey.ARROW_UP) {
|
||||
event.preventDefault();
|
||||
moveHighlight(-1);
|
||||
} else if (event.key === KeyboardKey.ENTER) {
|
||||
event.preventDefault();
|
||||
|
||||
if (highlightedId) {
|
||||
void handleModelKeyAction(highlightedId, event.altKey);
|
||||
} else if (visualOrder.length > 0) {
|
||||
highlightedId = visualOrder[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bits-ui auto-focuses the opened content, which can yank the page scroll: the
|
||||
// content prevents that and this focuses the search input instead
|
||||
$effect(() => {
|
||||
if (!isOpen || isMobile) return;
|
||||
|
||||
let frames = 0;
|
||||
let handle = requestAnimationFrame(function focusSearch() {
|
||||
const input = document.querySelector<HTMLElement>(DROPDOWN_MENU_CONTENT_SEARCH_SELECTOR);
|
||||
|
||||
if (input) {
|
||||
input.focus({ preventScroll: true });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (frames++ < 20) handle = requestAnimationFrame(focusSearch);
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(handle);
|
||||
});
|
||||
|
||||
export function open() {
|
||||
ms.handleOpenChange(true);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet selectorTriggerInner()}
|
||||
<ModelsSelectorTriggerIcon
|
||||
class="mr-0.375 size-3.5 max-md:size-4 shrink-0"
|
||||
option={selectedOption}
|
||||
/>
|
||||
|
||||
<span class="flex min-w-0 items-center gap-0.5">
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
class="min-w-0 overflow-hidden"
|
||||
hideOrgName={!showOrgName}
|
||||
hideQuantization
|
||||
modelId={selectedOption.model}
|
||||
/>
|
||||
{:else}
|
||||
<span class="min-w-0 font-medium">Select model</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if ms.updating || ms.isLoadingModel || triggerLoading}
|
||||
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
{:else}
|
||||
<ChevronDown class="h-3 w-3.5 shrink-0" />
|
||||
{/if}
|
||||
|
||||
{#if triggerLoading}
|
||||
<ModelLoadHighlight percent={triggerLoadPercent} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = ms.activeId === option.id}
|
||||
{@const isHighlighted = option.id === highlightedId}
|
||||
{@const isFav = ms.isFavorite(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
{hideOrgName}
|
||||
{isFav}
|
||||
{isHighlighted}
|
||||
{isSelected}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
|
||||
event.preventDefault();
|
||||
void handleModelKeyAction(option.id, event.altKey);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => (highlightedId = option.id)}
|
||||
onSelect={ms.handleSelect}
|
||||
{option}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet pickerList(listClass: string)}
|
||||
<div class={['models-list', listClass]}>
|
||||
{#if !ms.isCurrentModelInCache && displayModel}
|
||||
<!-- Show unavailable model as first option (disabled) -->
|
||||
<button
|
||||
aria-disabled="true"
|
||||
aria-selected="true"
|
||||
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
|
||||
disabled
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<ModelId
|
||||
class="flex-1"
|
||||
hideOrgName={!showOrgName}
|
||||
hideQuantization
|
||||
modelId={displayModel}
|
||||
/>
|
||||
|
||||
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if ms.isEmpty}
|
||||
<p class="px-4 py-3 text-sm text-muted-foreground">{ms.emptyMessage}</p>
|
||||
{/if}
|
||||
|
||||
<ModelsSelectorList
|
||||
activeId={ms.activeId}
|
||||
currentModel={displayModel}
|
||||
favorites={ms.favoriteItems}
|
||||
groups={ms.groupedFilteredOptions}
|
||||
loaded={ms.loadedItems}
|
||||
onSelect={ms.handleSelect}
|
||||
renderOption={modelOption}
|
||||
sectionHeaderClass="[&:not(:first-child)]:mt-2 mb-1 px-2 py-2.5 text-sm font-semibold text-foreground/80 select-none"
|
||||
{showOrgName}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if ms.loading && ms.options.length === 0 && ms.isMultiModel}
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
|
||||
Loading models...
|
||||
</div>
|
||||
{:else if ms.options.length === 0 && ms.isMultiModel}
|
||||
{#if displayModel}
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs text-muted-foreground"
|
||||
style="max-width: min(calc(100cqw - 10rem), 48rem)"
|
||||
>
|
||||
<MODEL_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground">No models yet.</span>
|
||||
{/if}
|
||||
{:else if ms.isMultiModel}
|
||||
{#if isMobile}
|
||||
<button
|
||||
class="relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 max-md:gap-1.5 max-md:h-7 max-md:px-2.25 max-md:py-1 max-md:text-[13px] focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground"
|
||||
disabled={disabled || ms.updating}
|
||||
onclick={() => ms.handleOpenChange(true)}
|
||||
style="max-width: min(calc(100vw-4rem), 32rem)"
|
||||
type="button"
|
||||
>
|
||||
{@render selectorTriggerInner()}
|
||||
</button>
|
||||
|
||||
<ModelsSelectorDrawer
|
||||
bind:open={isOpen}
|
||||
emptyMessage={ms.emptyMessage}
|
||||
isEmpty={ms.isEmpty && ms.isCurrentModelInCache}
|
||||
onOpenChange={ms.handleOpenChange}
|
||||
onSearchChange={(v) => ms.setSearchTerm(v)}
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
searchTerm={ms.searchTerm}
|
||||
>
|
||||
{@render pickerList('px-2.5')}
|
||||
</ModelsSelectorDrawer>
|
||||
{:else}
|
||||
<ModelsSelectorDropdown
|
||||
bind:open={isOpen}
|
||||
currentModel={displayModel}
|
||||
disabled={disabled || isOffline}
|
||||
{highlightedId}
|
||||
{ms}
|
||||
onHighlight={(id) => (highlightedId = id)}
|
||||
onModelKeyAction={(id, unload) => void handleModelKeyAction(id, unload)}
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
{showOrgName}
|
||||
/>
|
||||
{/if}
|
||||
{:else if isMobile}
|
||||
<!-- a phone has no manager: the single model still gets the drawer, so its
|
||||
load control, actions and information stay reachable -->
|
||||
<button
|
||||
class="relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 max-md:gap-1.5 max-md:h-7 max-md:px-2.25 max-md:py-1 max-md:text-[13px] focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground"
|
||||
disabled={disabled || ms.updating}
|
||||
onclick={() => ms.handleOpenChange(true)}
|
||||
style="max-width: min(calc(100vw-4rem), 32rem)"
|
||||
type="button"
|
||||
>
|
||||
{@render selectorTriggerInner()}
|
||||
</button>
|
||||
|
||||
<ModelsSelectorDrawer
|
||||
bind:open={isOpen}
|
||||
emptyMessage={ms.emptyMessage}
|
||||
isEmpty={ms.isEmpty && ms.isCurrentModelInCache}
|
||||
onOpenChange={ms.handleOpenChange}
|
||||
onSearchChange={(v) => ms.setSearchTerm(v)}
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
searchTerm={ms.searchTerm}
|
||||
>
|
||||
{@render pickerList('px-2.5')}
|
||||
</ModelsSelectorDrawer>
|
||||
{:else}
|
||||
<!-- a lone llama.cpp server has nothing to choose from: the trigger opens the
|
||||
manager instead, so the button is its own affordance -->
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<!-- prevent another nested button element -->
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
class={[
|
||||
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
|
||||
!ms.isCurrentModelInCache
|
||||
? 'bg-red-400/10 text-red-400! hover:bg-red-400/20 hover:text-red-400'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: 'text-foreground',
|
||||
isOpen && 'text-foreground'
|
||||
]}
|
||||
disabled={disabled || ms.updating}
|
||||
onclick={() => ms.handleOpenChange(true)}
|
||||
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
|
||||
>
|
||||
<ModelsSelectorTriggerIcon class="mr-0.375 size-3.5 shrink-0" option={selectedOption} />
|
||||
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
class="min-w-0 overflow-hidden"
|
||||
hideOrgName={!showOrgName}
|
||||
hideQuantization
|
||||
modelId={selectedOption.model}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if ms.updating}
|
||||
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import ModelDownloadProgressBar from '../ModelDownloadProgressBar.svelte';
|
||||
import ModelOrgAvatar from '../ModelOrgAvatar.svelte';
|
||||
import { Loader2, Pause, Play, X } from '@lucide/svelte';
|
||||
import { ActionIcon, ModelId } from '$lib/components/app';
|
||||
import { HuggingFaceService, ModelsService } from '$lib/services';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import type { ModelDownloadEntry } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
/** One entry from the status feed: an in-flight or paused download. */
|
||||
entry: ModelDownloadEntry;
|
||||
/** Ask the list to confirm cancelling; the list owns the single dialog. */
|
||||
onRequestCancel?: (repoWithTag: string) => void;
|
||||
/** Show the organization name in the repo id, as the other selector rows do. */
|
||||
showOrgName?: boolean;
|
||||
}
|
||||
|
||||
let { entry, onRequestCancel, showOrgName = true }: Props = $props();
|
||||
|
||||
let percent = $derived(
|
||||
entry.progress && entry.progress.totalBytes > 0
|
||||
? Math.round((entry.progress.downloadedBytes / entry.progress.totalBytes) * 100)
|
||||
: null
|
||||
);
|
||||
|
||||
let actionText = $derived(entry.isPaused ? 'Resume downloading' : 'Pause downloading');
|
||||
|
||||
// the base model org resolves lazily via HF when unknown
|
||||
let orgName = $derived(ModelsService.parseModelId(entry.repoWithTag).orgName);
|
||||
let fetchedBaseModelOrg = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const repoWithTag = entry.repoWithTag;
|
||||
|
||||
fetchedBaseModelOrg = null;
|
||||
|
||||
// avatars hidden with the Hub metadata setting off: no lookups at all
|
||||
if (!HuggingFaceService.isEnabled()) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void HuggingFaceService.getBaseModel(repoWithTag)
|
||||
.then((base) => {
|
||||
if (!cancelled && base?.org) fetchedBaseModelOrg = base.org;
|
||||
})
|
||||
// best-effort lookup: offline or unknown repos keep the repo org
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- One in-flight download; same actions as the discover quant chips: the row
|
||||
itself pauses / resumes, the trailing X cancels (stops and discards the
|
||||
partial files). Both affordances fade in on hover, the slots are reserved
|
||||
so the list never reflows. -->
|
||||
<div
|
||||
class="group relative flex items-center gap-2 rounded-sm p-2 text-left text-sm transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<button
|
||||
aria-label={actionText}
|
||||
class="flex min-w-0 flex-1 cursor-pointer items-center gap-2 text-left"
|
||||
onclick={() => {
|
||||
if (entry.isPaused) {
|
||||
void modelsStore.status
|
||||
.downloadModel(entry.repoWithTag)
|
||||
.catch((error) => console.warn('Failed to resume the download:', error));
|
||||
} else {
|
||||
void modelsStore.status.pauseDownload(entry.repoWithTag);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{#if orgName}
|
||||
<ModelOrgAvatar
|
||||
org={fetchedBaseModelOrg ?? orgName}
|
||||
quantOrg={orgName}
|
||||
quantPositionClass="-bottom-1 -right-1"
|
||||
quantSize="h-3 w-3"
|
||||
size="h-6 w-6"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ModelId class="flex-1" hideOrgName={!showOrgName} modelId={entry.repoWithTag} showRawTooltip />
|
||||
|
||||
{#if percent !== null}
|
||||
<span class="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">{percent}%</span>
|
||||
{:else if entry.isPaused}
|
||||
<span class="shrink-0 text-xs text-muted-foreground">Paused</span>
|
||||
{/if}
|
||||
|
||||
<!-- status action: spinner -> pause on hover while in flight, play on hover
|
||||
when paused; opacity only for the spinner, the spin owns the transform -->
|
||||
<span class="relative inline-flex h-5 w-5 shrink-0 items-center justify-center">
|
||||
{#if entry.isPaused}
|
||||
<Play
|
||||
class="absolute h-4 w-4 scale-75 opacity-0 transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-100 group-hover:opacity-100 [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
|
||||
/>
|
||||
{:else}
|
||||
<Loader2
|
||||
class="absolute h-4 w-4 animate-spin text-muted-foreground transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:opacity-0 [@media(pointer:coarse)]:hidden"
|
||||
/>
|
||||
|
||||
<Pause
|
||||
class="absolute h-4 w-4 opacity-0 transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- the row actions keep the sizing and reveal of the other selector rows -->
|
||||
<ActionIcon
|
||||
ariaLabel="Cancel downloading"
|
||||
class="pointer-events-none h-5 w-5 shrink-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 hover:text-destructive [@media(pointer:coarse)]:pointer-events-auto [@media(pointer:coarse)]:opacity-100"
|
||||
icon={X}
|
||||
iconSize="h-4 w-4"
|
||||
onclick={() => onRequestCancel?.(entry.repoWithTag)}
|
||||
/>
|
||||
|
||||
{#if entry.progress && entry.progress.totalBytes > 0}
|
||||
<ModelDownloadProgressBar
|
||||
downloadedBytes={entry.progress.downloadedBytes}
|
||||
overlay
|
||||
totalBytes={entry.progress.totalBytes}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenuSearchable } from '$lib/components/app';
|
||||
import { Drawer, DrawerContent } from '$lib/components/ui/drawer';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
emptyMessage?: string;
|
||||
/** Sticky picker footer: it sticks to the bottom of the drawer's scrollport. */
|
||||
footer?: Snippet;
|
||||
isEmpty?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onSearchChange?: (value: string) => void;
|
||||
onSearchKeyDown?: (event: KeyboardEvent) => void;
|
||||
open?: boolean;
|
||||
placeholder?: string;
|
||||
searchTerm?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
children,
|
||||
emptyMessage = 'No items found',
|
||||
footer,
|
||||
isEmpty = false,
|
||||
onOpenChange,
|
||||
onSearchChange,
|
||||
onSearchKeyDown,
|
||||
open = $bindable(false),
|
||||
placeholder = 'Search models...',
|
||||
searchTerm = ''
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Drawer bind:open {onOpenChange}>
|
||||
<DrawerContent class="overflow-hidden">
|
||||
<!-- the wrapper is the scrollport: the search header and the footer stick to it -->
|
||||
<div class="max-h-[min(40rem,calc(100dvh-5rem))] min-h-0 overflow-y-auto">
|
||||
<DropdownMenuSearchable
|
||||
{emptyMessage}
|
||||
{footer}
|
||||
headerClass="p-2.5 pt-4"
|
||||
{isEmpty}
|
||||
{onSearchChange}
|
||||
{onSearchKeyDown}
|
||||
{placeholder}
|
||||
searchClass="bg-transparent"
|
||||
searchValue={searchTerm}
|
||||
>
|
||||
{@render children()}
|
||||
</DropdownMenuSearchable>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
@@ -0,0 +1,248 @@
|
||||
<script lang="ts">
|
||||
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
|
||||
import { ChevronDown, Loader2 } from '@lucide/svelte';
|
||||
import {
|
||||
DropdownMenuSearchable,
|
||||
ModelId,
|
||||
ModelsSelectorList,
|
||||
ModelsSelectorOption,
|
||||
ModelsSelectorTriggerIcon
|
||||
} from '$lib/components/app';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { MODEL_ICON } from '$lib/constants';
|
||||
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
|
||||
import type { UseModelsSelectorReturn } from '$lib/hooks/use-models-selector.svelte';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import { modelsStore, uiStore } from '$lib/stores';
|
||||
import type { ModelOption, ModelSidecarBadge } from '$lib/types/models';
|
||||
import type { ModelItem } from '$lib/utils';
|
||||
import { modelLoadFraction, repoOf } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
/** The selector state this shell renders; the owner derives it once. */
|
||||
ms: UseModelsSelectorReturn;
|
||||
currentModel?: string | null;
|
||||
disabled?: boolean;
|
||||
/** Model id the list highlights from the keyboard; the owner tracks it. */
|
||||
highlightedId?: string | null;
|
||||
/** Bind the menu's open state, so the owner sees the close too. */
|
||||
open?: boolean;
|
||||
showOrgName?: boolean;
|
||||
onHighlight?: (id: string | null) => void;
|
||||
onManageModels?: () => void;
|
||||
onModelKeyAction?: (modelId: string, unload: boolean) => void;
|
||||
onSearchKeyDown?: (event: KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
currentModel = null,
|
||||
disabled = false,
|
||||
highlightedId = null,
|
||||
ms,
|
||||
onHighlight,
|
||||
onManageModels,
|
||||
onModelKeyAction,
|
||||
onSearchKeyDown,
|
||||
open = $bindable(false),
|
||||
showOrgName = true
|
||||
}: Props = $props();
|
||||
|
||||
const selectedOption = $derived(ms.getDisplayOption());
|
||||
const triggerModel = $derived(selectedOption?.model ?? null);
|
||||
|
||||
let triggerStatus = $derived(
|
||||
triggerModel
|
||||
? modelsStore.routerModels.find((m) => m.id === triggerModel)?.status?.value
|
||||
: undefined
|
||||
);
|
||||
let triggerLoading = $derived(
|
||||
!!triggerModel &&
|
||||
(triggerStatus === ServerModelStatus.LOADING ||
|
||||
modelsStore.status.isOperationInProgress(triggerModel))
|
||||
);
|
||||
let triggerLoadPercent = $derived(
|
||||
triggerModel && triggerLoading
|
||||
? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
|
||||
: 0
|
||||
);
|
||||
|
||||
/** Draft sidecar as it reads in the trigger tooltip, with its own quant. */
|
||||
function draftSidecarLabel(baseModel: string, badge: ModelSidecarBadge): string {
|
||||
const baseRepo = repoOf(baseModel);
|
||||
|
||||
// a sidecar of the model's own repo reads as a bare tag, a foreign one keeps its id
|
||||
if (badge.repo === baseRepo) {
|
||||
return `${badge.kind.toUpperCase()}${badge.quant ? `:${badge.quant}` : ''}`;
|
||||
}
|
||||
|
||||
return ModelsService.buildDownloadTag(badge.repo, badge.quant, badge.kind);
|
||||
}
|
||||
|
||||
/** Raw id of the selected model, plus every draft sidecar it pulls. */
|
||||
function triggerTooltipLabel(option: ModelOption): string {
|
||||
const drafts = (option.draftSidecars ?? []).map((badge) =>
|
||||
draftSidecarLabel(option.model, badge)
|
||||
);
|
||||
|
||||
return [option.model, ...drafts].join(' + ');
|
||||
}
|
||||
|
||||
function handleManageModels() {
|
||||
open = false;
|
||||
|
||||
// let the menu finish closing before the dialog takes focus
|
||||
setTimeout(() => (onManageModels ? onManageModels() : uiStore.openModelsManager()), 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet selectorTriggerInner()}
|
||||
<ModelsSelectorTriggerIcon class="mr-0.375 size-3.5 shrink-0" option={selectedOption} />
|
||||
|
||||
<span class="flex min-w-0 items-center gap-0.5">
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
class="min-w-0 overflow-hidden"
|
||||
hideOrgName={!showOrgName}
|
||||
hideQuantization
|
||||
modelId={selectedOption.model}
|
||||
/>
|
||||
{:else}
|
||||
<span class="min-w-0 font-medium">Select model</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if ms.updating || ms.isLoadingModel || triggerLoading}
|
||||
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
{:else}
|
||||
<ChevronDown class="h-3 w-3.5 shrink-0" />
|
||||
{/if}
|
||||
|
||||
{#if triggerLoading}
|
||||
<ModelLoadHighlight percent={triggerLoadPercent} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
|
||||
{@const isHighlighted = option.id === highlightedId}
|
||||
{@const isFav = ms.isFavorite(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
{hideOrgName}
|
||||
{isFav}
|
||||
{isHighlighted}
|
||||
{isSelected}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
|
||||
event.preventDefault();
|
||||
onModelKeyAction?.(option.id, event.altKey);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => onHighlight?.(option.id)}
|
||||
onSelect={ms.handleSelect}
|
||||
{option}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<DropdownMenu.Root bind:open onOpenChange={ms.handleOpenChange}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<!-- prevent another nested button element -->
|
||||
{#snippet child({ props })}
|
||||
<DropdownMenu.Trigger
|
||||
{...props}
|
||||
class={[
|
||||
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 max-md:h-8 max-md:px-2.25 max-md:py-1.25 max-md:text-[13px] focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
|
||||
!ms.isCurrentModelInCache
|
||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
||||
: 'text-foreground',
|
||||
open && 'text-foreground',
|
||||
'max-w-[min(calc(100vw-4rem) md:max-w-[min(calc(100cqw-9rem),25rem)]'
|
||||
]}
|
||||
disabled={disabled || ms.updating}
|
||||
>
|
||||
{@render selectorTriggerInner()}
|
||||
</DropdownMenu.Trigger>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
{#if selectedOption}
|
||||
<Tooltip.Content>
|
||||
<p class="font-mono">{triggerTooltipLabel(selectedOption)}</p>
|
||||
</Tooltip.Content>
|
||||
{/if}
|
||||
</Tooltip.Root>
|
||||
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
class="w-full md:min-w-80 md:w-112 max-w-[calc(100vw-2rem)] p-0! max-h-[min(40rem,calc(var(--bits-dropdown-menu-content-available-height)-1rem))]"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuSearchable
|
||||
emptyMessage={ms.emptyMessage}
|
||||
isEmpty={ms.isEmpty && ms.isCurrentModelInCache}
|
||||
onSearchChange={(v) => ms.setSearchTerm(v)}
|
||||
{onSearchKeyDown}
|
||||
placeholder="Search models..."
|
||||
searchClass="bg-transparent"
|
||||
searchValue={ms.searchTerm}
|
||||
>
|
||||
<!-- Option list; the search header sticks to the top and the actions
|
||||
footer to the bottom of the content scrollport. -->
|
||||
{@render pickerContent('px-1.5')}
|
||||
|
||||
{#snippet footer()}
|
||||
<DropdownMenu.Group class="px-2">
|
||||
<DropdownMenu.Item class="gap-2" onSelect={handleManageModels}>
|
||||
<MODEL_ICON class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
|
||||
Manage models
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
{/snippet}
|
||||
</DropdownMenuSearchable>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
{#snippet pickerContent(listClass: string)}
|
||||
<div class={['models-list', listClass]}>
|
||||
{#if !ms.isCurrentModelInCache && currentModel}
|
||||
<!-- Show unavailable model as first option (disabled) -->
|
||||
<button
|
||||
aria-disabled="true"
|
||||
aria-selected="true"
|
||||
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
|
||||
disabled
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<ModelId
|
||||
class="flex-1"
|
||||
hideOrgName={!showOrgName}
|
||||
hideQuantization
|
||||
modelId={currentModel}
|
||||
/>
|
||||
|
||||
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if ms.isEmpty}
|
||||
<p class="px-4 py-3 text-sm text-muted-foreground">{ms.emptyMessage}</p>
|
||||
{/if}
|
||||
|
||||
<ModelsSelectorList
|
||||
activeId={ms.activeId}
|
||||
{currentModel}
|
||||
favorites={ms.favoriteItems}
|
||||
groups={ms.groupedFilteredOptions}
|
||||
loaded={ms.loadedItems}
|
||||
onSelect={ms.handleSelect}
|
||||
renderOption={modelOption}
|
||||
sectionHeaderClass="[&:not(:first-child)]:mt-1 mb-1 px-2 py-2 text-[13px] font-semibold text-foreground/80 select-none"
|
||||
{showOrgName}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
@@ -0,0 +1,235 @@
|
||||
<script lang="ts">
|
||||
import ModelsSelectorDownloadItem from './ModelsSelectorDownloadItem.svelte';
|
||||
import { Heart, Power } from '@lucide/svelte';
|
||||
import {
|
||||
GroupedList,
|
||||
ModelAvatar,
|
||||
ModelsSection,
|
||||
ModelsSelectorOption
|
||||
} from '$lib/components/app';
|
||||
import { DialogConfirmDownload } from '$lib/components/app/dialogs';
|
||||
import Logo from '$lib/components/app/misc/Logo.svelte';
|
||||
import {
|
||||
LOCAL_BACKEND_ID,
|
||||
MODEL_ROW_WINDOW,
|
||||
SELECTOR_GROUP_STICKY_OFFSET,
|
||||
SETTINGS_KEYS
|
||||
} from '$lib/constants';
|
||||
import { ModelDownloadConfirmAction } from '$lib/enums';
|
||||
import { modelsStore, settingsStore } from '$lib/stores';
|
||||
import {
|
||||
type GroupedModelOptions,
|
||||
groupModelFamilies,
|
||||
type ModelFamilyGroup,
|
||||
type ModelItem,
|
||||
windowLocalGroups
|
||||
} from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
groups: GroupedModelOptions;
|
||||
currentModel: string | null;
|
||||
activeId: string | null;
|
||||
sectionHeaderClass?: string;
|
||||
onSelect: (modelId: string) => void;
|
||||
renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
|
||||
/** Favorite models of every backend, listed in their own section. */
|
||||
favorites?: ModelItem[];
|
||||
/** Loaded models of every llama-compat backend, leading the list. */
|
||||
loaded?: ModelItem[];
|
||||
/** Show the organization name in every model id of the list. */
|
||||
showOrgName?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
activeId,
|
||||
currentModel,
|
||||
favorites = [],
|
||||
groups,
|
||||
loaded = [],
|
||||
onSelect,
|
||||
renderOption,
|
||||
sectionHeaderClass = 'm-0 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none',
|
||||
showOrgName = true
|
||||
}: Props = $props();
|
||||
let render = $derived(renderOption ?? defaultOption);
|
||||
// a large catalog mounts a window at a time: the sentinel at the end of the list
|
||||
// grows the window when it scrolls into view
|
||||
let visibleCount = $state(MODEL_ROW_WINDOW);
|
||||
let sentinelEl = $state<HTMLElement | null>(null);
|
||||
const localGroups = $derived(windowLocalGroups(groups, visibleCount));
|
||||
const localRowCount = $derived(
|
||||
groups.available.reduce((count, group) => count + group.items.length, 0)
|
||||
);
|
||||
const hasMoreLocal = $derived(localGroups.shown < localRowCount);
|
||||
|
||||
$effect(() => {
|
||||
const sentinel = sentinelEl;
|
||||
|
||||
if (!sentinel || !hasMoreLocal) return;
|
||||
|
||||
// scroll does not bubble, so listen in the capture phase and ask the
|
||||
// sentinel where it is instead of guessing which ancestor scrolls
|
||||
const onScroll = () => {
|
||||
const rect = sentinel.getBoundingClientRect();
|
||||
|
||||
// grow only when the end of the list is on screen: otherwise every scroll
|
||||
// event of the page would mount the whole catalog
|
||||
if (rect.top > window.innerHeight || rect.bottom < 0) return;
|
||||
|
||||
visibleCount += MODEL_ROW_WINDOW;
|
||||
};
|
||||
|
||||
document.addEventListener('scroll', onScroll, { capture: true, passive: true });
|
||||
onScroll();
|
||||
|
||||
return () => document.removeEventListener('scroll', onScroll, { capture: true });
|
||||
});
|
||||
// section headers stick below the dropdown's search block: `--dropdown-sticky-height`
|
||||
// comes from DropdownMenuSearchable and falls back to 0 in the mobile sheet
|
||||
let headerClass = $derived(`${sectionHeaderClass} sticky z-10 bg-popover`);
|
||||
const headerStyle = 'top: var(--dropdown-sticky-height, 0px)';
|
||||
|
||||
/** In-flight / paused downloads, tracked by the status feed. */
|
||||
// only a running download belongs in the selector; a paused one waits in the
|
||||
// manager table, where its partial files are resumed or dropped
|
||||
let downloadEntries = $derived(
|
||||
modelsStore.status.getDownloadEntries().filter((entry) => !entry.isPaused)
|
||||
);
|
||||
|
||||
// cancel is confirmed once for the whole list, so one dialog serves every row
|
||||
// The target is kept while the dialog closes so its copy stays rendered.
|
||||
let pendingCancel = $state('');
|
||||
let cancelOpen = $state(false);
|
||||
|
||||
function requestCancel(repoWithTag: string) {
|
||||
pendingCancel = repoWithTag;
|
||||
cancelOpen = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet familyHeading({ group: family }: { group: ModelFamilyGroup<ModelItem> })}
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 select-none max-md:py-2.5">
|
||||
<ModelAvatar
|
||||
option={family.entries[0].option}
|
||||
showBaseModelAvatar
|
||||
showQuantBadge={false}
|
||||
size="size-6"
|
||||
/>
|
||||
|
||||
<span class="truncate text-[13px] font-semibold text-muted-foreground/70">
|
||||
{family.label}
|
||||
</span>
|
||||
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{family.entries.length} model{family.entries.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet listItem({ depth, entry }: { depth: number; entry: ModelItem })}
|
||||
<div style="padding-left: {depth * 16}px">{@render render(entry, !showOrgName)}</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet listRows(items: ModelItem[], prefix: string)}
|
||||
{#if settingsStore.config[SETTINGS_KEYS.GROUP_MODELS_BY_FAMILY]}
|
||||
<GroupedList
|
||||
group={familyHeading}
|
||||
groups={groupModelFamilies(items, (row) => row.option.model).map((family) => ({
|
||||
entries: family.entries,
|
||||
group: family,
|
||||
key: `${prefix}-${family.key}`
|
||||
}))}
|
||||
initialCollapsed={modelsStore.collapsedGroupsUnder(prefix)}
|
||||
item={listItem}
|
||||
keyOf={(row) => `${prefix}-${row.option.id}`}
|
||||
onCollapsedChange={(key, collapsed) => modelsStore.setGroupCollapsed(prefix, key, collapsed)}
|
||||
stickyStyle={SELECTOR_GROUP_STICKY_OFFSET}
|
||||
/>
|
||||
{:else}
|
||||
<GroupedList item={listItem} {items} keyOf={(row) => `${prefix}-${row.option.id}`} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || activeId === option.id}
|
||||
{@const isFav = modelsStore.favoriteModelIds.has(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
{hideOrgName}
|
||||
{isFav}
|
||||
isHighlighted={false}
|
||||
{isSelected}
|
||||
onKeyDown={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
{onSelect}
|
||||
{option}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#if loaded.length > 0}
|
||||
<ModelsSection
|
||||
count={loaded.length}
|
||||
label="Loaded models"
|
||||
persistKey="loaded"
|
||||
revealChevronOnHover
|
||||
sticky
|
||||
>
|
||||
{#snippet icon()}
|
||||
<Power class="h-3.5 w-3.5 shrink-0" />
|
||||
{/snippet}
|
||||
|
||||
{#each loaded as item (item.option.id)}
|
||||
{@render render(item, !showOrgName)}
|
||||
{/each}
|
||||
</ModelsSection>
|
||||
{/if}
|
||||
|
||||
{#if favorites.length > 0}
|
||||
<!-- Favorites come first; the sections below skip them -->
|
||||
<ModelsSection label="Favorites" persistKey="favorites" revealChevronOnHover sticky>
|
||||
{#snippet icon()}
|
||||
<Heart class="h-3.5 w-3.5 shrink-0" />
|
||||
{/snippet}
|
||||
|
||||
{#each favorites as item (`fav-${item.option.id}`)}
|
||||
{@render render(item, !showOrgName)}
|
||||
{/each}
|
||||
</ModelsSection>
|
||||
{/if}
|
||||
|
||||
{#if downloadEntries.length > 0}
|
||||
<p class={headerClass} style={headerStyle}>Downloading</p>
|
||||
|
||||
{#each downloadEntries as entry (entry.repoWithTag)}
|
||||
<ModelsSelectorDownloadItem {entry} onRequestCancel={requestCancel} {showOrgName} />
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#snippet localRows()}
|
||||
{@render listRows(
|
||||
localGroups.available.flatMap((group) => group.items),
|
||||
'local'
|
||||
)}
|
||||
|
||||
{#if hasMoreLocal}
|
||||
<div bind:this={sentinelEl} aria-hidden="true" class="h-px"></div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if groups.available.length > 0}
|
||||
<ModelsSection label="Local models" persistKey={LOCAL_BACKEND_ID} revealChevronOnHover sticky>
|
||||
{#snippet icon()}
|
||||
<Logo class="shrink-0" style="--size: 0.875rem" />
|
||||
{/snippet}
|
||||
|
||||
{@render localRows()}
|
||||
</ModelsSection>
|
||||
{/if}
|
||||
|
||||
<DialogConfirmDownload
|
||||
action={ModelDownloadConfirmAction.CANCEL}
|
||||
onClose={() => (cancelOpen = false)}
|
||||
open={cancelOpen}
|
||||
repoWithTag={pendingCancel}
|
||||
/>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
|
||||
import {
|
||||
ModelAvatar,
|
||||
ModelCapabilities,
|
||||
ModelId,
|
||||
ModelLoadControl,
|
||||
ModelRowActions
|
||||
} from '$lib/components/app';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import { modelsStore, settingsStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { modelLoadFraction, modelLoadProgressText } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
option: ModelOption;
|
||||
isSelected: boolean;
|
||||
isHighlighted: boolean;
|
||||
isFav: boolean;
|
||||
hideOrgName?: boolean;
|
||||
onSelect: (modelId: string) => void;
|
||||
onMouseEnter: () => void;
|
||||
onKeyDown: (e: KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
hideOrgName = false,
|
||||
isFav,
|
||||
isHighlighted,
|
||||
isSelected,
|
||||
onKeyDown,
|
||||
onMouseEnter,
|
||||
onSelect,
|
||||
option
|
||||
}: Props = $props();
|
||||
|
||||
let serverStatus = $derived(modelsStore.getModelStatus(option.model));
|
||||
let isOperationInProgress = $derived(modelsStore.status.isOperationInProgress(option.model));
|
||||
let isFailed = $derived(serverStatus === ServerModelStatus.FAILED);
|
||||
let isSleeping = $derived(serverStatus === ServerModelStatus.SLEEPING);
|
||||
let isLoaded = $derived(modelsStore.isModelRunning(option.model));
|
||||
let isLoading = $derived(serverStatus === ServerModelStatus.LOADING || isOperationInProgress);
|
||||
|
||||
let loadProgress = $derived(isLoading ? modelsStore.status.getLoadProgress(option.model) : null);
|
||||
let loadPercent = $derived(Math.round(modelLoadFraction(loadProgress) * 100));
|
||||
let loadTitle = $derived(modelLoadProgressText(loadProgress));
|
||||
let modalities = $derived(option.modalities);
|
||||
let showCapabilities = $derived(
|
||||
settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_CAPABILITIES_IN_SELECTOR] ?? false
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
aria-selected={isSelected || isHighlighted}
|
||||
class={[
|
||||
'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
|
||||
'cursor-pointer',
|
||||
// skip layout and paint for rows scrolled out of the long lists
|
||||
'[content-visibility:auto] [contain-intrinsic-size:auto_2.25rem] max-md:[contain-intrinsic-size:auto_3rem]',
|
||||
// a phone gives the row and its tappable area room to breathe
|
||||
'max-md:gap-2.5 max-md:px-2.5 max-md:py-2.5',
|
||||
isSelected && !isHighlighted && 'bg-accent/50',
|
||||
isHighlighted && 'bg-accent',
|
||||
(isSelected || isHighlighted) && 'text-accent-foreground',
|
||||
'hover:bg-accent',
|
||||
'focus:bg-accent',
|
||||
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
|
||||
]}
|
||||
onclick={() => onSelect(option.id)}
|
||||
onkeydown={onKeyDown}
|
||||
onmouseenter={onMouseEnter}
|
||||
role="option"
|
||||
tabindex="0"
|
||||
title={loadTitle}
|
||||
>
|
||||
<ModelAvatar {option} size="size-5 max-md:size-7" />
|
||||
|
||||
<ModelId
|
||||
aliases={option.aliases}
|
||||
class="min-w-0 flex-1"
|
||||
draftSidecars={option.draftSidecars}
|
||||
hideCapabilities
|
||||
hideModalities
|
||||
{hideOrgName}
|
||||
{modalities}
|
||||
modelId={option.model}
|
||||
tags={option.tags}
|
||||
title={option.model}
|
||||
/>
|
||||
|
||||
{#if showCapabilities}
|
||||
<ModelCapabilities {option} />
|
||||
{/if}
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1 max-md:gap-2.5">
|
||||
<ModelRowActions {isFav} {option} />
|
||||
|
||||
<ModelLoadControl {isFailed} {isLoaded} {isLoading} {isSleeping} {option} />
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<ModelLoadHighlight percent={loadPercent} />
|
||||
{/if}
|
||||
</div>
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import ModelAvatar from '../ModelAvatar.svelte';
|
||||
import { MODEL_ICON } from '$lib/constants';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
/** Selected model; its avatar decides the mark, the generic icon when absent. */
|
||||
option?: ModelOption | null;
|
||||
}
|
||||
|
||||
let { class: className = 'h-3.5 w-3.5', option }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if option}
|
||||
<ModelAvatar class={className} {option} showQuantBadge={false} showRepoOrgAvatar size={className}>
|
||||
{#snippet fallback()}
|
||||
<MODEL_ICON class={className} />
|
||||
{/snippet}
|
||||
</ModelAvatar>
|
||||
{:else}
|
||||
<MODEL_ICON class={className} />
|
||||
{/if}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Model selection UI: a desktop serves an anchored dropdown menu and a phone a
|
||||
* bottom drawer, over their shared parts. */
|
||||
|
||||
export { default as ModelsSelector } from './ModelsSelector.svelte';
|
||||
export { default as ModelsSelectorDropdown } from './ModelsSelectorDropdown.svelte';
|
||||
export { default as ModelsSelectorDrawer } from './ModelsSelectorDrawer.svelte';
|
||||
export { default as ModelsSelectorList } from './ModelsSelectorList.svelte';
|
||||
export { default as ModelsSelectorDownloadItem } from './ModelsSelectorDownloadItem.svelte';
|
||||
export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
|
||||
export { default as ModelsSelectorTriggerIcon } from './ModelsSelectorTriggerIcon.svelte';
|
||||
@@ -1,417 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
|
||||
import type { ModelItem } from './utils';
|
||||
import { ChevronDown, Lightbulb, Loader2 } from '@lucide/svelte';
|
||||
import {
|
||||
ChatFormActionAddReasoningSubmenu,
|
||||
DialogModelInformation,
|
||||
DropdownMenuSearchable,
|
||||
ModelId,
|
||||
ModelsSelectorList,
|
||||
ModelsSelectorOption
|
||||
} from '$lib/components/app';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { MODEL_SELECTOR_ICON, SETTINGS_KEYS } from '$lib/constants';
|
||||
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
|
||||
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
|
||||
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
|
||||
import { modelsStore, settingsStore } from '$lib/stores';
|
||||
import { modelLoadFraction } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
currentModel?: string | null;
|
||||
disabled?: boolean;
|
||||
forceForegroundText?: boolean;
|
||||
onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
|
||||
useGlobalSelection?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
currentModel = null,
|
||||
disabled = false,
|
||||
forceForegroundText = false,
|
||||
onModelChange,
|
||||
useGlobalSelection = false
|
||||
}: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let highlightedId = $state<string | null>(null);
|
||||
// The model submenu opens together with the menu so the list and its search
|
||||
// box are immediately available, as before the submenu was introduced
|
||||
let modelSubOpen = $state(false);
|
||||
|
||||
const ms = useModelsSelector({
|
||||
currentModel: () => currentModel,
|
||||
onModelChange: () => onModelChange,
|
||||
onOpenChange: (open) => {
|
||||
isOpen = open;
|
||||
highlightedId = null;
|
||||
|
||||
if (open) {
|
||||
// Defer submenu open so the Sub component is mounted first;
|
||||
// setting bind:open synchronously can be lost if the Sub hasn't
|
||||
// rendered yet.
|
||||
queueMicrotask(() => {
|
||||
if (isOpen) modelSubOpen = true;
|
||||
});
|
||||
} else {
|
||||
modelSubOpen = false;
|
||||
}
|
||||
},
|
||||
useGlobalSelection: () => useGlobalSelection
|
||||
});
|
||||
|
||||
const reasoning = useReasoningMenu();
|
||||
|
||||
const showOrgNameInTrigger = $derived(
|
||||
settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER] ?? false
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
void ms.searchTerm;
|
||||
highlightedId = null;
|
||||
});
|
||||
|
||||
// Focus the model submenu's search box without scrolling the page. bits-ui
|
||||
// auto-focuses the opened content by default, which can yank the page
|
||||
// scroll; we prevent that on the Content and refocus the search here.
|
||||
$effect(() => {
|
||||
if (!isOpen || !modelSubOpen) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const search = document.querySelector<HTMLElement>(
|
||||
'[data-slot="dropdown-menu-sub-content"] input'
|
||||
);
|
||||
|
||||
search?.focus({ preventScroll: true });
|
||||
});
|
||||
});
|
||||
|
||||
// Keyboard navigation follows the on-screen row order, not the flat option list order.
|
||||
let visualOrder = $derived.by(() => {
|
||||
const order: string[] = [];
|
||||
|
||||
for (const item of ms.groupedFilteredOptions.loaded) order.push(item.option.id);
|
||||
for (const item of ms.groupedFilteredOptions.favorites) order.push(item.option.id);
|
||||
for (const group of ms.groupedFilteredOptions.available) {
|
||||
for (const item of group.items) order.push(item.option.id);
|
||||
}
|
||||
|
||||
return order;
|
||||
});
|
||||
|
||||
let highlightedIndex = $derived(highlightedId ? visualOrder.indexOf(highlightedId) : -1);
|
||||
|
||||
function moveHighlight(direction: 1 | -1) {
|
||||
const len = visualOrder.length;
|
||||
|
||||
if (len === 0) {
|
||||
highlightedId = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let index = highlightedIndex;
|
||||
|
||||
if (index === -1) {
|
||||
index = direction === 1 ? 0 : len - 1;
|
||||
} else {
|
||||
index = (index + direction + len) % len;
|
||||
}
|
||||
|
||||
highlightedId = visualOrder[index];
|
||||
}
|
||||
|
||||
// Alt+Enter only unloads and keeps the dropdown open.
|
||||
async function handleModelKeyAction(modelId: string, unload: boolean) {
|
||||
if (!unload) {
|
||||
void ms.handleSelect(modelId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const model = modelsStore.routerModels.find((m) => m.id === modelId);
|
||||
const status = model?.status?.value as ServerModelStatus | undefined;
|
||||
|
||||
if (status === ServerModelStatus.LOADING) return;
|
||||
|
||||
await modelsStore.status.unload(modelId);
|
||||
}
|
||||
|
||||
export function open() {
|
||||
ms.handleOpenChange(true);
|
||||
}
|
||||
|
||||
function handleSearchKeyDown(event: KeyboardEvent) {
|
||||
if (event.isComposing) return;
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||
event.preventDefault();
|
||||
moveHighlight(1);
|
||||
} else if (event.key === KeyboardKey.ARROW_UP) {
|
||||
event.preventDefault();
|
||||
moveHighlight(-1);
|
||||
} else if (event.key === KeyboardKey.ENTER) {
|
||||
event.preventDefault();
|
||||
|
||||
if (highlightedId) {
|
||||
void handleModelKeyAction(highlightedId, event.altKey);
|
||||
} else if (visualOrder.length > 0) {
|
||||
highlightedId = visualOrder[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={['relative inline-flex flex-col items-end gap-1', className]}>
|
||||
{#if ms.loading && ms.options.length === 0 && ms.isRouter}
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
|
||||
Loading models…
|
||||
</div>
|
||||
{:else if ms.options.length === 0 && ms.isRouter}
|
||||
{#if currentModel}
|
||||
<span
|
||||
class={[
|
||||
'inline-flex items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs text-muted-foreground',
|
||||
className
|
||||
]}
|
||||
style="max-width: min(calc(100cqw - 10rem), 20rem)"
|
||||
>
|
||||
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
</span>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No models available.</p>
|
||||
{/if}
|
||||
{:else}
|
||||
{@const selectedOption = ms.getDisplayOption()}
|
||||
{@const triggerModel = selectedOption?.model}
|
||||
{@const triggerStatus = triggerModel
|
||||
? modelsStore.routerModels.find((m) => m.id === triggerModel)?.status?.value
|
||||
: undefined}
|
||||
{@const triggerLoading =
|
||||
!!triggerModel &&
|
||||
(triggerStatus === ServerModelStatus.LOADING ||
|
||||
modelsStore.status.isOperationInProgress(triggerModel))}
|
||||
{@const triggerLoadPercent = triggerLoading
|
||||
? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
|
||||
: 0}
|
||||
|
||||
{#if ms.isRouter}
|
||||
<DropdownMenu.Root bind:open={isOpen} onOpenChange={ms.handleOpenChange}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<!-- prevent another nested button element -->
|
||||
{#snippet child({ props })}
|
||||
<DropdownMenu.Trigger
|
||||
{...props}
|
||||
class={[
|
||||
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
|
||||
!ms.isCurrentModelInCache
|
||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground',
|
||||
isOpen && 'text-foreground',
|
||||
'max-w-[min(calc(100vw-4rem) md:max-w-[min(calc(100cqw-9rem),25rem)]'
|
||||
]}
|
||||
disabled={disabled || ms.updating}
|
||||
>
|
||||
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
class="min-w-0 overflow-hidden"
|
||||
hideOrgName={!showOrgNameInTrigger}
|
||||
hideQuantization
|
||||
modelId={selectedOption.model}
|
||||
/>
|
||||
{:else}
|
||||
<span class="min-w-0 font-medium">Select model</span>
|
||||
{/if}
|
||||
|
||||
{#if reasoning.isReasoningActive}
|
||||
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if ms.updating || ms.isLoadingModel}
|
||||
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
{:else}
|
||||
<ChevronDown class="h-3 w-3.5 shrink-0" />
|
||||
{/if}
|
||||
|
||||
{#if triggerLoading}
|
||||
<ModelLoadHighlight percent={triggerLoadPercent} />
|
||||
{/if}
|
||||
</DropdownMenu.Trigger>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
{#if selectedOption}
|
||||
<Tooltip.Content>
|
||||
<p class="font-mono">{selectedOption.model}</p>
|
||||
</Tooltip.Content>
|
||||
{/if}
|
||||
</Tooltip.Root>
|
||||
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
class="w-full md:min-w-64 md:max-w-80 max-w-[calc(100vw-2rem)]"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenu.Sub bind:open={modelSubOpen}>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
<MODEL_SELECTOR_ICON class="h-4 w-4" />
|
||||
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
class="min-w-0 flex-1 overflow-hidden"
|
||||
hideOrgName={!showOrgNameInTrigger}
|
||||
hideQuantization
|
||||
modelId={selectedOption.model}
|
||||
/>
|
||||
{:else}
|
||||
<span class="min-w-0 flex-1 truncate text-muted-foreground">No model</span>
|
||||
{/if}
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent class="w-100 max-w-[calc(100vw-2rem)] pt-0">
|
||||
<DropdownMenuSearchable
|
||||
emptyMessage="No models found."
|
||||
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
|
||||
onSearchChange={(v) => ms.setSearchTerm(v)}
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
placeholder="Search models..."
|
||||
searchValue={ms.searchTerm}
|
||||
>
|
||||
<div class="models-list">
|
||||
{#if !ms.isCurrentModelInCache && currentModel}
|
||||
<!-- Show unavailable model as first option (disabled) -->
|
||||
<button
|
||||
aria-disabled="true"
|
||||
aria-selected="true"
|
||||
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
|
||||
disabled
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<ModelId class="flex-1" hideQuantization modelId={currentModel} />
|
||||
|
||||
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if ms.filteredOptions.length === 0}
|
||||
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
|
||||
{/if}
|
||||
|
||||
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
|
||||
{@const isHighlighted = option.id === highlightedId}
|
||||
{@const isFav = ms.isFavorite(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
{hideOrgName}
|
||||
{isFav}
|
||||
{isHighlighted}
|
||||
{isSelected}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
|
||||
event.preventDefault();
|
||||
void handleModelKeyAction(option.id, event.altKey);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => (highlightedId = option.id)}
|
||||
onSelect={ms.handleSelect}
|
||||
{option}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<ModelsSelectorList
|
||||
activeId={ms.activeId}
|
||||
{currentModel}
|
||||
groups={ms.groupedFilteredOptions}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onSelect={ms.handleSelect}
|
||||
renderOption={modelOption}
|
||||
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuSearchable>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
<ChatFormActionAddReasoningSubmenu />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{:else}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<!-- prevent another nested button element -->
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
class={[
|
||||
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
|
||||
!ms.isCurrentModelInCache
|
||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground',
|
||||
isOpen && 'text-foreground'
|
||||
]}
|
||||
disabled={disabled || ms.updating}
|
||||
onclick={() => ms.handleOpenChange(true)}
|
||||
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
|
||||
>
|
||||
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
class="min-w-0 overflow-hidden"
|
||||
hideOrgName={!showOrgNameInTrigger}
|
||||
hideQuantization
|
||||
modelId={selectedOption.model}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if reasoning.isReasoningActive}
|
||||
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
|
||||
{/if}
|
||||
|
||||
{#if ms.updating}
|
||||
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
{#if selectedOption}
|
||||
<Tooltip.Content>
|
||||
<p class="font-mono">{selectedOption.model}</p>
|
||||
</Tooltip.Content>
|
||||
{/if}
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if ms.showModelDialog}
|
||||
<DialogModelInformation
|
||||
modelId={ms.infoModelId}
|
||||
onOpenChange={(v) => ms.setShowModelDialog(v)}
|
||||
open={ms.showModelDialog}
|
||||
/>
|
||||
{/if}
|
||||
@@ -1,76 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { GroupedModelOptions, ModelItem } from './utils';
|
||||
import { ModelsSelectorOption } from '$lib/components/app';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
|
||||
interface Props {
|
||||
groups: GroupedModelOptions;
|
||||
currentModel: string | null;
|
||||
activeId: string | null;
|
||||
sectionHeaderClass?: string;
|
||||
orgHeaderClass?: string;
|
||||
onSelect: (modelId: string) => void;
|
||||
onInfoClick: (modelName: string) => void;
|
||||
renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
|
||||
}
|
||||
|
||||
let {
|
||||
activeId,
|
||||
currentModel,
|
||||
groups,
|
||||
onInfoClick,
|
||||
onSelect,
|
||||
orgHeaderClass = 'px-2 py-2 text-[11px] font-semibold text-muted-foreground/50 select-none [&:not(:first-child)]:mt-1',
|
||||
renderOption,
|
||||
sectionHeaderClass = 'my-1 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none'
|
||||
}: Props = $props();
|
||||
let render = $derived(renderOption ?? defaultOption);
|
||||
</script>
|
||||
|
||||
{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || activeId === option.id}
|
||||
{@const isFav = modelsStore.favoriteModelIds.has(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
{hideOrgName}
|
||||
{isFav}
|
||||
isHighlighted={false}
|
||||
{isSelected}
|
||||
{onInfoClick}
|
||||
onKeyDown={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
{onSelect}
|
||||
{option}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#if groups.loaded.length > 0}
|
||||
<p class={sectionHeaderClass}>Loaded models</p>
|
||||
|
||||
{#each groups.loaded as item (`loaded-${item.option.id}`)}
|
||||
{@render render(item, false)}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if groups.favorites.length > 0}
|
||||
<p class={sectionHeaderClass}>Favorite models</p>
|
||||
|
||||
{#each groups.favorites as item (`fav-${item.option.id}`)}
|
||||
{@render render(item, true)}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if groups.available.length > 0}
|
||||
<p class={sectionHeaderClass}>Available models</p>
|
||||
|
||||
{#each groups.available as group (group.orgName)}
|
||||
{#if group.orgName}
|
||||
<p class={orgHeaderClass}>{group.orgName}</p>
|
||||
{/if}
|
||||
|
||||
{#each group.items as item (item.option.id)}
|
||||
{@render render(item, true)}
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -1,216 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
|
||||
import {
|
||||
CircleAlert,
|
||||
Heart,
|
||||
HeartOff,
|
||||
Info,
|
||||
Loader2,
|
||||
Power,
|
||||
PowerOff,
|
||||
RotateCw
|
||||
} from '@lucide/svelte';
|
||||
import { ActionIcon, ModelId } from '$lib/components/app';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { ModelCapability, ServerModelStatus } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { modelLoadFraction, modelLoadProgressText } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
option: ModelOption;
|
||||
isSelected: boolean;
|
||||
isHighlighted: boolean;
|
||||
isFav: boolean;
|
||||
hideOrgName?: boolean;
|
||||
onSelect: (modelId: string) => void;
|
||||
onMouseEnter: () => void;
|
||||
onKeyDown: (e: KeyboardEvent) => void;
|
||||
onInfoClick?: (modelName: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
hideOrgName = false,
|
||||
isFav,
|
||||
isHighlighted,
|
||||
isSelected,
|
||||
onInfoClick,
|
||||
onKeyDown,
|
||||
onMouseEnter,
|
||||
onSelect,
|
||||
option
|
||||
}: Props = $props();
|
||||
|
||||
let currentRouterModels = $derived(modelsStore.routerModels);
|
||||
let serverStatus = $derived.by(() => {
|
||||
const model = currentRouterModels.find((m) => m.id === option.model);
|
||||
|
||||
return (model?.status?.value as ServerModelStatus) ?? null;
|
||||
});
|
||||
let isOperationInProgress = $derived(modelsStore.status.isOperationInProgress(option.model));
|
||||
let isFailed = $derived(serverStatus === ServerModelStatus.FAILED);
|
||||
let isSleeping = $derived(serverStatus === ServerModelStatus.SLEEPING);
|
||||
let isLoaded = $derived(
|
||||
(serverStatus === ServerModelStatus.LOADED || isSleeping) && !isOperationInProgress
|
||||
);
|
||||
let isLoading = $derived(serverStatus === ServerModelStatus.LOADING || isOperationInProgress);
|
||||
|
||||
let loadProgress = $derived(isLoading ? modelsStore.status.getLoadProgress(option.model) : null);
|
||||
let loadPercent = $derived(Math.round(modelLoadFraction(loadProgress) * 100));
|
||||
let loadTitle = $derived(modelLoadProgressText(loadProgress));
|
||||
let modalities = $derived(option.modalities);
|
||||
let capabilities = $derived.by(() => ({
|
||||
reasoning: modelsStore.props.checkModelSupportsThinking(option.model),
|
||||
tools: option.capabilities.includes(ModelCapability.TOOL_USE)
|
||||
}));
|
||||
</script>
|
||||
|
||||
<div
|
||||
aria-selected={isSelected || isHighlighted}
|
||||
class={[
|
||||
'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
|
||||
'cursor-pointer',
|
||||
isSelected && !isHighlighted && 'bg-accent/50',
|
||||
isHighlighted && 'bg-accent',
|
||||
(isSelected || isHighlighted) && 'text-accent-foreground',
|
||||
'hover:bg-accent',
|
||||
'focus:bg-accent',
|
||||
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
|
||||
]}
|
||||
onclick={() => onSelect(option.id)}
|
||||
onkeydown={onKeyDown}
|
||||
onmouseenter={onMouseEnter}
|
||||
role="option"
|
||||
tabindex="0"
|
||||
title={loadTitle}
|
||||
>
|
||||
<ModelId
|
||||
aliases={option.aliases}
|
||||
class="flex-1"
|
||||
{hideOrgName}
|
||||
{modalities}
|
||||
modelId={option.model}
|
||||
showRawTooltip
|
||||
supportsThinking={capabilities.reasoning}
|
||||
supportsToolUse={capabilities.tools}
|
||||
tags={option.tags}
|
||||
/>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="pointer-events-none flex items-center justify-center gap-0.75 pl-2 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 [@media(pointer:coarse)]:pointer-events-auto [@media(pointer:coarse)]:opacity-100"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{#if isFav}
|
||||
<ActionIcon
|
||||
class="h-3 w-3 hover:text-foreground"
|
||||
icon={HeartOff}
|
||||
iconSize="h-2.5 w-2.5"
|
||||
onclick={() => modelsStore.toggleFavorite(option.model)}
|
||||
tooltip="Remove from favorites"
|
||||
/>
|
||||
{:else}
|
||||
<ActionIcon
|
||||
class="h-3 w-3 hover:text-foreground"
|
||||
icon={Heart}
|
||||
iconSize="h-2.5 w-2.5"
|
||||
onclick={() => modelsStore.toggleFavorite(option.model)}
|
||||
tooltip="Add to favorites"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- info button: only shown when model is loaded and callback is provided -->
|
||||
{#if isLoaded && onInfoClick}
|
||||
<ActionIcon
|
||||
class="h-3 w-3 hover:text-foreground"
|
||||
icon={Info}
|
||||
iconSize="h-2.5 w-2.5"
|
||||
onclick={() => onInfoClick(option.model)}
|
||||
tooltip="Model information"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-5">
|
||||
<Loader2 class="{ICON_CLASS_DEFAULT} animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{:else if isFailed}
|
||||
<div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-auto">
|
||||
<CircleAlert
|
||||
class="h-3.5 w-3.5 text-red-500 group-hover:hidden [@media(pointer:coarse)]:hidden"
|
||||
/>
|
||||
|
||||
<div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
|
||||
<ActionIcon
|
||||
class="h-3 w-3 text-red-500 hover:text-foreground"
|
||||
icon={RotateCw}
|
||||
iconSize="h-2.5 w-2.5"
|
||||
onclick={() => modelsStore.status.load(option.model)}
|
||||
stopPropagationOnClick
|
||||
tooltip="Retry loading model"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if isSleeping}
|
||||
<div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-auto">
|
||||
<span
|
||||
class="h-2 w-2 rounded-full bg-orange-400 group-hover:hidden [@media(pointer:coarse)]:hidden"
|
||||
></span>
|
||||
|
||||
<div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
|
||||
<ActionIcon
|
||||
class="h-3 w-3 text-red-500 hover:text-red-600 [@media(pointer:coarse)]:text-amber-500 [@media(pointer:coarse)]:hover:text-amber-600"
|
||||
icon={PowerOff}
|
||||
iconSize="h-2.5 w-2.5"
|
||||
onclick={(e) => {
|
||||
e?.stopPropagation();
|
||||
modelsStore.status.unload(option.model);
|
||||
}}
|
||||
tooltip="Unload model"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if isLoaded}
|
||||
<div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-auto">
|
||||
<span
|
||||
class="h-2 w-2 rounded-full bg-green-500 group-hover:hidden [@media(pointer:coarse)]:hidden"
|
||||
></span>
|
||||
|
||||
<div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
|
||||
<ActionIcon
|
||||
class="h-3 w-3 text-red-500 hover:text-red-600 [@media(pointer:coarse)]:text-green-500 [@media(pointer:coarse)]:hover:text-green-600"
|
||||
icon={PowerOff}
|
||||
iconSize="h-2.5 w-2.5"
|
||||
onclick={() => modelsStore.status.unload(option.model)}
|
||||
stopPropagationOnClick
|
||||
tooltip="Unload model"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-auto">
|
||||
<span
|
||||
class="h-2 w-2 rounded-full bg-muted-foreground/50 group-hover:hidden [@media(pointer:coarse)]:hidden"
|
||||
></span>
|
||||
|
||||
<div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
|
||||
<ActionIcon
|
||||
class="h-3 w-3 [@media(pointer:coarse)]:text-muted-foreground"
|
||||
icon={Power}
|
||||
iconSize="h-2.5 w-2.5"
|
||||
onclick={() => modelsStore.status.load(option.model)}
|
||||
stopPropagationOnClick
|
||||
tooltip="Load model"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<ModelLoadHighlight percent={loadPercent} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,210 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
|
||||
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
|
||||
import {
|
||||
DialogModelInformation,
|
||||
ModelId,
|
||||
ModelsSelectorList,
|
||||
SearchInput
|
||||
} from '$lib/components/app';
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import { modelLoadFraction } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
currentModel?: string | null;
|
||||
/** Callback when model changes. Return false to keep menu open (e.g., for validation failures) */
|
||||
onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
|
||||
disabled?: boolean;
|
||||
forceForegroundText?: boolean;
|
||||
/** When true, user's global selection takes priority over currentModel (for form selector) */
|
||||
useGlobalSelection?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
currentModel = null,
|
||||
disabled = false,
|
||||
forceForegroundText = false,
|
||||
onModelChange,
|
||||
useGlobalSelection = false
|
||||
}: Props = $props();
|
||||
|
||||
let sheetOpen = $state(false);
|
||||
|
||||
const ms = useModelsSelector({
|
||||
currentModel: () => currentModel,
|
||||
onModelChange: () => onModelChange,
|
||||
onOpenChange: (open) => {
|
||||
sheetOpen = open;
|
||||
},
|
||||
useGlobalSelection: () => useGlobalSelection
|
||||
});
|
||||
|
||||
export function open() {
|
||||
ms.handleOpenChange(true);
|
||||
}
|
||||
|
||||
function handleSheetOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
ms.handleOpenChange(false);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={['relative inline-flex flex-col items-end gap-1', className]}>
|
||||
{#if ms.loading && ms.options.length === 0 && ms.isRouter}
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
Loading models…
|
||||
</div>
|
||||
{:else if ms.options.length === 0 && ms.isRouter}
|
||||
<p class="text-xs text-muted-foreground">No models available.</p>
|
||||
{:else}
|
||||
{@const selectedOption = ms.getDisplayOption()}
|
||||
{@const triggerModel = selectedOption?.model}
|
||||
{@const triggerStatus = triggerModel
|
||||
? modelsStore.routerModels.find((m) => m.id === triggerModel)?.status?.value
|
||||
: undefined}
|
||||
{@const triggerLoading =
|
||||
!!triggerModel &&
|
||||
(triggerStatus === ServerModelStatus.LOADING ||
|
||||
modelsStore.status.isOperationInProgress(triggerModel))}
|
||||
{@const triggerLoadPercent = triggerLoading
|
||||
? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
|
||||
: 0}
|
||||
|
||||
{#if ms.isRouter}
|
||||
<button
|
||||
class={[
|
||||
`relative inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 max-sm:px-3 max-sm:py-2 max-sm:text-sm dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
|
||||
!ms.isCurrentModelInCache
|
||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground',
|
||||
sheetOpen && 'text-foreground'
|
||||
]}
|
||||
disabled={disabled || ms.updating}
|
||||
onclick={() => ms.handleOpenChange(true)}
|
||||
style="max-width: min(calc(100cqw - 9rem), 20rem)"
|
||||
type="button"
|
||||
>
|
||||
<Package class="h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
{#if !selectedOption}
|
||||
<span class="min-w-0 font-medium">Select model</span>
|
||||
{:else}
|
||||
<ModelId
|
||||
class="text-xs"
|
||||
hideOrgName
|
||||
hideQuantization
|
||||
hideTags
|
||||
modelId={selectedOption?.model || ''}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if ms.updating || ms.isLoadingModel}
|
||||
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
{:else}
|
||||
<ChevronDown class="h-3 w-3.5 shrink-0" />
|
||||
{/if}
|
||||
|
||||
{#if triggerLoading}
|
||||
<ModelLoadHighlight percent={triggerLoadPercent} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<Sheet.Root bind:open={sheetOpen} onOpenChange={handleSheetOpenChange}>
|
||||
<Sheet.Content class="max-h-[85vh] gap-1" side="bottom">
|
||||
<Sheet.Header>
|
||||
<Sheet.Title>Select Model</Sheet.Title>
|
||||
|
||||
<Sheet.Description class="sr-only">
|
||||
Choose a model to use for the conversation
|
||||
</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
|
||||
<div class="flex flex-col gap-1 pb-4">
|
||||
<div class="mb-3 px-4">
|
||||
<SearchInput
|
||||
onInput={(v) => ms.setSearchTerm(v)}
|
||||
placeholder="Search models..."
|
||||
value={ms.searchTerm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[60vh] overflow-y-auto px-2">
|
||||
{#if !ms.isCurrentModelInCache && currentModel}
|
||||
<button
|
||||
class="flex w-full cursor-not-allowed items-center rounded-md bg-red-400/10 px-3 py-2.5 text-left text-sm text-red-400"
|
||||
disabled
|
||||
type="button"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{selectedOption?.name || currentModel}
|
||||
</span>
|
||||
|
||||
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
||||
</button>
|
||||
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
{/if}
|
||||
|
||||
{#if ms.filteredOptions.length === 0}
|
||||
<p class="px-3 py-3 text-center text-sm text-muted-foreground">No models found.</p>
|
||||
{/if}
|
||||
|
||||
<ModelsSelectorList
|
||||
activeId={ms.activeId}
|
||||
{currentModel}
|
||||
groups={ms.groupedFilteredOptions}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onSelect={ms.handleSelect}
|
||||
orgHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none [&:not(:first-child)]:mt-2"
|
||||
sectionHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
{:else}
|
||||
<button
|
||||
class={[
|
||||
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
|
||||
!ms.isCurrentModelInCache
|
||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground'
|
||||
]}
|
||||
disabled={disabled || ms.updating}
|
||||
onclick={() => ms.handleOpenChange(true)}
|
||||
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
|
||||
>
|
||||
<Package class="h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
<ModelId class="font-medium" hideQuantization modelId={selectedOption?.model || ''} />
|
||||
|
||||
{#if ms.updating}
|
||||
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if ms.showModelDialog}
|
||||
<DialogModelInformation
|
||||
modelId={ms.infoModelId}
|
||||
onOpenChange={(v) => ms.setShowModelDialog(v)}
|
||||
open={ms.showModelDialog}
|
||||
/>
|
||||
{/if}
|
||||
@@ -1,109 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { DARK_INVERT_AVATAR_ORGS } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
// Orgs whose avatar failed before. A row remounting (scrolling, reopening
|
||||
// the selector) must not re-request them; the monogram renders instead.
|
||||
const failedAvatarOrgs = new SvelteSet<string>();
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
org: string;
|
||||
quantOrg?: string;
|
||||
size?: string;
|
||||
baseImageClass?: string;
|
||||
quantImageClass?: string;
|
||||
quantPositionClass?: string;
|
||||
quantSize?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
baseImageClass = '',
|
||||
class: className = '',
|
||||
org,
|
||||
quantImageClass = 'h-full w-full',
|
||||
quantOrg,
|
||||
quantPositionClass = '-bottom-0.75 -right-0.75',
|
||||
quantSize = 'h-4.25 w-4.25',
|
||||
size = 'h-9 w-9'
|
||||
}: Props = $props();
|
||||
|
||||
let orgAvatarFailed = $derived(failedAvatarOrgs.has(org));
|
||||
let quantAvatarFailed = $derived(failedAvatarOrgs.has(quantOrg ?? ''));
|
||||
|
||||
let invertAvatar = $derived(DARK_INVERT_AVATAR_ORGS.includes(org));
|
||||
let invertQuant = $derived(DARK_INVERT_AVATAR_ORGS.includes(quantOrg ?? ''));
|
||||
|
||||
// Monogram fallback: org initial on a hue derived from its name, so each org
|
||||
// gets a stable distinct color.
|
||||
let hue = $derived.by(() => {
|
||||
let h = 0;
|
||||
|
||||
for (let i = 0; i < org.length; i++) h = (h * 31 + org.charCodeAt(i)) >>> 0;
|
||||
|
||||
return h % 360;
|
||||
});
|
||||
|
||||
let quantHue = $derived.by(() => {
|
||||
const name = quantOrg ?? '';
|
||||
|
||||
let h = 0;
|
||||
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
|
||||
return h % 360;
|
||||
});
|
||||
</script>
|
||||
|
||||
<span class="relative mt-0.5 inline-flex shrink-0 {className}">
|
||||
{#if orgAvatarFailed}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex {size} items-center justify-center rounded-md text-sm font-semibold text-white"
|
||||
style="background-color: hsl({hue} 60% 45%)"
|
||||
>
|
||||
{org.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
{:else}
|
||||
<div class="rounded-md">
|
||||
<img
|
||||
alt=""
|
||||
class="{size} rounded-md {invertAvatar ? 'dark:invert' : ''} {baseImageClass}"
|
||||
loading="lazy"
|
||||
onerror={() => {
|
||||
failedAvatarOrgs.add(org);
|
||||
}}
|
||||
src={HuggingFaceService.getAvatarUrl(org)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if quantOrg && quantOrg !== org}
|
||||
<!-- native title instead of a floating tooltip: long model lists mount one badge per row -->
|
||||
<span
|
||||
class="absolute {quantPositionClass} {quantSize} overflow-hidden rounded-full border border-background bg-muted"
|
||||
title={quantOrg}
|
||||
>
|
||||
{#if quantAvatarFailed}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex h-full w-full items-center justify-center rounded-full text-[8px] font-semibold text-white"
|
||||
style="background-color: hsl({quantHue} 60% 45%)"
|
||||
>
|
||||
{quantOrg.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
{:else}
|
||||
<img
|
||||
alt=""
|
||||
class="{quantImageClass} rounded-full {invertQuant ? 'dark:invert' : ''}"
|
||||
loading="lazy"
|
||||
onerror={() => {
|
||||
failedAvatarOrgs.add(quantOrg ?? '');
|
||||
}}
|
||||
src={HuggingFaceService.getAvatarUrl(quantOrg)}
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
@@ -10,70 +10,6 @@
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **ModelsSelectorDropdown** - Model selection dropdown (desktop)
|
||||
*
|
||||
* Dropdown for selecting AI models with status indicators,
|
||||
* search, and model information display. Adapts UI based on server mode.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Uses DropdownMenuSearchable for model list
|
||||
* - Integrates with modelsStore for model options and selection
|
||||
* - Detects router vs single mode from serverStore
|
||||
* - Opens DialogModelInformation for model details
|
||||
*
|
||||
* **Features:**
|
||||
* - Searchable model list with keyboard navigation
|
||||
* - Model status indicators (loading/ready/error/updating)
|
||||
* - Model capabilities badges (vision, tools, etc.)
|
||||
* - Current/active model highlighting
|
||||
* - Model information dialog on info button click
|
||||
* - Router mode: shows all available models with status
|
||||
* - Single mode: shows current model name only
|
||||
* - Loading/updating skeleton states
|
||||
* - Global selection support for form integration
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <ModelsSelectorDropdown
|
||||
* currentModel={conversation.modelId}
|
||||
* onModelChange={(id, name) => updateModel(id)}
|
||||
* useGlobalSelection
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as ModelsSelectorDropdown } from './ModelsSelectorDropdown.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsSelectorList** - Grouped model options list
|
||||
*
|
||||
* Renders grouped model options (loaded, favorites, available) with section
|
||||
* headers and org subgroups. Shared between ModelsSelectorDropdown and ModelsSelectorSheet
|
||||
* to avoid template duplication.
|
||||
*
|
||||
* Accepts an optional `renderOption` snippet to customize how each option is
|
||||
* rendered (e.g., to add keyboard navigation or highlighting).
|
||||
*/
|
||||
export { default as ModelsSelectorList } from './ModelsSelectorList.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsSelectorOption** - Single model option row
|
||||
*
|
||||
* Renders a single model option with selection state, favorite toggle,
|
||||
* load/unload actions, status indicators, and an info button.
|
||||
* Used inside ModelsSelectorList or directly in custom render snippets.
|
||||
*/
|
||||
export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsSelectorSheet** - Mobile model selection sheet
|
||||
*
|
||||
* Bottom sheet variant of ModelsSelectorDropdown optimized for touch interaction
|
||||
* on mobile devices. Same functionality as ModelsSelectorDropdown but uses Sheet UI
|
||||
* instead of DropdownMenu.
|
||||
*/
|
||||
export { default as ModelsSelectorSheet } from './ModelsSelectorSheet.svelte';
|
||||
|
||||
/** * **ModelBadge** - Model name display badge
|
||||
*
|
||||
* Compact badge showing current model name with package icon.
|
||||
@@ -108,13 +44,19 @@ export { default as ModelBadge } from './ModelBadge.svelte';
|
||||
* aliases, and tags. Supports raw mode to show the unprocessed model name.
|
||||
* Respects the user's `showRawModelNames` setting.
|
||||
*/
|
||||
export { default as ModelCapabilities } from './ModelCapabilities.svelte';
|
||||
export { default as ModelContext } from './ModelContext.svelte';
|
||||
export { default as ModelId } from './ModelId.svelte';
|
||||
export { default as ModelAvatar } from './ModelAvatar.svelte';
|
||||
export { default as ModelDownloadProgressBar } from './ModelDownloadProgressBar.svelte';
|
||||
export { default as ModelLoadControl } from './ModelLoadControl.svelte';
|
||||
export { default as ModelOrgAvatar } from './ModelOrgAvatar.svelte';
|
||||
export { default as ModelRowActions } from './ModelRowActions.svelte';
|
||||
export { default as ModelsSection } from './ModelsSection.svelte';
|
||||
|
||||
/**
|
||||
* **ModelCapabilityIcons** - Capability and modality icon row
|
||||
*
|
||||
* The shared tool-use / reasoning / vision / video / audio icon cluster with
|
||||
* tooltips, used by ModelId and the discover details header so the order and
|
||||
* styling stay consistent across every model-id surface.
|
||||
*/
|
||||
/** Capability and modality icon row, shared by every model-id surface. */
|
||||
export { default as ModelCapabilityIcons } from './ModelCapabilityIcons.svelte';
|
||||
|
||||
/** Draft sidecar badges, rendered as `+ [KIND] [QUANT]` per sidecar. */
|
||||
export { default as ModelDraftSidecars } from './ModelDraftSidecars.svelte';
|
||||
export * from './ModelsSelector';
|
||||
@@ -1,7 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { KeyboardShortcutInfo } from '$lib/components/app';
|
||||
import * as Drawer from '$lib/components/ui/drawer';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { deviceStore } from '$lib/stores';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
interface ActionItem {
|
||||
@@ -31,61 +34,125 @@
|
||||
triggerIcon,
|
||||
triggerTooltip
|
||||
}: Props = $props();
|
||||
|
||||
// a phone shows the same actions in a drawer, where a thumb reaches them and every
|
||||
// row is a touch target of its own
|
||||
let isMobile = $derived(deviceStore.isMobile);
|
||||
|
||||
function runAction(action: ActionItem, event: Event) {
|
||||
open = false;
|
||||
|
||||
// let the drawer close before an action that moves the focus, such as a confirm dialog
|
||||
setTimeout(() => action.onclick(event), 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root bind:open>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<!-- prevent another nested button element -->
|
||||
{#snippet child({ props })}
|
||||
<DropdownMenu.Trigger
|
||||
{...props}
|
||||
class="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md p-0 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground {triggerClass}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{@render iconComponent(triggerIcon, 'h-3 w-3')}
|
||||
|
||||
{#if triggerTooltip}
|
||||
<span class="sr-only">{triggerTooltip}</span>
|
||||
{/if}
|
||||
</DropdownMenu.Trigger>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
{#if isMobile}
|
||||
<button
|
||||
class="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md p-0 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground {triggerClass}"
|
||||
data-slot="dropdown-menu-trigger"
|
||||
data-state={open ? 'open' : 'closed'}
|
||||
onclick={(event) => {
|
||||
event.stopPropagation();
|
||||
open = true;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{@render iconComponent(triggerIcon, 'h-3 w-3')}
|
||||
|
||||
{#if triggerTooltip}
|
||||
<Tooltip.Content>
|
||||
<p>{triggerTooltip}</p>
|
||||
</Tooltip.Content>
|
||||
<span class="sr-only">{triggerTooltip}</span>
|
||||
{/if}
|
||||
</Tooltip.Root>
|
||||
</button>
|
||||
|
||||
<DropdownMenu.Content {align} class="z-[999999] w-48">
|
||||
{#each actions as action, index (action.label)}
|
||||
{#if action.separator && index > 0}
|
||||
<DropdownMenu.Separator />
|
||||
<Drawer.Root bind:open>
|
||||
<Drawer.Content>
|
||||
<Drawer.Header>
|
||||
<Drawer.Title>{triggerTooltip ?? 'Actions'}</Drawer.Title>
|
||||
</Drawer.Header>
|
||||
|
||||
<div class="flex flex-col px-2 pb-4">
|
||||
{#each actions as action, index (action.label)}
|
||||
{#if action.separator && index > 0}
|
||||
<Separator class="my-1" />
|
||||
{/if}
|
||||
|
||||
<button
|
||||
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 disabled:pointer-events-none disabled:opacity-50 {action.variant ===
|
||||
'destructive'
|
||||
? 'text-destructive'
|
||||
: ''}"
|
||||
disabled={action.disabled}
|
||||
onclick={(event) => runAction(action, event)}
|
||||
type="button"
|
||||
>
|
||||
{@render iconComponent(action.icon, 'h-4 w-4 shrink-0')}
|
||||
|
||||
<span class="flex-1">{action.label}</span>
|
||||
|
||||
{#if action.shortcut}
|
||||
<KeyboardShortcutInfo keys={action.shortcut} variant={action.variant} />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root>
|
||||
{:else}
|
||||
<DropdownMenu.Root bind:open>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<!-- prevent another nested button element -->
|
||||
{#snippet child({ props })}
|
||||
<DropdownMenu.Trigger
|
||||
{...props}
|
||||
class="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md p-0 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground {triggerClass}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{@render iconComponent(triggerIcon, 'h-3 w-3')}
|
||||
|
||||
{#if triggerTooltip}
|
||||
<span class="sr-only">{triggerTooltip}</span>
|
||||
{/if}
|
||||
</DropdownMenu.Trigger>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
{#if triggerTooltip}
|
||||
<Tooltip.Content>
|
||||
<p>{triggerTooltip}</p>
|
||||
</Tooltip.Content>
|
||||
{/if}
|
||||
</Tooltip.Root>
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex items-center justify-between hover:[&>kbd]:opacity-100"
|
||||
disabled={action.disabled}
|
||||
onclick={action.onclick}
|
||||
variant={action.variant}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{@render iconComponent(
|
||||
action.icon,
|
||||
`h-4 w-4 ${action.variant === 'destructive' ? 'text-destructive' : ''}`
|
||||
)}
|
||||
{action.label}
|
||||
</div>
|
||||
|
||||
{#if action.shortcut}
|
||||
<KeyboardShortcutInfo keys={action.shortcut} variant={action.variant} />
|
||||
<DropdownMenu.Content {align} class="z-[999999] w-48">
|
||||
{#each actions as action, index (action.label)}
|
||||
{#if action.separator && index > 0}
|
||||
<DropdownMenu.Separator />
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex items-center justify-between hover:[&>kbd]:opacity-100"
|
||||
disabled={action.disabled}
|
||||
onclick={action.onclick}
|
||||
variant={action.variant}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{@render iconComponent(
|
||||
action.icon,
|
||||
`h-4 w-4 ${action.variant === 'destructive' ? 'text-destructive' : ''}`
|
||||
)}
|
||||
{action.label}
|
||||
</div>
|
||||
|
||||
{#if action.shortcut}
|
||||
<KeyboardShortcutInfo keys={action.shortcut} variant={action.variant} />
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
|
||||
{#snippet iconComponent(IconComponent: Component, className: string)}
|
||||
<IconComponent class={className} />
|
||||
|
||||
@@ -1,50 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { SearchInput } from '$lib/components/app';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { cn } from '$lib/components/ui/utils.js';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/** Extra classes for the sticky search header. */
|
||||
headerClass?: string;
|
||||
placeholder?: string;
|
||||
searchValue?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
onSearchKeyDown?: (event: KeyboardEvent) => void;
|
||||
emptyMessage?: string;
|
||||
isEmpty?: boolean;
|
||||
/** Extra classes for the wrapper around the option list. */
|
||||
contentClass?: string;
|
||||
/** Extra classes for the search input. */
|
||||
searchClass?: string;
|
||||
children: Snippet;
|
||||
/**
|
||||
* Optional sticky footer: it sticks to the bottom of DropdownMenu.Content's own
|
||||
* scrollport, which must stay the scroll container (overflow-y-auto + max-height).
|
||||
*/
|
||||
footer?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
children,
|
||||
contentClass = '',
|
||||
emptyMessage = 'No items found',
|
||||
footer,
|
||||
headerClass = '',
|
||||
isEmpty = false,
|
||||
onSearchChange,
|
||||
onSearchKeyDown,
|
||||
placeholder = 'Search...',
|
||||
searchClass = '',
|
||||
searchValue = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
// the search height is published so list headers can stick right below it
|
||||
let stickyHeaderHeight = $state(0);
|
||||
</script>
|
||||
|
||||
<div class="sticky top-0 z-10 mb-2 bg-popover p-1 pt-2">
|
||||
<div
|
||||
bind:clientHeight={stickyHeaderHeight}
|
||||
class={cn('sticky top-0 z-20 bg-popover p-1.5', headerClass)}
|
||||
>
|
||||
<SearchInput
|
||||
bind:value={searchValue}
|
||||
class={searchClass}
|
||||
onInput={onSearchChange}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
{placeholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto">
|
||||
{@render children()}
|
||||
<div class={contentClass}>
|
||||
<!-- wrapper carries the sticky offset: bits-ui owns the style of the scrollport -->
|
||||
<div style="--dropdown-sticky-height: {stickyHeaderHeight}px">
|
||||
{@render children()}
|
||||
|
||||
{#if isEmpty}
|
||||
<div class="px-2 py-3 text-center text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
{/if}
|
||||
{#if isEmpty}
|
||||
<div class="px-2 py-3 text-center text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if footer}
|
||||
<DropdownMenu.Separator />
|
||||
<div class="sticky bottom-0 z-20 bg-popover py-1.5">
|
||||
<div
|
||||
aria-orientation="horizontal"
|
||||
class="h-px bg-border/20 mb-1.5 mx-1.5"
|
||||
role="separator"
|
||||
></div>
|
||||
|
||||
{@render footer()}
|
||||
{@render footer()}
|
||||
</div>
|
||||
{/if}
|
||||
+24
-3
@@ -5,6 +5,9 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
DialogConversationRename,
|
||||
DialogManageModels,
|
||||
DialogMcpServers,
|
||||
DialogModelInformation,
|
||||
DialogSettingsChat,
|
||||
Logo,
|
||||
SidebarNavigationActions,
|
||||
@@ -93,6 +96,7 @@
|
||||
|
||||
let renameDialogOpen = $state(false);
|
||||
let settingsDialogOpen = $state(false);
|
||||
let mcpDialogOpen = $state(false);
|
||||
let renameTargetConversationId = $state<string | null>(null);
|
||||
let renameDraft = $state('');
|
||||
let renameOriginalTitle = $state('');
|
||||
@@ -306,6 +310,15 @@
|
||||
pendingCollapse = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A dialog owns the screen on a phone, so the expanded list steps aside for it. */
|
||||
function openDialog(open: () => void): void {
|
||||
if (deviceStore.isMobile) {
|
||||
scheduleMobileCollapse();
|
||||
}
|
||||
|
||||
open();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerWidth onkeydown={handleKeydown} />
|
||||
@@ -365,9 +378,9 @@
|
||||
>
|
||||
<ActionIcon
|
||||
ariaLabel="Collapse navigation"
|
||||
class="backdrop-blur-none md:h-9 md:w-9 h-10 w-10 rounded-full mr-1 hover:bg-accent!"
|
||||
class="backdrop-blur-none md:h-9 md:w-9 h-10 w-10 rounded-full mr-1 max-md:-mr-1 max-md:-mt-1 hover:bg-accent!"
|
||||
icon={deviceStore.isMobile ? X : PanelLeftClose}
|
||||
iconSize="h-4.5 w-4.5 md:h-4 md:w-4"
|
||||
iconSize="h-4 w-4"
|
||||
onclick={toggleExpandedMode}
|
||||
size="lg"
|
||||
tooltip="Close Sidebar"
|
||||
@@ -389,6 +402,8 @@
|
||||
bind:searchQuery
|
||||
class="px-2"
|
||||
isExpandedMode={innerWidth > 768 ? uiStore.isSidebarExpanded : true}
|
||||
onManageModelsClick={() => openDialog(() => uiStore.openModelsManager())}
|
||||
onMcpClick={() => openDialog(() => (mcpDialogOpen = true))}
|
||||
onNewChat={() => {
|
||||
if (deviceStore.isMobile) {
|
||||
scheduleMobileCollapse();
|
||||
@@ -402,7 +417,7 @@
|
||||
isSearchModeActive = false;
|
||||
searchQuery = '';
|
||||
}}
|
||||
onSettingsClick={() => (settingsDialogOpen = true)}
|
||||
onSettingsClick={() => openDialog(() => (settingsDialogOpen = true))}
|
||||
/>
|
||||
|
||||
{#if uiStore.isSidebarExpanded || isOnMobile}
|
||||
@@ -452,6 +467,12 @@
|
||||
|
||||
<DialogSettingsChat bind:open={settingsDialogOpen} />
|
||||
|
||||
<DialogMcpServers bind:open={mcpDialogOpen} />
|
||||
|
||||
<DialogManageModels bind:open={uiStore.manageModelsOpen} />
|
||||
|
||||
<DialogModelInformation />
|
||||
|
||||
<style>
|
||||
aside {
|
||||
@media (max-width: 768px) {
|
||||
|
||||
+47
-35
@@ -13,6 +13,7 @@
|
||||
} from '$lib/constants';
|
||||
import { SidebarAction, TooltipSide } from '$lib/enums';
|
||||
import { conversationsStore, deviceStore } from '$lib/stores';
|
||||
import type { DesktopIconStripItem } from '$lib/types';
|
||||
import type { Component } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { circIn } from 'svelte/easing';
|
||||
@@ -25,6 +26,8 @@
|
||||
searchQuery: string;
|
||||
onSearchDeactivated?: () => void;
|
||||
onSearchClick?: () => void;
|
||||
onManageModelsClick?: () => void;
|
||||
onMcpClick?: () => void;
|
||||
onNewChat?: () => void;
|
||||
onSettingsClick?: () => void;
|
||||
}
|
||||
@@ -33,6 +36,8 @@
|
||||
class: className,
|
||||
isExpandedMode = false,
|
||||
isSearchModeActive = $bindable(false),
|
||||
onManageModelsClick,
|
||||
onMcpClick,
|
||||
onNewChat,
|
||||
onSearchClick,
|
||||
onSearchDeactivated,
|
||||
@@ -40,6 +45,12 @@
|
||||
searchQuery = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
// the models manager stays desktop-only: a phone picks its models in the selector
|
||||
const actions = $derived(
|
||||
SIDEBAR_ACTIONS_ITEMS.filter(
|
||||
(item) => item.action !== SidebarAction.MANAGE_MODELS || !deviceStore.isMobile
|
||||
)
|
||||
);
|
||||
let initialized = $state(false);
|
||||
let showIcons = $state(false);
|
||||
let searchInputRef = $state<HTMLInputElement | null>(null);
|
||||
@@ -57,9 +68,40 @@
|
||||
|
||||
setTimeout(() => {
|
||||
initialized = true;
|
||||
}, ICON_STRIP_TRANSITION_DELAY_MULTIPLIER * SIDEBAR_ACTIONS_ITEMS.length);
|
||||
}, ICON_STRIP_TRANSITION_DELAY_MULTIPLIER * actions.length);
|
||||
});
|
||||
|
||||
/** Click handler of one icon strip item: a sidebar action, a route, or the search. */
|
||||
function itemClickHandler(
|
||||
item: DesktopIconStripItem,
|
||||
isSearchOnMobile: boolean
|
||||
): (() => void) | undefined {
|
||||
switch (item.action) {
|
||||
case SidebarAction.NEW_CHAT:
|
||||
return () => {
|
||||
onNewChat?.();
|
||||
void conversationsStore.openNewChat();
|
||||
};
|
||||
case SidebarAction.MANAGE_MODELS:
|
||||
return () => onManageModelsClick?.();
|
||||
case SidebarAction.MCP:
|
||||
return () => onMcpClick?.();
|
||||
case SidebarAction.SETTINGS:
|
||||
return () => onSettingsClick?.();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (item.route) {
|
||||
return () => {
|
||||
onNewChat?.();
|
||||
goto(item.route!);
|
||||
};
|
||||
}
|
||||
|
||||
return isSearchOnMobile ? undefined : onSearchClick;
|
||||
}
|
||||
|
||||
function handleSearchModeDeactivate() {
|
||||
isSearchModeActive = false;
|
||||
searchQuery = '';
|
||||
@@ -107,26 +149,11 @@
|
||||
? 'hidden pointer-events-none'
|
||||
: ''}"
|
||||
>
|
||||
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
|
||||
{#each actions as item, i (item.tooltip)}
|
||||
{@const isActive = isItemActive(item)}
|
||||
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
|
||||
{@const itemHref = isSearchOnMobile ? ROUTES.SEARCH : item.route}
|
||||
{@const itemOnClick =
|
||||
item.action === SidebarAction.NEW_CHAT
|
||||
? () => {
|
||||
onNewChat?.();
|
||||
void conversationsStore.openNewChat();
|
||||
}
|
||||
: item.action === SidebarAction.SETTINGS
|
||||
? () => onSettingsClick?.()
|
||||
: item.route
|
||||
? () => {
|
||||
onNewChat?.();
|
||||
goto(item.route!);
|
||||
}
|
||||
: isSearchOnMobile
|
||||
? undefined
|
||||
: onSearchClick}
|
||||
{@const itemOnClick = itemClickHandler(item, isSearchOnMobile)}
|
||||
{@const itemTransition = {
|
||||
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
|
||||
duration: ICON_STRIP_TRANSITION_DURATION,
|
||||
@@ -164,25 +191,10 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="{className} flex-col gap-1 hidden md:flex">
|
||||
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
|
||||
{#each actions as item, i (item.tooltip)}
|
||||
{@const isActive = isItemActive(item)}
|
||||
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
|
||||
{@const itemOnClick =
|
||||
item.action === SidebarAction.NEW_CHAT
|
||||
? () => {
|
||||
onNewChat?.();
|
||||
void conversationsStore.openNewChat();
|
||||
}
|
||||
: item.action === SidebarAction.SETTINGS
|
||||
? () => onSettingsClick?.()
|
||||
: item.route
|
||||
? () => {
|
||||
onNewChat?.();
|
||||
goto(item.route!);
|
||||
}
|
||||
: isSearchOnMobile
|
||||
? undefined
|
||||
: onSearchClick}
|
||||
{@const itemOnClick = itemClickHandler(item, isSearchOnMobile)}
|
||||
{@const itemTransition = {
|
||||
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
|
||||
duration: ICON_STRIP_TRANSITION_DURATION,
|
||||
|
||||
@@ -27,7 +27,9 @@
|
||||
outline:
|
||||
'shadow-sm hover:text-accent-foreground hover:bg-muted-foreground/10 backdrop-blur-sm dark:border-input border',
|
||||
secondary:
|
||||
'bg-background dark:bg-muted-foreground/15 dark:text-secondary-foreground shadow-sm text-foreground hover:bg-muted-foreground/20 dark:hover:bg-muted-foreground/25'
|
||||
'bg-muted/30 dark:bg-muted-foreground/15 dark:text-secondary-foreground shadow-sm border-muted border text-foreground hover:bg-muted dark:hover:bg-muted-foreground/25',
|
||||
tertiary:
|
||||
'bg-muted/60 dark:bg-muted/75 shadow-sm border border-border/30 text-foreground hover:bg-muted/80 dark:border-border/20 dark:hover:bg-muted'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
let {
|
||||
children,
|
||||
class: className,
|
||||
overlayClass,
|
||||
portalProps,
|
||||
ref = $bindable(null),
|
||||
showCloseButton = false,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||
overlayClass?: string;
|
||||
portalProps?: DialogPrimitive.PortalProps;
|
||||
children: Snippet;
|
||||
showCloseButton?: boolean;
|
||||
@@ -20,7 +22,7 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Portal {...portalProps}>
|
||||
<Dialog.Overlay />
|
||||
<Dialog.Overlay class={overlayClass} />
|
||||
|
||||
<DialogPrimitive.Content
|
||||
bind:ref
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import { cn, type WithElementRef } from '$lib/components/ui/utils';
|
||||
import { PANEL_CLOSE_MOBILE_CLASS } from '$lib/constants';
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
@@ -33,7 +34,7 @@
|
||||
|
||||
{#if showCloseButton}
|
||||
<DialogPrimitive.Close
|
||||
class="absolute top-0 right-0 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
class="absolute top-0 right-0 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none max-md:-top-3 max-md:-right-3 {PANEL_CLOSE_MOBILE_CLASS} [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DrawerPrimitive.CloseProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Close bind:ref data-slot="drawer-close" {...restProps} />
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import DrawerOverlay from './drawer-overlay.svelte';
|
||||
import DrawerPortal from './drawer-portal.svelte';
|
||||
import type { WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
|
||||
import { cn } from '$lib/components/ui/utils.js';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||
|
||||
let {
|
||||
children,
|
||||
class: className,
|
||||
portalProps,
|
||||
ref = $bindable(null),
|
||||
...restProps
|
||||
}: DrawerPrimitive.ContentProps & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DrawerPortal>>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPortal {...portalProps}>
|
||||
<DrawerOverlay />
|
||||
|
||||
<DrawerPrimitive.Content
|
||||
bind:ref
|
||||
class={cn(
|
||||
'bg-popover text-popover-foreground flex h-auto flex-col text-sm data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b group/drawer-content fixed z-50 data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:start-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:end-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm',
|
||||
className
|
||||
)}
|
||||
data-slot="drawer-content"
|
||||
{...restProps}
|
||||
>
|
||||
<div
|
||||
class="bg-muted mx-auto mt-4 hidden h-1 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block"
|
||||
></div>
|
||||
|
||||
{@render children?.()}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/components/ui/utils.js';
|
||||
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||
|
||||
let {
|
||||
class: className,
|
||||
ref = $bindable(null),
|
||||
...restProps
|
||||
}: DrawerPrimitive.DescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Description
|
||||
bind:ref
|
||||
class={cn('text-muted-foreground text-sm', className)}
|
||||
data-slot="drawer-description"
|
||||
{...restProps}
|
||||
/>
|
||||
+2
-2
@@ -12,8 +12,8 @@
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
class={cn('flex flex-col gap-1.5 p-4', className)}
|
||||
data-slot="sheet-header"
|
||||
class={cn('gap-2 p-4 mt-auto flex flex-col', className)}
|
||||
data-slot="drawer-footer"
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
+5
-2
@@ -12,8 +12,11 @@
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
class={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||
data-slot="sheet-footer"
|
||||
class={cn(
|
||||
'gap-0.5 p-4 md:gap-0.5 md:text-left flex flex-col group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center',
|
||||
className
|
||||
)}
|
||||
data-slot="drawer-header"
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||
|
||||
let {
|
||||
activeSnapPoint = $bindable(null),
|
||||
open = $bindable(false),
|
||||
shouldScaleBackground = true,
|
||||
...restProps
|
||||
}: DrawerPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.NestedRoot bind:activeSnapPoint bind:open {shouldScaleBackground} {...restProps} />
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/components/ui/utils.js';
|
||||
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||
|
||||
let {
|
||||
class: className,
|
||||
ref = $bindable(null),
|
||||
...restProps
|
||||
}: DrawerPrimitive.OverlayProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Overlay
|
||||
bind:ref
|
||||
class={cn(
|
||||
'bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
|
||||
className
|
||||
)}
|
||||
data-slot="drawer-overlay"
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||
|
||||
let { ...restProps }: DrawerPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Portal {...restProps} />
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/components/ui/utils.js';
|
||||
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||
|
||||
let {
|
||||
class: className,
|
||||
ref = $bindable(null),
|
||||
...restProps
|
||||
}: DrawerPrimitive.TitleProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Title
|
||||
bind:ref
|
||||
class={cn('text-foreground text-base font-medium', className)}
|
||||
data-slot="drawer-title"
|
||||
{...restProps}
|
||||
/>
|
||||
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user