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:
Concedo
2026-05-20 18:48:34 +08:00
77 changed files with 1291 additions and 1048 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ int main(int argc, char ** argv) {
if (!params.fit_params_print) {
const common_params_fit_status status = common_fit_params(params.model.path.c_str(), &mparams, &cparams,
params.tensor_split, params.tensor_buft_overrides.data(), params.fit_params_target.data(), params.fit_params_min_ctx,
params.verbosity >= 4 ? GGML_LOG_LEVEL_DEBUG : GGML_LOG_LEVEL_ERROR);
params.verbosity >= LOG_LEVEL_DEBUG ? GGML_LOG_LEVEL_DEBUG : GGML_LOG_LEVEL_ERROR);
if (status != COMMON_PARAMS_FIT_STATUS_SUCCESS) {
LOG_ERR("%s: failed to fit CLI arguments to free memory, exiting...\n", __func__);
exit(1);
+18 -11
View File
@@ -467,20 +467,26 @@ struct server_slot {
const double n_gen_second = 1e3 / t_token_generation * n_decoded;
SLT_INF(*this,
"\n"
"prompt eval time = %10.2f ms / %5d tokens (%8.2f ms per token, %8.2f tokens per second)\n"
" eval time = %10.2f ms / %5d tokens (%8.2f ms per token, %8.2f tokens per second)\n"
"prompt eval time = %10.2f ms / %5d tokens (%8.2f ms per token, %8.2f tokens per second)\n",
t_prompt_processing, n_prompt_tokens_processed, t_prompt, n_prompt_second);
SLT_INF(*this,
" eval time = %10.2f ms / %5d tokens (%8.2f ms per token, %8.2f tokens per second)\n",
t_token_generation, n_decoded, t_gen, n_gen_second);
SLT_INF(*this,
" total time = %10.2f ms / %5d tokens\n",
t_prompt_processing, n_prompt_tokens_processed, t_prompt, n_prompt_second,
t_token_generation, n_decoded, t_gen, n_gen_second,
t_prompt_processing + t_token_generation, n_prompt_tokens_processed + n_decoded);
SLT_INF(*this,
" graphs reused = %10d\n",
llama_perf_context(ctx_tgt).n_reused);
if (n_draft_total > 0) {
const float draft_ratio = (float) n_draft_accepted / n_draft_total;
SLT_CNT(*this,
"draft acceptance rate = %0.5f (%5d accepted / %5d generated)\n",
draft_ratio, n_draft_accepted, n_draft_total
);
SLT_INF(*this,
"draft acceptance = %0.5f (%5d accepted / %5d generated)\n",
draft_ratio, n_draft_accepted, n_draft_total);
}
common_speculative_print_stats(spec);
@@ -2583,9 +2589,9 @@ private:
llama_pos pos_next = slot.prompt.tokens.pos_next(n_past);
// the largest pos_min required for a checkpoint to be useful
const auto pos_min_thold = std::max(0, pos_next - n_swa);
const auto pos_min_thold = std::max(0, pos_next - n_swa - 1);
if (n_past > 0 && n_past < slot.prompt.n_tokens()) {
if (n_past > 0 && n_past <= slot.prompt.n_tokens()) {
const auto pos_min = llama_memory_seq_pos_min(llama_get_memory(ctx_tgt), slot.id);
if (pos_min == -1) {
SLT_ERR(slot, "n_past = %d, slot.prompt.tokens.size() = %d, seq_id = %d, pos_min = %d\n", n_past, (int) slot.prompt.tokens.size(), slot.id, pos_min);
@@ -3885,6 +3891,7 @@ void server_routes::init_routes() {
{ "eos_token", meta->eos_token_str },
{ "build_info", meta->build_info },
{ "is_sleeping", queue_tasks.is_sleeping() },
{ "cors_proxy_enabled", params.ui_mcp_proxy || params.webui_mcp_proxy },
};
if (params.use_jinja) {
if (!tmpl_tools.empty()) {
+1
View File
@@ -1165,6 +1165,7 @@ void server_models_routes::init_routes() {
// Deprecated: use ui_settings instead (kept for backward compat)
{"webui_settings", webui_settings},
{"build_info", std::string(llama_build_info())},
{"cors_proxy_enabled", params.ui_mcp_proxy || params.webui_mcp_proxy},
});
return res;
}
+2
View File
@@ -0,0 +1,2 @@
VITE_PUBLIC_APP_NAME='llama-ui'
# VITE_DEBUG='true'
+1 -1
View File
@@ -25,4 +25,4 @@ vite.config.ts.timestamp-*
*storybook.log
storybook-static
*.code-workspace
*.code-workspace
+2 -3
View File
@@ -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'
}
+30 -30
View File
@@ -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": {
+3 -2
View File
@@ -1,6 +1,7 @@
@import 'tailwindcss';
@source ".";
@source '.';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@@ -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}
@@ -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}
/>
@@ -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';
+1 -1
View File
@@ -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 -1
View File
@@ -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',
+11 -11
View File
@@ -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
};
}
+47 -12
View File
@@ -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:`,
+39 -20
View File
@@ -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');
}
};
+2 -23
View File
@@ -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;
}
/**
+138 -225
View File
@@ -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 = [];
+1
View File
@@ -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 {
+2 -2
View File
@@ -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,
+11 -7
View File
@@ -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) {
+10 -7
View File
@@ -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 -14
View File
@@ -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>
+45 -35
View File
@@ -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>
+6 -3
View File
@@ -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 -3
View File
@@ -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);
});
});
-12
View File
@@ -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: {
+78
View File
@@ -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);
}
);
});
+1
View File
@@ -0,0 +1 @@
/// <reference types="@vitest/browser-playwright" />