mirror of
https://github.com/LostRuins/koboldcpp.git
synced 2026-09-19 09:15:18 +02:00
Merge branch 'upstream' into concedo_experimental
# Conflicts: # .devops/cann.Dockerfile # .devops/cpu.Dockerfile # .devops/cuda.Dockerfile # .devops/intel.Dockerfile # .devops/llama-cli-cann.Dockerfile # .devops/musa.Dockerfile # .devops/openvino.Dockerfile # .devops/rocm.Dockerfile # .devops/s390x.Dockerfile # .devops/vulkan.Dockerfile # .github/ISSUE_TEMPLATE/011-bug-results.yml # .github/ISSUE_TEMPLATE/019-bug-misc.yml # .github/workflows/build-and-test-snapdragon.yml # .github/workflows/docker.yml # .github/workflows/server-self-hosted.yml # .github/workflows/ui-ci.yml # .pi/gg/SYSTEM.md # README.md # common/arg.cpp # docs/backend/SYCL.md # docs/backend/snapdragon/CMakeUserPresets.json # docs/backend/snapdragon/README.md # docs/speculative.md # examples/save-load-state/save-load-state.cpp # ggml/src/ggml-hexagon/ggml-hexagon.cpp # ggml/src/ggml-hexagon/htp/CMakeLists.txt # ggml/src/ggml-hexagon/htp/htp-ctx.h # ggml/src/ggml-hexagon/htp/htp-ops.h # ggml/src/ggml-hexagon/htp/main.c # ggml/src/ggml-hexagon/htp/rope-ops.c # ggml/src/ggml-hexagon/htp/unary-ops.c # ggml/src/ggml-opencl/CMakeLists.txt # ggml/src/ggml-opencl/ggml-opencl.cpp # ggml/src/ggml-opencl/kernels/cvt.cl # ggml/src/ggml-sycl/ggml-sycl.cpp # ggml/src/ggml-webgpu/ggml-webgpu.cpp # ggml/src/ggml-webgpu/wgsl-shaders/gated_delta_net.wgsl # tools/cli/README.md # tools/server/README.md
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
VITE_PUBLIC_APP_NAME='llama-ui'
|
||||
# VITE_DEBUG='true'
|
||||
+1
-1
@@ -25,4 +25,4 @@ vite.config.ts.timestamp-*
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
*.code-workspace
|
||||
*.code-workspace
|
||||
|
||||
@@ -20,9 +20,7 @@ export default ts.config(
|
||||
prettier,
|
||||
...svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node }
|
||||
},
|
||||
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
||||
rules: {
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
@@ -30,6 +28,7 @@ export default ts.config(
|
||||
'svelte/no-at-html-tags': 'off',
|
||||
// This app uses hash-based routing (#/) where resolve() from $app/paths does not apply
|
||||
'svelte/no-navigation-without-resolve': 'off',
|
||||
|
||||
// Enforce empty line at end of file
|
||||
'eol-last': 'error'
|
||||
}
|
||||
|
||||
Generated
+30
-30
@@ -2307,9 +2307,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/kit": {
|
||||
"version": "2.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.59.1.tgz",
|
||||
"integrity": "sha512-d8OON70AphLdDesuTIl//M2O6fRTIicX8aYv8vhCiYEhTTI2OboKqey0Hu1A4VFhqwgqtq0vKDmPFGkw8kKmgw==",
|
||||
"version": "2.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.60.1.tgz",
|
||||
"integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2318,7 +2318,7 @@
|
||||
"@types/cookie": "^0.6.0",
|
||||
"acorn": "^8.14.1",
|
||||
"cookie": "^0.6.0",
|
||||
"devalue": "^5.6.4",
|
||||
"devalue": "^5.8.1",
|
||||
"esm-env": "^1.2.2",
|
||||
"kleur": "^4.1.5",
|
||||
"magic-string": "^0.30.5",
|
||||
@@ -4296,9 +4296,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/devalue": {
|
||||
"version": "5.6.4",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
|
||||
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
|
||||
"version": "5.8.1",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
|
||||
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/devlop": {
|
||||
@@ -4856,12 +4856,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.5.0",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.0.tgz",
|
||||
"integrity": "sha512-XKhFohWaSBdVJNTi5TaHziqnPkv04I9UQV6q1Wy7Ui6GGQZVW12ojDFwqer14EvCXxjvPG0CyWXx7cAXpALB4Q==",
|
||||
"version": "8.5.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
||||
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.1.0"
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -4909,9 +4909,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -5541,9 +5541,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.14",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
|
||||
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
|
||||
"version": "4.12.19",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.19.tgz",
|
||||
"integrity": "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -5722,9 +5722,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -6008,9 +6008,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.22",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz",
|
||||
"integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==",
|
||||
"version": "0.16.47",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
|
||||
"integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
@@ -9245,9 +9245,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "5.55.1",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.1.tgz",
|
||||
"integrity": "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==",
|
||||
"version": "5.55.7",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz",
|
||||
"integrity": "sha512-ymI5ykLPwIHW839E053FQbI1G+jnRFJEw3Kv5Y4njixVWywQBx+NUFpkkKyk5LIb36Fg9DVXSYpqiGekLD0hyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
@@ -9259,7 +9259,7 @@
|
||||
"aria-query": "5.3.1",
|
||||
"axobject-query": "^4.1.0",
|
||||
"clsx": "^2.1.1",
|
||||
"devalue": "^5.6.4",
|
||||
"devalue": "^5.8.1",
|
||||
"esm-env": "^1.2.1",
|
||||
"esrap": "^2.2.4",
|
||||
"is-reference": "^3.0.3",
|
||||
@@ -10606,9 +10606,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@import 'tailwindcss';
|
||||
@source ".";
|
||||
|
||||
@source '.';
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
+1
@@ -15,6 +15,7 @@
|
||||
|
||||
{#if videoSrc}
|
||||
<video controls class="mb-4 w-full" src={videoSrc}>
|
||||
<track kind="captions" src="" />
|
||||
Your browser does not support the video element.
|
||||
</video>
|
||||
{:else}
|
||||
|
||||
+20
-8
@@ -7,7 +7,6 @@
|
||||
import { activeMessages } from '$lib/stores/conversations.svelte';
|
||||
|
||||
interface Props {
|
||||
currentModel?: string;
|
||||
disabled?: boolean;
|
||||
forceForegroundText?: boolean;
|
||||
hasAudioModality?: boolean;
|
||||
@@ -20,7 +19,6 @@
|
||||
}
|
||||
|
||||
let {
|
||||
currentModel,
|
||||
disabled = false,
|
||||
forceForegroundText = false,
|
||||
hasAudioModality = $bindable(false),
|
||||
@@ -41,14 +39,28 @@
|
||||
|
||||
let lastSyncedConversationModel: string | null = null;
|
||||
|
||||
let selectorModel = $derived(conversationModel ?? modelsStore.selectedModelName ?? null);
|
||||
|
||||
$effect(() => {
|
||||
if (conversationModel && conversationModel !== lastSyncedConversationModel) {
|
||||
lastSyncedConversationModel = conversationModel;
|
||||
if (modelOptions().some((m) => m.model === conversationModel)) {
|
||||
modelsStore.selectedModelName = conversationModel;
|
||||
modelsStore.selectModelByName(conversationModel);
|
||||
} else {
|
||||
modelsStore.selectedModelName = null;
|
||||
modelsStore.clearSelection();
|
||||
}
|
||||
|
||||
modelsStore.selectModelByName(conversationModel);
|
||||
} else if (isRouter && !modelsStore.selectedModelId && modelsStore.loadedModelIds.length > 0) {
|
||||
lastSyncedConversationModel = conversationModel;
|
||||
} else if (
|
||||
isRouter &&
|
||||
!modelsStore.selectedModelId &&
|
||||
modelsStore.loadedModelIds.length > 0 &&
|
||||
activeMessages().length > 0 &&
|
||||
!conversationModel
|
||||
) {
|
||||
lastSyncedConversationModel = null;
|
||||
// auto-select the first loaded model only when nothing is selected yet
|
||||
|
||||
const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
|
||||
|
||||
if (first) modelsStore.selectModelById(first.id);
|
||||
@@ -151,7 +163,7 @@
|
||||
<ModelsSelectorSheet
|
||||
disabled={disabled || isOffline}
|
||||
bind:this={selectorModelRef}
|
||||
{currentModel}
|
||||
currentModel={selectorModel}
|
||||
{forceForegroundText}
|
||||
{useGlobalSelection}
|
||||
/>
|
||||
@@ -159,7 +171,7 @@
|
||||
<ModelsSelectorDropdown
|
||||
disabled={disabled || isOffline}
|
||||
bind:this={selectorModelRef}
|
||||
{currentModel}
|
||||
currentModel={selectorModel}
|
||||
{forceForegroundText}
|
||||
{useGlobalSelection}
|
||||
/>
|
||||
|
||||
+2
-2
@@ -162,7 +162,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||
console.log('[ChatFormPickerMcpPrompts] Fetching completions for:', {
|
||||
serverName: selectedPrompt.serverName,
|
||||
promptName: selectedPrompt.name,
|
||||
@@ -181,7 +181,7 @@
|
||||
value
|
||||
);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||
console.log('[ChatFormPickerMcpPrompts] Autocomplete result:', {
|
||||
argName,
|
||||
value,
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Trash2, AlertTriangle, RefreshCw } from '@lucide/svelte';
|
||||
import { Trash2 } from '@lucide/svelte';
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
|
||||
import {
|
||||
ChatScreenForm,
|
||||
ChatMessages,
|
||||
ChatScreenDragOverlay,
|
||||
ChatScreenProcessingInfo,
|
||||
ChatScreenActionScrollDown,
|
||||
DialogEmptyFileAlert,
|
||||
DialogFileUploadError,
|
||||
DialogChatError,
|
||||
ServerLoadingSplash,
|
||||
DialogConfirmation
|
||||
DialogConfirmation,
|
||||
ChatScreenServerError
|
||||
} from '$lib/components/app';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { setProcessingInfoContext } from '$lib/contexts';
|
||||
import { ErrorDialogType } from '$lib/enums';
|
||||
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
|
||||
@@ -34,11 +34,12 @@
|
||||
activeConversation
|
||||
} from '$lib/stores/conversations.svelte';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { serverLoading, serverError, serverStore, isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { serverLoading, serverError, isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
|
||||
import { isFileTypeSupported, filterFilesByModalities } from '$lib/utils';
|
||||
import { parseFilesToMessageExtras, processFilesToChatUploaded } from '$lib/utils/browser-only';
|
||||
import { onMount } from 'svelte';
|
||||
import ChatScreenGreeting from './ChatScreenGreeting.svelte';
|
||||
|
||||
let { showCenteredEmpty = false } = $props();
|
||||
|
||||
@@ -67,6 +68,8 @@
|
||||
|
||||
let showEmptyFileDialog = $state(false);
|
||||
|
||||
let processingInfoVisible = $state(false);
|
||||
|
||||
let emptyFileNames = $state<string[]>([]);
|
||||
|
||||
let initialMessage = $state('');
|
||||
@@ -174,6 +177,10 @@
|
||||
showDeleteDialog = false;
|
||||
}
|
||||
|
||||
function handleProcessingInfoVisibility(visible: boolean) {
|
||||
processingInfoVisible = visible;
|
||||
}
|
||||
|
||||
function handleDragEnter(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -338,7 +345,9 @@
|
||||
});
|
||||
|
||||
function handleMessagesReady() {
|
||||
if (!disableAutoScroll && !autoScroll.userScrolledUp) {
|
||||
if (disableAutoScroll) return;
|
||||
|
||||
if (!autoScroll.userScrolledUp) {
|
||||
requestAnimationFrame(() => {
|
||||
autoScroll.scrollToBottom('instant');
|
||||
});
|
||||
@@ -392,59 +401,32 @@
|
||||
{#if !isEmpty}
|
||||
<ChatMessages
|
||||
messages={activeMessages()}
|
||||
onMessagesReady={handleMessagesReady}
|
||||
onUserAction={() => {
|
||||
autoScroll.enable();
|
||||
if (!autoScroll.userScrolledUp) {
|
||||
autoScroll.scrollToBottom();
|
||||
}
|
||||
}}
|
||||
onMessagesReady={handleMessagesReady}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="pointer-events-none {isEmpty
|
||||
? 'absolute bottom-[calc(50dvh-7rem)]'
|
||||
: 'sticky bottom-4'} right-4 left-4 mt-auto pt-16 transition-all duration-200"
|
||||
class={[
|
||||
'pointer-events-none sticky right-4 left-4 mt-auto transition-all duration-200',
|
||||
isEmpty ? 'bottom-[calc(50dvh-7rem)]' : 'bottom-4 pt-24 md:pt-32'
|
||||
]}
|
||||
>
|
||||
{#if isEmpty}
|
||||
<div class="mb-8 px-4 text-center" use:fadeInView={{ duration: 300 }}>
|
||||
<h1 class="mb-2 text-2xl font-semibold tracking-tight md:text-3xl">Hello there</h1>
|
||||
<ChatScreenGreeting {isEmpty} />
|
||||
|
||||
<p class="text-muted-foreground md:text-lg">
|
||||
{serverStore.props?.modalities?.audio
|
||||
? 'Record audio, type a message '
|
||||
: 'Type a message'} or upload files to get started
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<ChatScreenActionScrollDown
|
||||
container={chatScrollContainer}
|
||||
hasProcessingInfoVisible={processingInfoVisible}
|
||||
/>
|
||||
|
||||
{#if page.params.id}
|
||||
<ChatScreenProcessingInfo />
|
||||
{/if}
|
||||
<ChatScreenProcessingInfo onVisibilityChange={handleProcessingInfoVisibility} />
|
||||
|
||||
{#if hasPropsError}
|
||||
<div
|
||||
class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1"
|
||||
use:fadeInView={{ y: 10, duration: 250 }}
|
||||
>
|
||||
<Alert.Root variant="destructive">
|
||||
<AlertTriangle class="h-4 w-4" />
|
||||
<Alert.Title class="flex items-center justify-between">
|
||||
<span>Server unavailable</span>
|
||||
<button
|
||||
onclick={() => serverStore.fetch()}
|
||||
disabled={isServerLoading}
|
||||
class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-2 py-1 text-xs font-medium hover:bg-destructive/30 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
|
||||
{isServerLoading ? 'Retrying...' : 'Retry'}
|
||||
</button>
|
||||
</Alert.Title>
|
||||
<Alert.Description>{serverError()}</Alert.Description>
|
||||
</Alert.Root>
|
||||
</div>
|
||||
{/if}
|
||||
<ChatScreenServerError />
|
||||
|
||||
<div class="conversation-chat-form pointer-events-auto rounded-t-3xl">
|
||||
<ChatScreenForm
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { ArrowDown } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
container: HTMLDivElement | undefined;
|
||||
hasProcessingInfoVisible: boolean;
|
||||
}
|
||||
|
||||
let { container, hasProcessingInfoVisible }: Props = $props();
|
||||
|
||||
let show = $state(false);
|
||||
|
||||
let buttonBottom = $derived(hasProcessingInfoVisible ? '2rem' : '0');
|
||||
|
||||
function checkVisibility() {
|
||||
if (!container) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const distanceFromBottom = scrollHeight - clientHeight - scrollTop;
|
||||
show = distanceFromBottom > clientHeight * 0.5;
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (container) {
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const c = container;
|
||||
if (c) {
|
||||
c.addEventListener('scroll', checkVisibility);
|
||||
checkVisibility();
|
||||
return () => {
|
||||
c.removeEventListener('scroll', checkVisibility);
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="pointer-events-{show
|
||||
? 'auto'
|
||||
: 'none'} relative z-50 mx-auto mb-4 flex max-w-[48rem] justify-center"
|
||||
>
|
||||
<Button
|
||||
onclick={scrollToBottom}
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class="pointer-events-all absolute h-10 w-10 rounded-full bg-background/80 shadow-lg backdrop-blur-sm transition-all duration-200 hover:bg-muted/80"
|
||||
style="bottom: {buttonBottom}; transform: translateY({show ? '0' : '2rem'}); opacity: {show
|
||||
? 1
|
||||
: 0};"
|
||||
aria-label="Scroll to bottom"
|
||||
>
|
||||
<ArrowDown class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
|
||||
interface Props {
|
||||
isEmpty: boolean;
|
||||
}
|
||||
|
||||
let { isEmpty = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
'pointer-events-none mb-4 hidden px-4 text-center',
|
||||
isEmpty && 'pointer-events-auto block!'
|
||||
]}
|
||||
use:fadeInView={{ duration: 300 }}
|
||||
>
|
||||
<h1 class="mb-2 text-2xl font-semibold tracking-tight md:text-3xl">Hello there</h1>
|
||||
|
||||
<p class="text-muted-foreground md:text-lg">
|
||||
{serverStore.props?.modalities?.audio ? 'Record audio, type a message ' : 'Type a message'} or upload
|
||||
files to get started
|
||||
</p>
|
||||
</div>
|
||||
@@ -6,6 +6,7 @@
|
||||
import { activeMessages, activeConversation } from '$lib/stores/conversations.svelte';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { getProcessingInfoContext } from '$lib/contexts';
|
||||
import { page } from '$app/state';
|
||||
|
||||
const processingState = useProcessingState();
|
||||
const processingInfoCtx = getProcessingInfoContext();
|
||||
@@ -16,6 +17,14 @@
|
||||
let isStreaming = $derived(isChatStreaming());
|
||||
let processingDetails = $derived(processingState.getTechnicalDetails());
|
||||
|
||||
let processingVisible = $derived(processingDetails.length > 0);
|
||||
|
||||
let { onVisibilityChange }: { onVisibilityChange?: (visible: boolean) => void } = $props();
|
||||
|
||||
$effect(() => {
|
||||
onVisibilityChange?.(processingVisible);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const conversation = activeConversation();
|
||||
|
||||
@@ -60,9 +69,12 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={['chat-processing-info-container pointer-events-none', showProcessingInfo && 'visible']}
|
||||
class={[
|
||||
'chat-processing-info-container pointer-events-none relative',
|
||||
page.params.id && showProcessingInfo && 'visible'
|
||||
]}
|
||||
>
|
||||
<div class="chat-processing-info-content">
|
||||
<div class="chat-processing-info-content absolute bottom-4 left-1/2 -translate-x-1/2">
|
||||
{#each processingDetails as detail (detail)}
|
||||
<span class="chat-processing-info-detail pointer-events-auto backdrop-blur-sm">{detail}</span>
|
||||
{/each}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { AlertTriangle, RefreshCw } from '@lucide/svelte';
|
||||
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { serverError, serverLoading, serverStore } from '$lib/stores/server.svelte';
|
||||
|
||||
let hasError = $derived(!!serverError());
|
||||
</script>
|
||||
|
||||
{#if hasError}
|
||||
<div
|
||||
class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1"
|
||||
use:fadeInView={{ y: 10, duration: 250 }}
|
||||
>
|
||||
<Alert.Root variant="destructive">
|
||||
<AlertTriangle class="h-4 w-4" />
|
||||
|
||||
<Alert.Title class="flex items-center justify-between">
|
||||
<span>Server unavailable</span>
|
||||
|
||||
<button
|
||||
onclick={() => serverStore.fetch()}
|
||||
disabled={serverLoading()}
|
||||
class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-2 py-1 text-xs font-medium hover:bg-destructive/30 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw class="h-3 w-3 {serverLoading() ? 'animate-spin' : ''}" />
|
||||
{serverLoading() ? 'Retrying...' : 'Retry'}
|
||||
</button>
|
||||
</Alert.Title>
|
||||
|
||||
<Alert.Description>{serverError()}</Alert.Description>
|
||||
</Alert.Root>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -667,3 +667,17 @@ export { default as ChatScreenForm } from './ChatScreen/ChatScreenForm.svelte';
|
||||
* Only visible when `isCurrentConversationLoading` is true.
|
||||
*/
|
||||
export { default as ChatScreenProcessingInfo } from './ChatScreen/ChatScreenProcessingInfo.svelte';
|
||||
|
||||
/**
|
||||
* Scroll-to-bottom action button. Displays a floating button when the user
|
||||
* has scrolled up more than half a viewport height from the bottom.
|
||||
* Takes the chat container element as a prop to manage scroll state internally.
|
||||
*/
|
||||
export { default as ChatScreenActionScrollDown } from './ChatScreen/ChatScreenActionScrollDown.svelte';
|
||||
|
||||
/**
|
||||
* Server error alert displayed when the server is unreachable.
|
||||
* Shows the error message with a retry button.
|
||||
* Rendered inside ChatScreen when `serverError` store has a value.
|
||||
*/
|
||||
export { default as ChatScreenServerError } from './ChatScreen/ChatScreenServerError.svelte';
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
SETTINGS_KEYS
|
||||
} from '$lib/constants';
|
||||
import { ColorMode, UrlProtocol } from '$lib/enums';
|
||||
import { FileTypeText } from '$lib/enums/files';
|
||||
import { FileTypeText } from '$lib/enums/files.enums';
|
||||
import { highlightCode, detectIncompleteCodeBlock, type IncompleteCodeBlock } from '$lib/utils';
|
||||
import '$styles/katex-custom.scss';
|
||||
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
} from '$lib/constants';
|
||||
import { RouterService } from '$lib/services/router.service';
|
||||
import { setMode } from 'mode-watcher';
|
||||
import { ColorMode } from '$lib/enums/ui';
|
||||
import { ColorMode } from '$lib/enums/ui.enums';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { SETTING_CONFIG_INFO, SETTINGS_KEYS } from '$lib/constants';
|
||||
import { SettingsFieldType } from '$lib/enums/settings';
|
||||
import { SettingsFieldType } from '$lib/enums/settings.enums';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { modelsStore, selectedModelName, propsCacheVersion } from '$lib/stores/models.svelte';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Zap, Globe, Radio } from '@lucide/svelte';
|
||||
import { MCPTransportType } from '$lib/enums';
|
||||
import type { ClientCapabilities, Implementation } from '$lib/types';
|
||||
import type { Component } from 'svelte';
|
||||
import { MimeTypeImage } from '$lib/enums/files';
|
||||
import { MimeTypeImage } from '$lib/enums/files.enums';
|
||||
|
||||
export const DEFAULT_CLIENT_VERSION = '1.0.0';
|
||||
export const MCP_CLIENT_NAME = 'llama-ui-mcp';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ColorMode } from '$lib/enums/ui';
|
||||
import { SettingsFieldType } from '$lib/enums/settings';
|
||||
import { ColorMode } from '$lib/enums/ui.enums';
|
||||
import { SettingsFieldType } from '$lib/enums/settings.enums';
|
||||
import { SyncableParameterType } from '$lib/enums';
|
||||
import {
|
||||
Funnel,
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
MimeTypeApplication,
|
||||
MimeTypeText
|
||||
} from '$lib/enums';
|
||||
import { FileExtensionVideo, FileTypeVideo } from '$lib/enums/files';
|
||||
import { FileExtensionVideo, FileTypeVideo } from '$lib/enums/files.enums';
|
||||
|
||||
// File type configuration using enums
|
||||
export const AUDIO_FILE_TYPES = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ToolSource } from '$lib/enums/tools';
|
||||
import { ToolSource } from '$lib/enums/tools.enums';
|
||||
|
||||
export const TOOL_GROUP_LABELS = {
|
||||
[ToolSource.BUILTIN]: 'Built-in',
|
||||
|
||||
@@ -4,9 +4,9 @@ export {
|
||||
AttachmentItemEnabledWhen,
|
||||
AttachmentAction,
|
||||
AttachmentItemVisibleWhen
|
||||
} from './attachment';
|
||||
} from './attachment.enums';
|
||||
|
||||
export { AgenticSectionType, ToolCallType } from './agentic';
|
||||
export { AgenticSectionType, ToolCallType } from './agentic.enums';
|
||||
|
||||
export {
|
||||
ChatMessageStatsView,
|
||||
@@ -17,7 +17,7 @@ export {
|
||||
MessageType,
|
||||
PdfViewMode,
|
||||
ReasoningFormat
|
||||
} from './chat';
|
||||
} from './chat.enums';
|
||||
|
||||
export {
|
||||
FileTypeCategory,
|
||||
@@ -38,7 +38,7 @@ export {
|
||||
MimeTypeImage,
|
||||
MimeTypeText,
|
||||
SpecialFileType
|
||||
} from './files';
|
||||
} from './files.enums';
|
||||
|
||||
export {
|
||||
MCPConnectionPhase,
|
||||
@@ -48,16 +48,16 @@ export {
|
||||
MCPContentType,
|
||||
MCPRefType,
|
||||
JsonSchemaType
|
||||
} from './mcp';
|
||||
} from './mcp.enums';
|
||||
|
||||
export { ModelModality } from './model';
|
||||
export { ModelModality } from './model.enums';
|
||||
|
||||
export { ServerRole, ServerModelStatus } from './server';
|
||||
export { ServerRole, ServerModelStatus } from './server.enums';
|
||||
|
||||
export { ParameterSource, SyncableParameterType, SettingsFieldType } from './settings';
|
||||
export { ParameterSource, SyncableParameterType, SettingsFieldType } from './settings.enums';
|
||||
|
||||
export { ColorMode, HtmlInputType, McpPromptVariant, TooltipSide, UrlProtocol } from './ui';
|
||||
export { ColorMode, HtmlInputType, McpPromptVariant, TooltipSide, UrlProtocol } from './ui.enums';
|
||||
|
||||
export { KeyboardKey } from './keyboard';
|
||||
export { KeyboardKey } from './keyboard.enums';
|
||||
|
||||
export { ToolSource, ToolPermissionDecision, ToolResponseField } from './tools';
|
||||
export { ToolSource, ToolPermissionDecision, ToolResponseField } from './tools.enums';
|
||||
|
||||
@@ -100,6 +100,14 @@ export class AutoScrollController {
|
||||
this._autoScrollEnabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets scroll state when switching conversations.
|
||||
*/
|
||||
resetScrollState(): void {
|
||||
this._userScrolledUp = false;
|
||||
this._autoScrollEnabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the auto-scroll interval for continuous scrolling during streaming.
|
||||
*/
|
||||
|
||||
@@ -66,7 +66,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
const serverModel = $derived(singleModelName());
|
||||
|
||||
const currentModel = $derived(opts.currentModel());
|
||||
const useGlobalSelection = $derived(opts.useGlobalSelection?.() ?? false);
|
||||
const onModelChange = $derived(opts.onModelChange?.());
|
||||
|
||||
const isHighlightedCurrentModelActive = $derived.by(() => {
|
||||
@@ -128,6 +127,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
|
||||
if (onModelChange) {
|
||||
const result = await onModelChange(option.id, option.model);
|
||||
|
||||
if (result === false) {
|
||||
shouldCloseMenu = false;
|
||||
}
|
||||
@@ -142,12 +142,14 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>(
|
||||
'[data-slot="chat-form"] textarea'
|
||||
);
|
||||
|
||||
textarea?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) {
|
||||
isLoadingModel = true;
|
||||
|
||||
modelsStore
|
||||
.loadModel(option.model)
|
||||
.catch((error) => console.error('Failed to load model:', error))
|
||||
@@ -158,6 +160,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
function getDisplayOption(): ModelOption | undefined {
|
||||
if (!isRouter) {
|
||||
const displayModel = serverModel || currentModel;
|
||||
|
||||
if (displayModel) {
|
||||
return {
|
||||
id: serverModel ? 'current' : 'offline-current',
|
||||
@@ -166,12 +169,8 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
capabilities: []
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (useGlobalSelection && activeId) {
|
||||
const selected = options.find((option) => option.id === activeId);
|
||||
if (selected) return selected;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (currentModel) {
|
||||
@@ -183,6 +182,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
capabilities: []
|
||||
};
|
||||
}
|
||||
|
||||
return options.find((option) => option.model === currentModel);
|
||||
}
|
||||
|
||||
@@ -197,57 +197,77 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
get options() {
|
||||
return options;
|
||||
},
|
||||
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
|
||||
get updating() {
|
||||
return updating;
|
||||
},
|
||||
|
||||
get activeId() {
|
||||
return activeId;
|
||||
},
|
||||
|
||||
get isRouter() {
|
||||
return isRouter;
|
||||
},
|
||||
|
||||
get serverModel() {
|
||||
return serverModel;
|
||||
},
|
||||
|
||||
get isHighlightedCurrentModelActive() {
|
||||
return isHighlightedCurrentModelActive;
|
||||
},
|
||||
|
||||
get isCurrentModelInCache() {
|
||||
return isCurrentModelInCache;
|
||||
},
|
||||
|
||||
get filteredOptions() {
|
||||
return filteredOptions;
|
||||
},
|
||||
|
||||
get groupedFilteredOptions() {
|
||||
return groupedFilteredOptions;
|
||||
},
|
||||
|
||||
get isLoadingModel() {
|
||||
return isLoadingModel;
|
||||
},
|
||||
|
||||
get searchTerm() {
|
||||
return searchTerm;
|
||||
},
|
||||
|
||||
get showModelDialog() {
|
||||
return showModelDialog;
|
||||
},
|
||||
|
||||
get infoModelId() {
|
||||
return infoModelId;
|
||||
},
|
||||
|
||||
setSearchTerm(value: string) {
|
||||
searchTerm = value;
|
||||
},
|
||||
|
||||
setShowModelDialog(value: boolean) {
|
||||
showModelDialog = value;
|
||||
},
|
||||
|
||||
handleInfoClick,
|
||||
|
||||
handleSelect,
|
||||
|
||||
handleOpenChange,
|
||||
|
||||
isFavorite(model: string) {
|
||||
return modelsStore.favoriteModelIds.has(model);
|
||||
},
|
||||
|
||||
getDisplayOption
|
||||
};
|
||||
}
|
||||
|
||||
@@ -392,7 +392,7 @@ export class MCPService {
|
||||
|
||||
const url = new URL(config.url);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||
console.log(`[MCPService] Creating WebSocket transport for ${url.href}`);
|
||||
}
|
||||
|
||||
@@ -413,12 +413,12 @@ export class MCPService {
|
||||
onLog
|
||||
);
|
||||
|
||||
if (useProxy && import.meta.env.DEV) {
|
||||
if (useProxy && import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||
console.log(`[MCPService] Using CORS proxy for ${config.url} -> ${url.href}`);
|
||||
}
|
||||
|
||||
try {
|
||||
if (import.meta.env.DEV) {
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||
console.log(`[MCPService] Creating StreamableHTTP transport for ${url.href}`);
|
||||
}
|
||||
|
||||
@@ -520,7 +520,7 @@ export class MCPService {
|
||||
)
|
||||
);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||
console.log(`[MCPService][${serverName}] Creating transport...`);
|
||||
}
|
||||
|
||||
@@ -560,6 +560,22 @@ export class MCPService {
|
||||
);
|
||||
|
||||
const runtimeErrorHandler = (error: Error) => {
|
||||
// Ignore errors that are expected when the SDK's transport is closed,
|
||||
// or when connecting to servers that don't support SSE (stateless-only
|
||||
// endpoints returning 405). The SDK wraps the original AbortError in
|
||||
// a new Error with the message "SSE stream disconnected: AbortError",
|
||||
// and also produces "Cannot cancel a stream locked by a reader".
|
||||
// DOMException is thrown by the browser when aborting fetch requests.
|
||||
const msg = error.message || String(error);
|
||||
if (
|
||||
error.name === 'AbortError' ||
|
||||
error instanceof DOMException ||
|
||||
msg.includes('SSE stream disconnected') ||
|
||||
msg.includes('stream locked by a reader') ||
|
||||
msg.includes('The operation was aborted')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
console.error(`[MCPService][${serverName}] Protocol error after initialize:`, error);
|
||||
};
|
||||
|
||||
@@ -658,7 +674,10 @@ export class MCPService {
|
||||
this.createLog(MCPConnectionPhase.LISTING_TOOLS, 'Listing available tools...')
|
||||
);
|
||||
|
||||
console.log(`[MCPService][${serverName}] Connected, listing tools...`);
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||
console.log(`[MCPService][${serverName}] Connected, listing tools...`);
|
||||
}
|
||||
|
||||
const tools = await this.listTools({
|
||||
client,
|
||||
transport,
|
||||
@@ -680,10 +699,11 @@ export class MCPService {
|
||||
`Connection established with ${tools.length} tools (${connectionTimeMs}ms)`
|
||||
)
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[MCPService][${serverName}] Initialization complete with ${tools.length} tools in ${connectionTimeMs}ms`
|
||||
);
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||
console.log(
|
||||
`[MCPService][${serverName}] Initialization complete with ${tools.length} tools in ${connectionTimeMs}ms`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
client,
|
||||
@@ -709,9 +729,22 @@ export class MCPService {
|
||||
* @param connection - The active MCP connection to close
|
||||
*/
|
||||
static async disconnect(connection: MCPConnection): Promise<void> {
|
||||
console.log(`[MCPService][${connection.serverName}] Disconnecting...`);
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||
console.log(`[MCPService][${connection.serverName}] Disconnecting...`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Prevent reconnection on voluntary disconnect
|
||||
// Terminate the session first for streamable-http transports to cleanly
|
||||
// close streams, matching the inspector's disconnect flow.
|
||||
if (connection.transport instanceof StreamableHTTPClientTransport) {
|
||||
await connection.transport.terminateSession();
|
||||
}
|
||||
|
||||
// Clear error handlers before closing to prevent noise from expected
|
||||
// abort errors during shutdown. The inspector avoids this entirely
|
||||
// by not setting onerror, but since we use it for protocol logging,
|
||||
// we must clear it before disconnect.
|
||||
connection.client.onerror = undefined;
|
||||
if (connection.transport.onclose) {
|
||||
connection.transport.onclose = undefined;
|
||||
}
|
||||
@@ -1078,7 +1111,9 @@ export class MCPService {
|
||||
try {
|
||||
await connection.client.unsubscribeResource({ uri });
|
||||
|
||||
console.log(`[MCPService][${connection.serverName}] Unsubscribed from resource: ${uri}`);
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||
console.log(`[MCPService][${connection.serverName}] Unsubscribed from resource: ${uri}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[MCPService][${connection.serverName}] Failed to unsubscribe from resource:`,
|
||||
|
||||
@@ -119,7 +119,8 @@ const localStorageMigration: Migration = {
|
||||
// Only migrate if new key doesn't already exist
|
||||
const newValue = localStorage.getItem(newKey);
|
||||
if (newValue !== null) {
|
||||
console.log(`[Migration] localStorage: ${newKey} already exists, skipping`);
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||
console.log(`[Migration] localStorage: ${newKey} already exists, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -127,9 +128,11 @@ const localStorageMigration: Migration = {
|
||||
if (oldValue !== null) {
|
||||
localStorage.setItem(newKey, oldValue);
|
||||
// Keep old key for downgrade compatibility - DO NOT DELETE
|
||||
console.log(
|
||||
`[Migration] localStorage: copied ${deprecatedKey} → ${newKey} (preserved old)`
|
||||
);
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||
console.log(
|
||||
`[Migration] localStorage: copied ${deprecatedKey} → ${newKey} (preserved old)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,7 +149,8 @@ const idxdbMigration: Migration = {
|
||||
async run(): Promise<void> {
|
||||
const oldDbNames = await Dexie.getDatabaseNames();
|
||||
if (!oldDbNames.includes(DB_APP_NAME_DEPRECATED)) {
|
||||
console.log('[Migration] IndexedDB: no old database found, skipping');
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||
console.log('[Migration] IndexedDB: no old database found, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,11 +159,13 @@ const idxdbMigration: Migration = {
|
||||
newDb.version(1).stores(IDXDB_STORES);
|
||||
const existingConvs = await newDb.table(IDXDB_TABLES.conversations).count();
|
||||
if (existingConvs > 0) {
|
||||
console.log('[Migration] IndexedDB: new database already has data, skipping');
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||
console.log('[Migration] IndexedDB: new database already has data, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Migration] IndexedDB: copying from', DB_APP_NAME_DEPRECATED);
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||
console.log('[Migration] IndexedDB: copying from', DB_APP_NAME_DEPRECATED);
|
||||
|
||||
const oldDb = new Dexie(DB_APP_NAME_DEPRECATED);
|
||||
oldDb.version(1).stores(IDXDB_STORES);
|
||||
@@ -169,15 +175,18 @@ const idxdbMigration: Migration = {
|
||||
|
||||
if (conversations.length > 0) {
|
||||
await newDb.table(IDXDB_TABLES.conversations).bulkAdd(conversations);
|
||||
console.log(`[Migration] IndexedDB: copied ${conversations.length} conversations`);
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||
console.log(`[Migration] IndexedDB: copied ${conversations.length} conversations`);
|
||||
}
|
||||
if (messages.length > 0) {
|
||||
await newDb.table(IDXDB_TABLES.messages).bulkAdd(messages);
|
||||
console.log(`[Migration] IndexedDB: copied ${messages.length} messages`);
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||
console.log(`[Migration] IndexedDB: copied ${messages.length} messages`);
|
||||
}
|
||||
|
||||
// Non-destructive: DO NOT delete old database - keep for downgrade compatibility
|
||||
console.log('[Migration] IndexedDB: preserved old database for downgrade compatibility');
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||
console.log('[Migration] IndexedDB: preserved old database for downgrade compatibility');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -419,7 +428,8 @@ const legacyMessageMigration: Migration = {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Migration] Legacy messages: migrated ${migratedCount} messages`);
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||
console.log(`[Migration] Legacy messages: migrated ${migratedCount} messages`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -434,7 +444,8 @@ const themeMigration: Migration = {
|
||||
async run(): Promise<void> {
|
||||
const legacyTheme = localStorage.getItem('theme');
|
||||
if (legacyTheme === null) {
|
||||
console.log('[Migration] Theme: no legacy theme key found, skipping');
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||
console.log('[Migration] Theme: no legacy theme key found, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -443,7 +454,8 @@ const themeMigration: Migration = {
|
||||
const config = configRaw ? JSON.parse(configRaw) : {};
|
||||
|
||||
if (SETTINGS_KEYS.THEME in config) {
|
||||
console.log('[Migration] Theme: config already has theme, skipping');
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||
console.log('[Migration] Theme: config already has theme, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -451,7 +463,8 @@ const themeMigration: Migration = {
|
||||
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
|
||||
|
||||
// Non-destructive: DO NOT delete legacy theme key - keep for downgrade compatibility
|
||||
console.log(`[Migration] Theme: copied standalone theme to config (preserved old key)`);
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||
console.log(`[Migration] Theme: copied standalone theme to config (preserved old key)`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -491,7 +504,8 @@ export const MigrationService = {
|
||||
*/
|
||||
resetState(): void {
|
||||
localStorage.removeItem(MIGRATION_STATE_KEY);
|
||||
console.log('[Migration] State reset - all migrations will run again');
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||
console.log('[Migration] State reset - all migrations will run again');
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -500,25 +514,30 @@ export const MigrationService = {
|
||||
*/
|
||||
async runAllMigrations(): Promise<void> {
|
||||
const state = getMigrationState();
|
||||
console.log('[Migration] Starting migration run, state:', state);
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||
console.log('[Migration] Starting migration run, state:', state);
|
||||
|
||||
for (const migration of migrations) {
|
||||
if (isMigrationCompleted(migration.id)) {
|
||||
console.log(`[Migration] ${migration.id}: already completed, skipping`);
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||
console.log(`[Migration] ${migration.id}: already completed, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[Migration] ${migration.id}: running...`);
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||
console.log(`[Migration] ${migration.id}: running...`);
|
||||
await migration.run();
|
||||
markMigrationCompleted(migration.id);
|
||||
console.log(`[Migration] ${migration.id}: completed successfully`);
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||
console.log(`[Migration] ${migration.id}: completed successfully`);
|
||||
} catch (error) {
|
||||
console.error(`[Migration] ${migration.id}: failed`, error);
|
||||
markMigrationFailed(migration.id);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Migration] All migrations complete');
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||
console.log('[Migration] All migrations complete');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { base } from '$app/paths';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { MCPService } from '$lib/services/mcp.service';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { mode } from 'mode-watcher';
|
||||
import {
|
||||
parseMcpServerSettings,
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
ToolCallType
|
||||
} from '$lib/enums';
|
||||
import {
|
||||
CORS_PROXY_ENDPOINT,
|
||||
DEFAULT_CACHE_TTL_MS,
|
||||
DEFAULT_MCP_CONFIG,
|
||||
EXPECTED_THEMED_ICON_PAIR_COUNT,
|
||||
@@ -86,7 +85,6 @@ class MCPStore {
|
||||
private _toolCount = $state(0);
|
||||
private _connectedServers = $state<string[]>([]);
|
||||
private _healthChecks = $state<Record<string, HealthCheckState>>({});
|
||||
private _proxyAvailable = $state(false);
|
||||
|
||||
private connections = new Map<string, MCPConnection>();
|
||||
private toolsIndex = new Map<string, string>();
|
||||
@@ -96,27 +94,8 @@ class MCPStore {
|
||||
private initPromise: Promise<boolean> | null = null;
|
||||
private activeFlowCount = 0;
|
||||
|
||||
constructor() {
|
||||
if (browser) {
|
||||
this.probeProxy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probes the CORS proxy endpoint to determine availability.
|
||||
* The endpoint is only registered when llama-server runs with --ui-mcp-proxy.
|
||||
*/
|
||||
async probeProxy(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${base}${CORS_PROXY_ENDPOINT}`, { method: 'HEAD' });
|
||||
this._proxyAvailable = response.status !== 404;
|
||||
} catch {
|
||||
this._proxyAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
get isProxyAvailable(): boolean {
|
||||
return this._proxyAvailable;
|
||||
return serverStore.props?.cors_proxy_enabled ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@ import { toast } from 'svelte-sonner';
|
||||
import { ServerModelStatus, ModelModality } from '$lib/enums';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import { PropsService } from '$lib/services/props.service';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { serverStore, isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { TTLCache } from '$lib/utils';
|
||||
import {
|
||||
MODEL_PROPS_CACHE_TTL_MS,
|
||||
@@ -14,14 +14,7 @@ import {
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
|
||||
/**
|
||||
* modelsStore - Reactive store for model management in both MODEL and ROUTER modes
|
||||
*
|
||||
* This store manages:
|
||||
* - Available models list
|
||||
* - Selected model for new conversations
|
||||
* - Loaded models tracking (ROUTER mode)
|
||||
* - Model usage tracking per conversation
|
||||
* - Automatic unloading of unused models
|
||||
* modelsStore - Reactive store for model management in both MODEL and ROUTER modes.
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **ModelsService**: Stateless service for model API communication
|
||||
@@ -31,14 +24,8 @@ import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
*
|
||||
* **API Inconsistency Workaround:**
|
||||
* In MODEL mode, `/props` returns modalities for the single model.
|
||||
* In ROUTER mode, `/props` has no modalities - must use `/props?model=<id>` per model.
|
||||
* In ROUTER mode, `/props` has no modalities — must use `/props?model=<id>` per model.
|
||||
* This store normalizes this behavior so consumers don't need to know the server mode.
|
||||
*
|
||||
* **Key Features:**
|
||||
* - **MODEL mode**: Single model, always loaded
|
||||
* - **ROUTER mode**: Multi-model with load/unload capability
|
||||
* - **Auto-unload**: Automatically unloads models not used by any conversation
|
||||
* - **Lazy loading**: ensureModelLoaded() loads models on demand
|
||||
*/
|
||||
class ModelsStore {
|
||||
/**
|
||||
@@ -57,8 +44,8 @@ class ModelsStore {
|
||||
selectedModelId = $state<string | null>(null);
|
||||
selectedModelName = $state<string | null>(null);
|
||||
|
||||
// dedup concurrent fetch() callers, all awaiters share the same inflight promise
|
||||
// without this, ?model=<name> URL handler raced an in-progress fetch and saw an empty list
|
||||
// Dedup concurrent fetch() callers — all awaiters share the same inflight promise.
|
||||
// Without this, ?model=<name> URL handler races an in-progress fetch and sees an empty list.
|
||||
private inflightFetch: Promise<void> | null = null;
|
||||
|
||||
private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map());
|
||||
@@ -67,9 +54,9 @@ class ModelsStore {
|
||||
favoriteModelIds = $state<Set<string>>(this.loadFavoritesFromStorage());
|
||||
|
||||
/**
|
||||
* Model-specific props cache with TTL
|
||||
* Key: modelId, Value: props data including modalities
|
||||
* TTL: 10 minutes - props don't change frequently
|
||||
* Model-specific props cache with TTL.
|
||||
* Key: modelId, Value: props data including modalities.
|
||||
* TTL: 10 minutes — props don't change frequently.
|
||||
*/
|
||||
private modelPropsCache = new TTLCache<string, ApiLlamaCppServerProps>({
|
||||
ttlMs: MODEL_PROPS_CACHE_TTL_MS,
|
||||
@@ -78,7 +65,7 @@ class ModelsStore {
|
||||
private modelPropsFetching = $state<Set<string>>(new Set());
|
||||
|
||||
/**
|
||||
* Version counter for props cache - used to trigger reactivity when props are updated
|
||||
* Version counter for props cache — used to trigger reactivity when props are updated.
|
||||
*/
|
||||
propsCacheVersion = $state(0);
|
||||
|
||||
@@ -92,7 +79,7 @@ class ModelsStore {
|
||||
|
||||
get selectedModel(): ModelOption | null {
|
||||
if (!this.selectedModelId) return null;
|
||||
return this.models.find((model) => model.id === this.selectedModelId) ?? null;
|
||||
return this.models.find((m) => m.id === this.selectedModelId) ?? null;
|
||||
}
|
||||
|
||||
get loadedModelIds(): string[] {
|
||||
@@ -117,7 +104,7 @@ class ModelsStore {
|
||||
* In ROUTER mode, returns null (model is per-conversation).
|
||||
*/
|
||||
get singleModelName(): string | null {
|
||||
if (serverStore.isRouterMode) return null;
|
||||
if (isRouterMode()) return null;
|
||||
|
||||
const props = serverStore.props;
|
||||
if (props?.model_alias) return props.model_alias;
|
||||
@@ -126,6 +113,11 @@ class ModelsStore {
|
||||
return props.model_path.split(/(\\|\/)/).pop() || null;
|
||||
}
|
||||
|
||||
get selectedModelContextSize(): number | null {
|
||||
if (!this.selectedModelName) return null;
|
||||
return this.getModelContextSize(this.selectedModelName);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
@@ -134,10 +126,6 @@ class ModelsStore {
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get modalities for a specific model
|
||||
* Returns cached modalities from model props
|
||||
*/
|
||||
getModelModalities(modelId: string): ModelModalities | null {
|
||||
const model = this.models.find((m) => m.model === modelId || m.id === modelId);
|
||||
if (model?.modalities) {
|
||||
@@ -146,46 +134,29 @@ class ModelsStore {
|
||||
|
||||
const props = this.modelPropsCache.get(modelId);
|
||||
if (props?.modalities) {
|
||||
return {
|
||||
vision: props.modalities.vision ?? false,
|
||||
audio: props.modalities.audio ?? false,
|
||||
video: props.modalities.video ?? false
|
||||
};
|
||||
return this.buildModalities(props.modalities);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model supports vision modality
|
||||
*/
|
||||
modelSupportsVision(modelId: string): boolean {
|
||||
return this.getModelModalities(modelId)?.vision ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model supports audio modality
|
||||
*/
|
||||
modelSupportsAudio(modelId: string): boolean {
|
||||
return this.getModelModalities(modelId)?.audio ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model supports video modality
|
||||
*/
|
||||
modelSupportsVideo(modelId: string): boolean {
|
||||
return this.getModelModalities(modelId)?.video ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model modalities as an array of ModelModality enum values
|
||||
*/
|
||||
getModelModalitiesArray(modelId: string): ModelModality[] {
|
||||
const modalities = this.getModelModalities(modelId);
|
||||
if (!modalities) return [];
|
||||
|
||||
const result: ModelModality[] = [];
|
||||
|
||||
if (modalities.vision) result.push(ModelModality.VISION);
|
||||
if (modalities.audio) result.push(ModelModality.AUDIO);
|
||||
if (modalities.video) result.push(ModelModality.VIDEO);
|
||||
@@ -193,16 +164,10 @@ class ModelsStore {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get props for a specific model (from cache)
|
||||
*/
|
||||
getModelProps(modelId: string): ApiLlamaCppServerProps | null {
|
||||
return this.modelPropsCache.get(modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get context size (n_ctx) for a specific model from cached props
|
||||
*/
|
||||
getModelContextSize(modelId: string): number | null {
|
||||
const props = this.getModelProps(modelId);
|
||||
const nCtx = props?.default_generation_settings?.n_ctx;
|
||||
@@ -210,17 +175,6 @@ class ModelsStore {
|
||||
return typeof nCtx === 'number' ? nCtx : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get context size for the currently selected model or null if no model is selected
|
||||
*/
|
||||
get selectedModelContextSize(): number | null {
|
||||
if (!this.selectedModelName) return null;
|
||||
return this.getModelContextSize(this.selectedModelName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if props are being fetched for a model
|
||||
*/
|
||||
isModelPropsFetching(modelId: string): boolean {
|
||||
return this.modelPropsFetching.has(modelId);
|
||||
}
|
||||
@@ -235,10 +189,10 @@ class ModelsStore {
|
||||
|
||||
isModelLoaded(modelId: string): boolean {
|
||||
const model = this.routerModels.find((m) => m.id === modelId);
|
||||
|
||||
return (
|
||||
model?.status.value === ServerModelStatus.LOADED ||
|
||||
model?.status.value === ServerModelStatus.SLEEPING ||
|
||||
false
|
||||
model?.status.value === ServerModelStatus.SLEEPING
|
||||
);
|
||||
}
|
||||
|
||||
@@ -248,6 +202,7 @@ class ModelsStore {
|
||||
|
||||
getModelStatus(modelId: string): ServerModelStatus | null {
|
||||
const model = this.routerModels.find((m) => m.id === modelId);
|
||||
|
||||
return model?.status.value ?? null;
|
||||
}
|
||||
|
||||
@@ -257,6 +212,7 @@ class ModelsStore {
|
||||
|
||||
isModelInUse(modelId: string): boolean {
|
||||
const usage = this.modelUsage.get(modelId);
|
||||
|
||||
return usage !== undefined && usage.size > 0;
|
||||
}
|
||||
|
||||
@@ -269,8 +225,8 @@ class ModelsStore {
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetch list of models from server and detect server role
|
||||
* Also fetches modalities for MODEL mode (single model)
|
||||
* Fetch list of models from server and detect server role.
|
||||
* Also fetches modalities for MODEL mode (single model).
|
||||
*/
|
||||
async fetch(force = false): Promise<void> {
|
||||
if (this.inflightFetch) return this.inflightFetch;
|
||||
@@ -293,69 +249,87 @@ class ModelsStore {
|
||||
await serverStore.fetch();
|
||||
}
|
||||
|
||||
const response = await ModelsService.list();
|
||||
const router = isRouterMode();
|
||||
|
||||
const models: ModelOption[] = response.data.map((item: ApiModelDataEntry, index: number) => {
|
||||
const details = response.models?.[index];
|
||||
const rawCapabilities = Array.isArray(details?.capabilities) ? details?.capabilities : [];
|
||||
const displayNameSource =
|
||||
details?.name && details.name.trim().length > 0 ? details.name : item.id;
|
||||
const displayName = this.toDisplayName(displayNameSource);
|
||||
const modelId = details?.model || item.id;
|
||||
if (router) {
|
||||
const response = await ModelsService.listRouter();
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
name: displayName,
|
||||
model: modelId,
|
||||
description: details?.description,
|
||||
capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
|
||||
details: details?.details,
|
||||
meta: item.meta ?? null,
|
||||
parsedId: ModelsService.parseModelId(modelId),
|
||||
aliases: item.aliases ?? [],
|
||||
tags: item.tags ?? []
|
||||
} satisfies ModelOption;
|
||||
});
|
||||
this.routerModels = response.data;
|
||||
this.models = this.buildModelOptions(response);
|
||||
|
||||
this.models = models;
|
||||
await this.fetchModalitiesForLoadedModels();
|
||||
|
||||
// WORKAROUND: In MODEL mode, /props returns modalities for the single model,
|
||||
// but /v1/models doesn't include modalities. We bridge this gap here.
|
||||
const serverProps = serverStore.props;
|
||||
if (serverStore.isModelMode && this.models.length > 0 && serverProps?.modalities) {
|
||||
const modalities: ModelModalities = {
|
||||
vision: serverProps.modalities.vision ?? false,
|
||||
audio: serverProps.modalities.audio ?? false,
|
||||
video: serverProps.modalities.video ?? false
|
||||
};
|
||||
this.modelPropsCache.set(this.models[0].model, serverProps);
|
||||
this.models = this.models.map((model, index) =>
|
||||
index === 0 ? { ...model, modalities } : model
|
||||
);
|
||||
const visible = this.getVisibleModels();
|
||||
|
||||
if (visible.length === 1 && this.isModelLoaded(visible[0].model)) {
|
||||
this.selectModelById(visible[0].id);
|
||||
}
|
||||
} else {
|
||||
this.models = await this.fetchModelModeInternal();
|
||||
}
|
||||
} catch (error) {
|
||||
this.models = [];
|
||||
this.error = error instanceof Error ? error.message : 'Failed to load models';
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch models in MODEL mode (single model, standard OpenAI-compatible). */
|
||||
private async fetchModelModeInternal(): Promise<ModelOption[]> {
|
||||
const response = await ModelsService.list();
|
||||
|
||||
return this.buildModelOptions(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch router models with full metadata (ROUTER mode only)
|
||||
* This fetches the /models endpoint which returns status info for each model
|
||||
* Build ModelOption[] from an API response.
|
||||
* Both MODEL and ROUTER modes share the same mapping logic;
|
||||
* they differ only in which endpoint is called.
|
||||
*/
|
||||
private buildModelOptions(
|
||||
response: ApiModelListResponse | ApiRouterModelsListResponse
|
||||
): ModelOption[] {
|
||||
return response.data.map((item: ApiModelDataEntry, index: number) => {
|
||||
const details = response.models?.[index];
|
||||
const rawCapabilities = Array.isArray(details?.capabilities) ? details?.capabilities : [];
|
||||
const displayNameSource =
|
||||
details?.name && details.name.trim().length > 0 ? details.name : item.id;
|
||||
const modelId = details?.model || item.id;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
name: this.toDisplayName(displayNameSource),
|
||||
model: modelId,
|
||||
description: details?.description,
|
||||
capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
|
||||
details: details?.details,
|
||||
meta: item.meta ?? null,
|
||||
parsedId: ModelsService.parseModelId(modelId),
|
||||
aliases: item.aliases ?? [],
|
||||
tags: item.tags ?? []
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch router models with full metadata (ROUTER mode only).
|
||||
* No-op in router mode — fetch() already calls listRouter() internally.
|
||||
* Kept for API compatibility (e.g. handleOpenChange dropdown open handler).
|
||||
*/
|
||||
async fetchRouterModels(): Promise<void> {
|
||||
if (!isRouterMode()) return;
|
||||
|
||||
try {
|
||||
const response = await ModelsService.listRouter();
|
||||
this.routerModels = response.data;
|
||||
await this.fetchModalitiesForLoadedModels();
|
||||
|
||||
const o = this.models.filter((option) => this.getModelProps(option.model)?.ui !== false);
|
||||
|
||||
if (o.length === 1 && this.isModelLoaded(o[0].model)) {
|
||||
this.selectModelById(o[0].id);
|
||||
const visible = this.getVisibleModels();
|
||||
if (visible.length === 1 && this.isModelLoaded(visible[0].model)) {
|
||||
this.selectModelById(visible[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch router models:', error);
|
||||
@@ -364,10 +338,10 @@ class ModelsStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch props for a specific model from /props endpoint
|
||||
* Uses caching to avoid redundant requests
|
||||
* Fetch props for a specific model from /props endpoint.
|
||||
* Uses caching to avoid redundant requests.
|
||||
*
|
||||
* In ROUTER mode, this will only fetch props if the model is loaded,
|
||||
* In ROUTER mode, this only fetches props if the model is loaded,
|
||||
* since unloaded models return 400 from /props endpoint.
|
||||
*
|
||||
* @param modelId - Model identifier to fetch props for
|
||||
@@ -397,10 +371,7 @@ class ModelsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch modalities for all loaded models from /props endpoint
|
||||
* This updates the modalities field in models array
|
||||
*/
|
||||
/** Fetch modalities for all loaded models from /props endpoint. */
|
||||
async fetchModalitiesForLoadedModels(): Promise<void> {
|
||||
const loadedModelIds = this.loadedModelIds;
|
||||
if (loadedModelIds.length === 0) return;
|
||||
@@ -410,7 +381,6 @@ class ModelsStore {
|
||||
try {
|
||||
const results = await Promise.all(propsPromises);
|
||||
|
||||
// Update models with modalities
|
||||
this.models = this.models.map((model) => {
|
||||
const modelIndex = loadedModelIds.indexOf(model.model);
|
||||
if (modelIndex === -1) return model;
|
||||
@@ -418,13 +388,7 @@ class ModelsStore {
|
||||
const props = results[modelIndex];
|
||||
if (!props?.modalities) return model;
|
||||
|
||||
const modalities: ModelModalities = {
|
||||
vision: props.modalities.vision ?? false,
|
||||
audio: props.modalities.audio ?? false,
|
||||
video: props.modalities.video ?? false
|
||||
};
|
||||
|
||||
return { ...model, modalities };
|
||||
return { ...model, modalities: this.buildModalities(props.modalities) };
|
||||
});
|
||||
|
||||
this.propsCacheVersion++;
|
||||
@@ -433,17 +397,38 @@ class ModelsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update modalities for a specific model.
|
||||
* Called when a model is loaded or when we need fresh modality data.
|
||||
*/
|
||||
async updateModelModalities(modelId: string): Promise<void> {
|
||||
const props = await this.fetchModelProps(modelId);
|
||||
if (!props?.modalities) return;
|
||||
|
||||
this.models = this.models.map((model) =>
|
||||
model.model === modelId
|
||||
? { ...model, modalities: this.buildModalities(props.modalities!) }
|
||||
: model
|
||||
);
|
||||
|
||||
this.propsCacheVersion++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter to models visible in the UI (ui !== false).
|
||||
*/
|
||||
private getVisibleModels(): ModelOption[] {
|
||||
return this.models.filter((option) => this.getModelProps(option.model)?.ui !== false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the model name from the last assistant message in the active conversation.
|
||||
* Iterates backward through messages to find the most recent message with a model.
|
||||
* Used by both the chat page and settings page to maintain model consistency.
|
||||
* @returns The model name or null if not found
|
||||
*/
|
||||
getModelFromLastAssistantResponse(): string | null {
|
||||
const messages = conversationsStore.activeMessages;
|
||||
if (!messages || messages.length === 0) return null;
|
||||
|
||||
// Iterate backward to find the last message with a model
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].model) {
|
||||
return messages[i].model;
|
||||
@@ -456,22 +441,13 @@ class ModelsStore {
|
||||
/**
|
||||
* Auto-selects the model from the last assistant response if available and loaded.
|
||||
* Returns true if a model was selected, false otherwise.
|
||||
* This is used by the chat page to maintain model consistency across page navigation.
|
||||
*/
|
||||
async selectModelFromLastAssistantResponse(): Promise<boolean> {
|
||||
const lastModel = this.getModelFromLastAssistantResponse();
|
||||
if (!lastModel) return false;
|
||||
|
||||
// Skip if already selected
|
||||
if (this.selectedModelName === lastModel) return false;
|
||||
if (!lastModel || this.selectedModelName === lastModel) return false;
|
||||
|
||||
const matchingModel = this.models.find((option) => option.model === lastModel);
|
||||
if (!matchingModel) return false;
|
||||
|
||||
if (!this.isModelLoaded(lastModel)) {
|
||||
console.log('[modelsStore] last assistant model not loaded:', lastModel);
|
||||
return false;
|
||||
}
|
||||
if (!matchingModel || !this.isModelLoaded(lastModel)) return false;
|
||||
|
||||
try {
|
||||
await this.selectModelById(matchingModel.id);
|
||||
@@ -484,22 +460,17 @@ class ModelsStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-selects the first available model if none is selected, and fetches its props.
|
||||
* Auto-selects the first available model if none is selected.
|
||||
* Prioritizes:
|
||||
* 1. Model from active conversation's last assistant response (if loaded)
|
||||
* 2. Model from active conversation's last assistant response (if not loaded)
|
||||
* 3. First loaded model (not from active conversation)
|
||||
* 4. First available model
|
||||
* This is used to ensure default values are populated in settings pages.
|
||||
*/
|
||||
async ensureFirstModelSelected(): Promise<void> {
|
||||
if (this.selectedModelName) return;
|
||||
|
||||
// Filter models that are visible in the UI
|
||||
const availableModels = this.models.filter(
|
||||
(option) => this.getModelProps(option.model)?.ui !== false
|
||||
);
|
||||
|
||||
const availableModels = this.getVisibleModels();
|
||||
if (availableModels.length === 0) return;
|
||||
|
||||
// Try to select model from last assistant response first
|
||||
@@ -515,7 +486,7 @@ class ModelsStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find a loaded model first
|
||||
// Try a loaded model first
|
||||
const loadedModel = availableModels.find((m) => this.isModelLoaded(m.model));
|
||||
if (loadedModel) {
|
||||
await this.selectModelById(loadedModel.id);
|
||||
@@ -524,34 +495,7 @@ class ModelsStore {
|
||||
}
|
||||
|
||||
// Fall back to the first available model
|
||||
const firstModel = availableModels[0];
|
||||
await this.selectModelById(firstModel.id);
|
||||
// Don't fetch props for unloaded models (will fail in ROUTER mode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update modalities for a specific model
|
||||
* Called when a model is loaded or when we need fresh modality data
|
||||
*/
|
||||
async updateModelModalities(modelId: string): Promise<void> {
|
||||
try {
|
||||
const props = await this.fetchModelProps(modelId);
|
||||
if (!props?.modalities) return;
|
||||
|
||||
const modalities: ModelModalities = {
|
||||
vision: props.modalities.vision ?? false,
|
||||
audio: props.modalities.audio ?? false,
|
||||
video: props.modalities.video ?? false
|
||||
};
|
||||
|
||||
this.models = this.models.map((model) =>
|
||||
model.model === modelId ? { ...model, modalities } : model
|
||||
);
|
||||
|
||||
this.propsCacheVersion++;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to update modalities for model ${modelId}:`, error);
|
||||
}
|
||||
await this.selectModelById(availableModels[0].id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -562,9 +506,6 @@ class ModelsStore {
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Select a model for new conversations
|
||||
*/
|
||||
async selectModelById(modelId: string): Promise<void> {
|
||||
if (!modelId || this.updating) return;
|
||||
if (this.selectedModelId === modelId) return;
|
||||
@@ -584,8 +525,7 @@ class ModelsStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a model by its model name (used for syncing with conversation model)
|
||||
* @param modelName - Model name to select (e.g., "ggml-org/GLM-4.7-Flash-GGUF")
|
||||
* Select a model by its model name (used for syncing with conversation model).
|
||||
*/
|
||||
selectModelByName(modelName: string): void {
|
||||
const option = this.models.find((model) => model.model === modelName);
|
||||
@@ -615,7 +555,7 @@ class ModelsStore {
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Loading/Unloading Models
|
||||
* Loading / Unloading Models
|
||||
*
|
||||
*
|
||||
*/
|
||||
@@ -623,27 +563,18 @@ class ModelsStore {
|
||||
/**
|
||||
* WORKAROUND: Polling for model status after load/unload operations.
|
||||
*
|
||||
* Currently, the `/models/load` and `/models/unload` endpoints return success
|
||||
* before the operation actually completes on the server. This means an immediate
|
||||
* request to `/models` returns stale status (e.g., "loading" after load request,
|
||||
* "loaded" after unload request).
|
||||
* Currently, `/models/load` and `/models/unload` return success before
|
||||
* the operation actually completes on the server.
|
||||
*
|
||||
* TODO: Remove this polling once llama-server properly waits for the operation
|
||||
* to complete before returning success from `/load` and `/unload` endpoints.
|
||||
* At that point, a single `fetchRouterModels()` call after the operation will
|
||||
* be sufficient to get the correct status.
|
||||
* TODO: Remove polling once llama-server properly waits for the operation
|
||||
* to complete before returning success.
|
||||
*/
|
||||
|
||||
/** Polling interval in ms for checking model status */
|
||||
private static readonly STATUS_POLL_INTERVAL = 500;
|
||||
|
||||
/**
|
||||
* Poll for expected model status after load/unload operation.
|
||||
* Keeps polling indefinitely until the model reaches the expected status or fails.
|
||||
*
|
||||
* @param modelId - Model identifier to check
|
||||
* @param expectedStatus - Expected status to wait for
|
||||
* @throws Error if model reaches FAILED status
|
||||
* Keeps polling until the model reaches the expected status or fails.
|
||||
*/
|
||||
private async pollForModelStatus(
|
||||
modelId: string,
|
||||
@@ -654,9 +585,7 @@ class ModelsStore {
|
||||
await this.fetchRouterModels();
|
||||
|
||||
const currentStatus = this.getModelStatus(modelId);
|
||||
if (currentStatus === expectedStatus) {
|
||||
return;
|
||||
}
|
||||
if (currentStatus === expectedStatus) return;
|
||||
|
||||
if (currentStatus === ServerModelStatus.FAILED) {
|
||||
throw new Error(
|
||||
@@ -677,15 +606,8 @@ class ModelsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a model (ROUTER mode)
|
||||
* @param modelId - Model identifier to load
|
||||
*/
|
||||
async loadModel(modelId: string): Promise<void> {
|
||||
if (this.isModelLoaded(modelId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isModelLoaded(modelId)) return;
|
||||
if (this.modelLoadingStates.get(modelId)) return;
|
||||
|
||||
this.modelLoadingStates.set(modelId, true);
|
||||
@@ -694,7 +616,6 @@ class ModelsStore {
|
||||
try {
|
||||
await ModelsService.load(modelId);
|
||||
await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
|
||||
|
||||
await this.updateModelModalities(modelId);
|
||||
toast.success(`Model loaded: ${this.toDisplayName(modelId)}`);
|
||||
} catch (error) {
|
||||
@@ -706,15 +627,8 @@ class ModelsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload a model (ROUTER mode)
|
||||
* @param modelId - Model identifier to unload
|
||||
*/
|
||||
async unloadModel(modelId: string): Promise<void> {
|
||||
if (!this.isModelLoaded(modelId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isModelLoaded(modelId)) return;
|
||||
if (this.modelLoadingStates.get(modelId)) return;
|
||||
|
||||
this.modelLoadingStates.set(modelId, true);
|
||||
@@ -722,7 +636,6 @@ class ModelsStore {
|
||||
|
||||
try {
|
||||
await ModelsService.unload(modelId);
|
||||
|
||||
await this.pollForModelStatus(modelId, ServerModelStatus.UNLOADED);
|
||||
toast.info(`Model unloaded: ${this.toDisplayName(modelId)}`);
|
||||
} catch (error) {
|
||||
@@ -734,15 +647,8 @@ class ModelsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a model is loaded before use
|
||||
* @param modelId - Model identifier to ensure is loaded
|
||||
*/
|
||||
async ensureModelLoaded(modelId: string): Promise<void> {
|
||||
if (this.isModelLoaded(modelId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isModelLoaded(modelId)) return;
|
||||
await this.loadModel(modelId);
|
||||
}
|
||||
|
||||
@@ -779,11 +685,9 @@ class ModelsStore {
|
||||
private loadFavoritesFromStorage(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(FAVORITE_MODELS_LOCALSTORAGE_KEY);
|
||||
|
||||
return raw ? new Set(JSON.parse(raw) as string[]) : new Set();
|
||||
} catch {
|
||||
toast.error('Failed to load favorite models from local storage');
|
||||
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
@@ -799,10 +703,19 @@ class ModelsStore {
|
||||
private toDisplayName(id: string): string {
|
||||
const segments = id.split(/\\|\//);
|
||||
const candidate = segments.pop();
|
||||
|
||||
return candidate && candidate.trim().length > 0 ? candidate : id;
|
||||
}
|
||||
|
||||
private buildModalities(
|
||||
modalities: NonNullable<ApiLlamaCppServerProps['modalities']>
|
||||
): ModelModalities {
|
||||
return {
|
||||
vision: modalities.vision ?? false,
|
||||
audio: modalities.audio ?? false,
|
||||
video: modalities.video ?? false
|
||||
};
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.models = [];
|
||||
this.routerModels = [];
|
||||
|
||||
Vendored
+1
@@ -203,6 +203,7 @@ export interface ApiLlamaCppServerProps {
|
||||
/** @deprecated Use {@link ui_settings} instead */
|
||||
webui_settings?: Record<string, string | number | boolean>;
|
||||
ui_settings?: Record<string, string | number | boolean>;
|
||||
cors_proxy_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ApiChatCompletionRequest {
|
||||
|
||||
Vendored
+2
-2
@@ -1,5 +1,5 @@
|
||||
import type { MCPConnectionPhase, MCPLogLevel, HealthCheckStatus } from '$lib/enums/mcp';
|
||||
import type { ToolSource } from '$lib/enums/tools';
|
||||
import type { MCPConnectionPhase, MCPLogLevel, HealthCheckStatus } from '$lib/enums/mcp.enums';
|
||||
import type { ToolSource } from '$lib/enums/tools.enums';
|
||||
import type {
|
||||
Client,
|
||||
ClientCapabilities as SDKClientCapabilities,
|
||||
|
||||
@@ -12,17 +12,21 @@ export async function validateApiKey(fetch: typeof globalThis.fetch): Promise<vo
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = config().apiKey;
|
||||
|
||||
// No API key configured — server doesn't require auth, skip the request entirely.
|
||||
// The /props endpoint is only protected when the server has API keys configured,
|
||||
// and in that case the client always has one set (from settings).
|
||||
if (!apiKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKey = config().apiKey;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
};
|
||||
|
||||
if (apiKey) {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${base}/props`, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -333,7 +333,8 @@ async function migrateConversation(convId: string): Promise<number> {
|
||||
export async function runLegacyMigration(): Promise<void> {
|
||||
if (!isMigrationNeeded()) return;
|
||||
|
||||
console.log('[Migration] Starting legacy message format migration...');
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
||||
console.log('[Migration] Starting legacy message format migration...');
|
||||
|
||||
try {
|
||||
const conversations = await DatabaseService.getAllConversations();
|
||||
@@ -344,12 +345,14 @@ export async function runLegacyMigration(): Promise<void> {
|
||||
totalMigrated += count;
|
||||
}
|
||||
|
||||
if (totalMigrated > 0) {
|
||||
console.log(
|
||||
`[Migration] Migrated ${totalMigrated} messages across ${conversations.length} conversations`
|
||||
);
|
||||
} else {
|
||||
console.log('[Migration] No legacy messages found, marking as done');
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||
if (totalMigrated > 0) {
|
||||
console.log(
|
||||
`[Migration] Migrated ${totalMigrated} messages across ${conversations.length} conversations`
|
||||
);
|
||||
} else {
|
||||
console.log('[Migration] No legacy messages found, marking as done');
|
||||
}
|
||||
}
|
||||
|
||||
markMigrationDone();
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { conversationsStore, isConversationsInitialized } from '$lib/stores/conversations.svelte';
|
||||
import { modelsStore, modelOptions } from '$lib/stores/models.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { replaceState } from '$app/navigation';
|
||||
@@ -72,23 +71,13 @@
|
||||
conversationsStore.clearActiveConversation();
|
||||
chatStore.clearUIState();
|
||||
|
||||
if (
|
||||
isRouterMode() &&
|
||||
modelsStore.selectedModelName &&
|
||||
!modelsStore.isModelLoaded(modelsStore.selectedModelName)
|
||||
) {
|
||||
modelsStore.clearSelection();
|
||||
await modelsStore.fetch();
|
||||
|
||||
const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
|
||||
if (first) {
|
||||
await modelsStore.selectModelById(first.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle URL params only if we have ?q= or ?model= or ?new_chat=true
|
||||
if (qParam !== null || modelParam !== null || newChatParam === 'true') {
|
||||
await handleUrlParams();
|
||||
}
|
||||
|
||||
await modelsStore.ensureFirstModelSelected();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
import { untrack } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import {
|
||||
DesktopIconStrip,
|
||||
DialogConversationTitleUpdate,
|
||||
SidebarNavigation
|
||||
} from '$lib/components/app';
|
||||
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
@@ -30,26 +32,29 @@
|
||||
import { conversations } from '$lib/stores/conversations.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let alwaysShowSidebarOnDesktop = $derived(config().alwaysShowSidebarOnDesktop);
|
||||
let isMobile = new IsMobile();
|
||||
let isDesktop = $derived(!isMobile.current);
|
||||
let sidebarOpen = $state(false);
|
||||
let mounted = $state(false);
|
||||
let innerHeight = $state<number | undefined>();
|
||||
|
||||
let chatSidebar:
|
||||
| { activateSearchMode?: () => void; editActiveConversation?: () => void }
|
||||
| {
|
||||
activateSearchMode?: () => void;
|
||||
editActiveConversation?: () => void;
|
||||
}
|
||||
| undefined = $state();
|
||||
|
||||
let titleUpdateDialogOpen = $state(false);
|
||||
let titleUpdateCurrentTitle = $state('');
|
||||
let titleUpdateNewTitle = $state('');
|
||||
let titleUpdateResolve: ((value: boolean) => void) | null = null;
|
||||
|
||||
const panelNav = useSettingsNavigation();
|
||||
|
||||
function navigateToConversation(direction: -1 | 1) {
|
||||
const allConvs = conversations();
|
||||
|
||||
if (allConvs.length === 0) return;
|
||||
|
||||
const currentId = page.params.id;
|
||||
@@ -61,6 +66,7 @@
|
||||
}
|
||||
|
||||
const idx = allConvs.findIndex((c) => c.id === currentId);
|
||||
|
||||
if (idx === -1) return;
|
||||
|
||||
const targetIdx = idx + direction;
|
||||
@@ -75,38 +81,41 @@
|
||||
// Global keyboard shortcuts
|
||||
const { handleKeydown } = useKeyboardShortcuts({
|
||||
editActiveConversation: () => chatSidebar?.editActiveConversation?.(),
|
||||
|
||||
navigateToPrevConversation: () => navigateToConversation(-1),
|
||||
|
||||
navigateToNextConversation: () => navigateToConversation(1)
|
||||
});
|
||||
|
||||
function checkApiKey() {
|
||||
const apiKey = config().apiKey;
|
||||
|
||||
if (
|
||||
(page.route.id === '/(chat)' || page.route.id === '/(chat)/chat/[id]') &&
|
||||
page.status !== 401 &&
|
||||
page.status !== 403
|
||||
) {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
if (apiKey && apiKey.trim() !== '') {
|
||||
headers.Authorization = `Bearer ${apiKey.trim()}`;
|
||||
}
|
||||
|
||||
fetch(`${base}/props`, { headers })
|
||||
.then((response) => {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Error checking API key:', e);
|
||||
});
|
||||
// No API key configured — server doesn't require auth, no need to validate.
|
||||
// This mirrors the early return in validateApiKey() to avoid redundant /props requests.
|
||||
if (!apiKey || apiKey.trim() === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
if (
|
||||
(page.route.id === '/(chat)' || page.route.id === '/(chat)/chat/[id]') &&
|
||||
page.status !== 401 &&
|
||||
page.status !== 403
|
||||
) {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey.trim()}`
|
||||
};
|
||||
|
||||
fetch(`${base}/props`, { headers })
|
||||
.then((response) => {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Error checking API key:', e);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleTitleUpdateCancel() {
|
||||
@@ -134,6 +143,7 @@
|
||||
$effect(() => {
|
||||
if (alwaysShowSidebarOnDesktop && isDesktop) {
|
||||
sidebarOpen = true;
|
||||
|
||||
return;
|
||||
}
|
||||
});
|
||||
@@ -170,6 +180,7 @@
|
||||
// Only fetch router models once when we have models loaded and in router mode
|
||||
if (isRouter && modelsCount > 0 && !routerModelsFetched) {
|
||||
routerModelsFetched = true;
|
||||
|
||||
untrack(() => {
|
||||
modelsStore.fetchRouterModels();
|
||||
});
|
||||
@@ -218,7 +229,6 @@
|
||||
|
||||
<Tooltip.Provider delayDuration={TOOLTIP_DELAY_DURATION}>
|
||||
<ModeWatcher />
|
||||
|
||||
<Toaster richColors />
|
||||
|
||||
<DialogConversationTitleUpdate
|
||||
@@ -230,10 +240,10 @@
|
||||
/>
|
||||
|
||||
<Sidebar.Provider bind:open={sidebarOpen}>
|
||||
<div class="flex h-screen w-full" style:height="{innerHeight}px">
|
||||
<Sidebar.Root variant="floating" class="h-full">
|
||||
<SidebarNavigation bind:this={chatSidebar} />
|
||||
</Sidebar.Root>
|
||||
<div class="flex h-screen w-full">
|
||||
<Sidebar.Root variant="floating" class="h-full"
|
||||
><SidebarNavigation bind:this={chatSidebar} /></Sidebar.Root
|
||||
>
|
||||
|
||||
{#if !(alwaysShowSidebarOnDesktop && isDesktop) && !(panelNav.isSettingsRoute && !isDesktop)}
|
||||
{#if mounted}
|
||||
@@ -261,9 +271,9 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Sidebar.Inset class="flex flex-1 flex-col overflow-hidden">
|
||||
{@render children?.()}
|
||||
</Sidebar.Inset>
|
||||
<Sidebar.Inset class="flex flex-1 flex-col overflow-hidden"
|
||||
>{@render children?.()}</Sidebar.Inset
|
||||
>
|
||||
</div>
|
||||
</Sidebar.Provider>
|
||||
</Tooltip.Provider>
|
||||
|
||||
@@ -8,6 +8,9 @@ $use-ttf: false;
|
||||
$font-folder: 'katex-fonts';
|
||||
|
||||
// Import KaTeX SCSS with overridden variables
|
||||
// Note: @import is deprecated but required because KaTeX uses @import internally
|
||||
// The deprecation warnings are from KaTeX's code and cannot be avoided
|
||||
@import 'katex/src/styles/katex.scss';
|
||||
@use 'katex/src/styles/katex.scss' with (
|
||||
$use-woff2: true,
|
||||
$use-woff: false,
|
||||
$use-ttf: false,
|
||||
$font-folder: 'katex-fonts'
|
||||
);
|
||||
|
||||
@@ -4,8 +4,9 @@ import TestWrapper from './components/TestWrapper.svelte';
|
||||
|
||||
describe('/+page.svelte', () => {
|
||||
it('should render page without throwing', async () => {
|
||||
// Basic smoke test - page should render without throwing errors
|
||||
// API calls will fail in test environment but component should still mount
|
||||
expect(() => render(TestWrapper)).not.toThrow();
|
||||
// Basic smoke test - page should render without throwing errors.
|
||||
// API calls are mocked in vitest-setup-client.ts.
|
||||
await render(TestWrapper);
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,18 +23,6 @@ export default defineConfig({
|
||||
minify: true
|
||||
},
|
||||
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
additionalData: `
|
||||
$use-woff2: true;
|
||||
$use-woff: false;
|
||||
$use-ttf: false;
|
||||
`
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
plugins: [tailwindcss(), sveltekit(), devtoolsJson(), llamaCppBuildPlugin()],
|
||||
|
||||
test: {
|
||||
|
||||
@@ -1,2 +1,80 @@
|
||||
/// <reference types="@vitest/browser/matchers" />
|
||||
/// <reference types="@vitest/browser/providers/playwright" />
|
||||
|
||||
import { beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock fetch for API calls during client tests.
|
||||
// In test environment there is no backend server, so we intercept
|
||||
// the specific endpoints the app uses and return valid mock data.
|
||||
beforeEach(() => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
|
||||
// Mock server props endpoint
|
||||
if (url.includes('/server')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
mode: 'router',
|
||||
version: 'test',
|
||||
git_commit: 'test',
|
||||
git_branch: 'test'
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Mock models list endpoint
|
||||
if (/\/v1\/models|\/models\b/.test(url)) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
object: 'list',
|
||||
data: [
|
||||
{
|
||||
id: 'test-model.gguf',
|
||||
object: 'model',
|
||||
owned_by: 'llamacpp',
|
||||
created: 0,
|
||||
in_cache: false,
|
||||
path: 'models/test-model.gguf',
|
||||
status: { value: 'unloaded' },
|
||||
meta: {}
|
||||
}
|
||||
],
|
||||
models: [
|
||||
{
|
||||
model: 'test-model.gguf',
|
||||
name: 'Test Model',
|
||||
details: {}
|
||||
}
|
||||
]
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Mock /props endpoint (used for modalities)
|
||||
if (url.includes('/props')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
default_generation_settings: { n_ctx: 2048 }
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Mock /tools endpoint (used for built-in tools list)
|
||||
if (url.includes('/tools')) {
|
||||
return new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Default: use real fetch
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="@vitest/browser-playwright" />
|
||||
Reference in New Issue
Block a user