webui: Add Import/Export of Settings configuration + improve architecture (#22803)

* refactor: Settings keys as constant object keys

* chore: Run `npm audit fix`

* refactor: Settings Sections UI

* feat: Refactor Settings structure and implement import/export logic

* feat: Introduce ROUTES constant and RouterService

* refactor: Consolidate settings definitions into registry

* refactor: Update settings page routing structure

* chore: Migrate hardcoded URLs to use ROUTES and RouterService

* feat: Enhance model selection logic for settings and chat

* chore: Update webui static build

* refactor: Address PR review comments

* fix: Remove unneeded setting

* fix: Re-add missing settings

* fix: Add missing `/slots` proxy for webui dev mode

* chore: Dev-mode logs

* fix: Data binding

* fix: Steering for non-agentic flow
This commit is contained in:
Aleksander Grygier
2026-05-08 11:26:04 +02:00
committed by GitHub
parent a8fd165fec
commit 9b2925e1e0
55 changed files with 5133 additions and 4615 deletions
File diff suppressed because one or more lines are too long
+3538 -3507
View File
File diff suppressed because it is too large Load Diff
+18 -18
View File
@@ -2307,9 +2307,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.57.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.57.1.tgz",
"integrity": "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw==",
"version": "2.59.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.59.1.tgz",
"integrity": "sha512-d8OON70AphLdDesuTIl//M2O6fRTIicX8aYv8vhCiYEhTTI2OboKqey0Hu1A4VFhqwgqtq0vKDmPFGkw8kKmgw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3640,9 +3640,9 @@
}
},
"node_modules/bits-ui": {
"version": "2.18.0",
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.0.tgz",
"integrity": "sha512-GLOBZRVy3hxNHIQ2MpD/+5aK9KcBFZRhUJtZ1UDABXdlVR4K6zFpgt4T+Rwuhf2sQzlc6yK1q/DprHPjwT4Pjw==",
"version": "2.18.1",
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.1.tgz",
"integrity": "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4856,9 +4856,9 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.0.tgz",
"integrity": "sha512-XKhFohWaSBdVJNTi5TaHziqnPkv04I9UQV6q1Wy7Ui6GGQZVW12ojDFwqer14EvCXxjvPG0CyWXx7cAXpALB4Q==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
@@ -7943,9 +7943,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"dev": true,
"funding": [
{
@@ -10084,9 +10084,9 @@
"license": "MIT"
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
@@ -10302,9 +10302,9 @@
}
},
"node_modules/vite-plugin-devtools-json/node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
@@ -8,6 +8,7 @@
import { HealthCheckStatus } from '$lib/enums';
import type { MCPServerSettingsEntry } from '$lib/types';
import { goto } from '$app/navigation';
import { ROUTES } from '$lib/constants/routes';
interface Props {
onMcpSettingsClick?: () => void;
@@ -52,7 +53,7 @@
function handleMcpSettingsClick() {
onMcpSettingsClick?.();
goto(`${hasMcpServers ? '' : '?add'}#/settings/mcp`);
goto(`${hasMcpServers ? '' : '?add'}${ROUTES.MCP_SERVERS}`);
}
</script>
@@ -12,6 +12,8 @@
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
import { AttachmentMenuItemId } from '$lib/enums';
import { PencilRuler } from '@lucide/svelte';
import { ROUTES, SETTINGS_SECTION_SLUGS } from '$lib/constants/routes';
import { RouterService } from '$lib/services/router.service';
interface Props {
class?: string;
@@ -146,13 +148,16 @@
<div class="my-2 border-t"></div>
<a href="#/settings/mcp" class="flex items-center gap-3 px-3 py-2">
<a href={ROUTES.MCP_SERVERS} class="flex items-center gap-3 px-3 py-2">
<McpLogo class="inline h-4 w-4" />
<span class="text-sm">MCP Servers</span>
</a>
<a href="#/settings/chat/tools" class="flex items-center gap-3 px-3 py-2">
<a
href={RouterService.settings(SETTINGS_SECTION_SLUGS.TOOLS)}
class="flex items-center gap-3 px-3 py-2"
>
<PencilRuler class="inline h-4 w-4" />
<span class="text-sm">Tools</span>
@@ -13,6 +13,7 @@
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { getFileTypeCategory } from '$lib/utils';
import { goto } from '$app/navigation';
import { ROUTES } from '$lib/constants/routes';
interface Props {
canSend?: boolean;
@@ -100,7 +101,7 @@
{onSystemPromptClick}
{onMcpPromptClick}
{onMcpResourcesClick}
onMcpSettingsClick={() => goto('#/settings/mcp')}
onMcpSettingsClick={() => goto(ROUTES.MCP_SERVERS)}
/>
</div>
{/if}
@@ -17,6 +17,7 @@
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
import { deriveAgenticSections } from '$lib/utils';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
import { ROUTES } from '$lib/constants/routes';
interface Props {
class?: string;
@@ -182,7 +183,7 @@
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
if (conversationDeleted) {
goto(`#/`);
goto(ROUTES.START);
}
return;
@@ -205,7 +206,7 @@
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
if (conversationDeleted) {
goto(`#/`);
goto(ROUTES.START);
}
} else {
chatActions.delete(message);
@@ -271,7 +272,7 @@
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
isEditing = false;
if (conversationDeleted) {
goto(`#/`);
goto(ROUTES.START);
}
return;
}
@@ -85,10 +85,6 @@
editCtx.setUploadedFiles([...editCtx.editedUploadedFiles, ...processed]);
}
function handleUploadedFilesChange(files: ChatUploadedFile[]) {
editCtx.setUploadedFiles(files);
}
$effect(() => {
chatStore.setEditModeActive(handleFilesAdd);
@@ -104,7 +100,7 @@
<ChatForm
value={editCtx.editedContent}
attachments={editCtx.editedExtras}
uploadedFiles={editCtx.editedUploadedFiles}
bind:uploadedFiles={editCtx.editedUploadedFiles}
placeholder="Edit your message..."
showMcpPromptButton
showAddButton={editCtx.messageRole === MessageRole.USER}
@@ -112,7 +108,6 @@
onValueChange={editCtx.setContent}
onAttachmentRemove={handleAttachmentRemove}
onUploadedFileRemove={handleUploadedFileRemove}
onUploadedFilesChange={handleUploadedFilesChange}
onFilesAdd={handleFilesAdd}
onSubmit={handleSubmit}
/>
@@ -0,0 +1,83 @@
<script lang="ts">
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Checkbox } from '$lib/components/ui/checkbox';
import Label from '$lib/components/ui/label/label.svelte';
import { Shield, ShieldOff } from '@lucide/svelte';
let {
open = $bindable(),
includeSensitiveData = $bindable(false),
onCancel,
onConfirm
}: {
open: boolean;
includeSensitiveData: boolean;
onCancel: () => void;
onConfirm: () => void;
} = $props();
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
onCancel();
}
}
</script>
<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title class="flex items-center gap-2">
{#if includeSensitiveData}
<ShieldOff class="h-5 w-5 text-destructive" />
{:else}
<Shield class="h-5 w-5 text-destructive" />
{/if}
Export Settings
</AlertDialog.Title>
<AlertDialog.Description>
{#if includeSensitiveData}
<p class="text-amber-500">
Warning: This export will include sensitive data such as API keys and MCP server custom
headers (e.g., authorization tokens). Do not share this file with anyone you don't
trust.
</p>
{:else}
<p>
Sensitive data (API keys, MCP server custom headers) will not be included in the export
to protect your credentials.
</p>
{/if}
</AlertDialog.Description>
</AlertDialog.Header>
<div class="flex items-center gap-2 py-2">
<Checkbox id="include-sensitive" bind:checked={includeSensitiveData} />
<Label
for="include-sensitive"
class="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{#if includeSensitiveData}
<span class="text-destructive">Include sensitive data (not recommended)</span>
{:else}
<span>Include sensitive data</span>
{/if}
</Label>
</div>
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={onCancel}>Cancel</AlertDialog.Cancel>
<AlertDialog.Action
onclick={onConfirm}
class="bg-destructive text-white hover:bg-destructive/80"
>
{#if includeSensitiveData}
Export Anyway
{:else}
Export Without Sensitive Data
{/if}
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -18,6 +18,37 @@
*/
export { default as DialogMcpServerAddNew } from './DialogMcpServerAddNew.svelte';
/**
* **DialogExportSettings** - Settings export dialog with sensitive data warning
*
* Dialog for exporting settings with an option to include or exclude
* sensitive data (API keys, MCP server custom headers). Defaults to excluding
* sensitive data for security. User must explicitly opt-in to include them.
*
* **Architecture:**
* - Uses ShadCN AlertDialog
* - Checkbox to toggle sensitive data inclusion (defaults to false)
* - Warning icon and message when sensitive data is included
* - Destructive variant for the action button when exporting with sensitive data
*
* **Features:**
* - Secure default: sensitive data excluded by default
* - User must explicitly opt-in to include sensitive data
* - Visual warning (ShieldOff icon) when sensitive data is included
* - Different action text based on sensitive data state
*
* @example
* ```svelte
* <DialogExportSettings
* bind:open={showExportSettings}
* bind:includeSensitiveData
* onConfirm={handleSettingsExport}
* onCancel={() => showExportSettings = false}
* />
* ```
*/
export { default as DialogExportSettings } from './DialogExportSettings.svelte';
/**
*
* CONFIRMATION DIALOGS
@@ -11,6 +11,8 @@
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
import * as Sidebar from '$lib/components/ui/sidebar';
import Input from '$lib/components/ui/input/input.svelte';
import { ROUTES } from '$lib/constants/routes';
import { RouterService } from '$lib/services/router.service';
import {
conversationsStore,
conversations,
@@ -159,7 +161,7 @@
}
handleMobileSidebarItemClick();
await goto(`#/chat/${id}`);
await goto(RouterService.chat(id));
}
function handleStopGeneration(id: string) {
@@ -171,7 +173,7 @@
<ScrollArea class="h-full flex-1">
<Sidebar.Header class="gap-4 bg-sidebar/50 p-3 backdrop-blur-lg md:pt-4 md:pb-2">
<div class="flex items-center justify-between">
<a href="#/" onclick={handleMobileSidebarItemClick}>
<a href={ROUTES.START} onclick={handleMobileSidebarItemClick}>
<h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">{APP_NAME}</h1>
</a>
@@ -11,6 +11,7 @@
import { DropdownMenuActions } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import { FORK_TREE_DEPTH_PADDING } from '$lib/constants';
import { RouterService } from '$lib/services/router.service';
import { getAllLoadingChats } from '$lib/stores/chat.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { TruncatedText } from '$lib/components/app';
@@ -113,7 +114,7 @@
<Tooltip.Root>
<Tooltip.Trigger>
<a
href="#/chat/{conversation.forkedFromConversationId}"
href={RouterService.chat(conversation.forkedFromConversationId)}
class="flex shrink-0 items-center text-muted-foreground transition-colors hover:text-foreground"
>
<GitBranch class="h-3.5 w-3.5" />
@@ -7,6 +7,8 @@
import Label from '$lib/components/ui/label/label.svelte';
import { serverStore, serverLoading } from '$lib/stores/server.svelte';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { SETTINGS_KEYS } from '$lib/constants';
import { ROUTES } from '$lib/constants/routes';
import { fade, fly, scale } from 'svelte/transition';
import { KeyboardKey } from '$lib/enums';
@@ -63,7 +65,7 @@
try {
// Update the API key in settings first
settingsStore.updateConfig('apiKey', apiKeyInput.trim());
settingsStore.updateConfig(SETTINGS_KEYS.API_KEY, apiKeyInput.trim());
// Test the API key by making a real request to the server
const response = await fetch(`${base}/props`, {
@@ -79,7 +81,7 @@
// Show success state briefly, then navigate to home
setTimeout(() => {
goto(`#/`);
goto(ROUTES.START);
}, 1000);
} else {
// API key is invalid - User Story A
@@ -1,11 +1,11 @@
<script lang="ts">
import SettingsChatFooter from './SettingsChatFooter.svelte';
import SettingsChatFields from './SettingsChatFields.svelte';
import SettingsChatToolsTab from './SettingsChatToolsTab.svelte';
import SettingsChatImportExportTab from './SettingsChatImportExportTab.svelte';
import {
SettingsChatDesktopSidebar,
SettingsChatMobileHeader
SettingsChatFields,
SettingsChatImportExportTab,
SettingsChatMobileHeader,
SettingsChatToolsTab,
SettingsFooter
} from '$lib/components/app/settings';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import {
@@ -15,6 +15,7 @@
SETTINGS_SECTION_TITLES,
type SettingsSection
} from '$lib/constants';
import { RouterService } from '$lib/services/router.service';
import { setMode } from 'mode-watcher';
import { ColorMode } from '$lib/enums/ui';
import { fade } from 'svelte/transition';
@@ -22,7 +23,8 @@
import { page } from '$app/state';
import { setChatSettingsConfigContext } from '$lib/contexts';
import { settingsReferrer } from '$lib/stores/settings-referrer.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
interface Props {
initialSection?: string;
getSectionHref?: (section: SettingsSection) => string;
@@ -33,14 +35,30 @@
let activeSlug = $derived(
initialSection ?? (page.params as Record<string, string | undefined>).section ?? 'general'
);
let currentSection = $derived(
SETTINGS_CHAT_SECTIONS.find((section) => section.slug === activeSlug) ||
SETTINGS_CHAT_SECTIONS[0]
);
let localConfig: SettingsConfigType = $state({ ...config() });
let mobileHeader: { updateCarousel: () => void } | undefined;
let fetchInitiated = false;
$effect(() => {
if (isRouterMode() && currentSection.fields && !fetchInitiated) {
fetchInitiated = true;
void modelsStore
.fetch()
.then(() => modelsStore.fetchRouterModels())
.then(() => modelsStore.fetchModalitiesForLoadedModels())
.then(() => modelsStore.ensureFirstModelSelected());
}
});
function handleThemeChange(newTheme: string) {
localConfig.theme = newTheme;
setMode(newTheme as ColorMode);
@@ -110,13 +128,15 @@
<SettingsChatDesktopSidebar
sections={SETTINGS_CHAT_SECTIONS}
isActive={(section: SettingsSection) => section.slug === activeSlug}
getHref={getSectionHref ?? ((section: SettingsSection) => `#/settings/chat/${section.slug}`)}
getHref={getSectionHref ??
((section: SettingsSection) => RouterService.settings(section.slug))}
/>
<SettingsChatMobileHeader
sections={SETTINGS_CHAT_SECTIONS}
isActive={(section: SettingsSection) => section.slug === activeSlug}
getHref={getSectionHref ?? ((section: SettingsSection) => `#/settings/chat/${section.slug}`)}
getHref={getSectionHref ??
((section: SettingsSection) => RouterService.settings(section.slug))}
bind:this={mobileHeader}
/>
@@ -149,7 +169,7 @@
</div>
</div>
<SettingsChatFooter onReset={handleReset} onSave={handleSave} />
<SettingsFooter onReset={handleReset} onSave={handleSave} />
</div>
</div>
</div>
@@ -9,9 +9,9 @@
import { SettingsFieldType } from '$lib/enums/settings';
import { settingsStore } from '$lib/stores/settings.svelte';
import { serverStore } from '$lib/stores/server.svelte';
import { modelsStore, selectedModelName } from '$lib/stores/models.svelte';
import { modelsStore, selectedModelName, propsCacheVersion } from '$lib/stores/models.svelte';
import { normalizeFloatingPoint } from '$lib/utils/precision';
import SettingsChatParameterSourceIndicator from './SettingsChatParameterSourceIndicator.svelte';
import { SettingsChatParameterSourceIndicator } from '$lib/components/app/settings';
import type { Component } from 'svelte';
interface Props {
@@ -23,13 +23,19 @@
let { fields, localConfig, onConfigChange, onThemeChange }: Props = $props();
// server sampling defaults for placeholders
let sp = $derived.by(() => {
let currentModelParams = $derived.by(() => {
propsCacheVersion();
if (serverStore.isRouterMode) {
const m = selectedModelName();
if (m) {
const p = modelsStore.getModelProps(m);
return (p?.default_generation_settings?.params ?? {}) as Record<string, unknown>;
const currentModelName = selectedModelName();
if (currentModelName) {
const currentModelProps = modelsStore.getModelProps(currentModelName);
return (currentModelProps?.default_generation_settings?.params ?? {}) as Record<
string,
unknown
>;
}
}
return (serverStore.defaultParams ?? {}) as Record<string, unknown>;
@@ -40,7 +46,7 @@
<div class="space-y-2">
{#if field.type === SettingsFieldType.INPUT}
{@const currentValue = String(localConfig[field.key] ?? '')}
{@const serverDefault = sp[field.key]}
{@const serverDefault = currentModelParams[field.key]}
{@const isCustomRealTime = (() => {
if (serverDefault == null) return false;
if (currentValue === '') return false;
@@ -78,8 +84,8 @@
// Update local config immediately for real-time badge feedback
onConfigChange(field.key, e.currentTarget.value);
}}
placeholder={sp[field.key] != null
? `Default: ${normalizeFloatingPoint(sp[field.key])}`
placeholder={currentModelParams[field.key] != null
? `Default: ${normalizeFloatingPoint(currentModelParams[field.key])}`
: ''}
class="w-full {isCustomRealTime ? 'pr-8' : ''}"
/>
@@ -133,7 +139,8 @@
<Checkbox
id="showSystemMessage"
checked={Boolean(localConfig.showSystemMessage ?? true)}
onCheckedChange={(checked) => onConfigChange('showSystemMessage', Boolean(checked))}
onCheckedChange={(checked) =>
onConfigChange(SETTINGS_KEYS.SHOW_SYSTEM_MESSAGE, Boolean(checked))}
/>
<Label for="showSystemMessage" class="cursor-pointer text-sm font-normal">
@@ -147,7 +154,7 @@
opt.value === localConfig[field.key]
)}
{@const currentValue = localConfig[field.key]}
{@const serverDefault = sp[field.key]}
{@const serverDefault = currentModelParams[field.key]}
{@const isCustomRealTime = (() => {
if (serverDefault == null) return false;
if (currentValue === '' || currentValue === undefined) return false;
@@ -0,0 +1,62 @@
<script lang="ts">
import type { Component } from 'svelte';
import { Button, type ButtonVariant } from '$lib/components/ui/button';
let {
title,
description,
IconComponent,
buttonText,
onclick,
titleClass,
buttonVariant,
buttonClass,
wrapperClass,
summary
}: {
title: string;
description: string;
IconComponent: Component;
buttonText: string;
onclick: () => void;
titleClass?: string;
buttonVariant?: ButtonVariant;
buttonClass?: string;
wrapperClass?: string;
summary?: { show: boolean; verb: string; items: DatabaseConversation[] };
} = $props();
let sectionButtonClass = $derived(buttonClass ?? 'justify-start justify-self-start md:w-auto');
let sectionButtonVariant = $derived(buttonVariant ?? 'outline');
</script>
<div class="grid gap-1 {wrapperClass ?? ''}">
<h4 class="mt-0 mb-2 text-sm font-medium {titleClass ?? ''}">{title}</h4>
<p class="mb-4 text-sm text-muted-foreground">{description}</p>
<Button class={sectionButtonClass} {onclick} variant={sectionButtonVariant}>
<IconComponent class="mr-2 h-4 w-4" />
{buttonText}
</Button>
{#if summary && summary.show && summary.items.length > 0}
<div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
<h5 class="mb-2 text-sm font-medium">
{summary.verb}
{summary.items.length} conversation{summary.items.length === 1 ? '' : 's'}
</h5>
<ul class="space-y-1 text-sm text-muted-foreground">
{#each summary.items.slice(0, 10) as conv (conv.id)}
<li class="truncate">{conv.name || 'Untitled conversation'}</li>
{/each}
{#if summary.items.length > 10}
<li class="italic">... and {summary.items.length - 10} more</li>
{/if}
</ul>
</div>
{/if}
</div>
@@ -1,21 +1,18 @@
<script lang="ts">
import type { Component } from 'svelte';
import { Download, Upload, Trash2 } from '@lucide/svelte';
import { Button, type ButtonVariant } from '$lib/components/ui/button';
import { DialogConversationSelection, DialogConfirmation } from '$lib/components/app';
import {
DialogConversationSelection,
DialogConfirmation,
DialogExportSettings
} from '$lib/components/app';
import { createMessageCountMap } from '$lib/utils';
import { settingsStore } from '$lib/stores/settings.svelte';
import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
import { toast } from 'svelte-sonner';
import { fade } from 'svelte/transition';
import { ConversationSelectionMode, HtmlInputType, FileExtensionText } from '$lib/enums';
interface SectionOpts {
wrapperClass?: string;
titleClass?: string;
buttonVariant?: ButtonVariant;
buttonClass?: string;
summary?: { show: boolean; verb: string; items: DatabaseConversation[] };
}
import SettingsChatImportExportSection from './SettingsChatImportExportSection.svelte';
import SettingsGroup from '$lib/components/app/settings/SettingsGroup.svelte';
let exportedConversations = $state<DatabaseConversation[]>([]);
let importedConversations = $state<DatabaseConversation[]>([]);
@@ -33,6 +30,82 @@
// Delete functionality state
let showDeleteDialog = $state(false);
// Settings import/export state
let showSettingsExportSummary = $state(false);
let showSettingsImportSummary = $state(false);
let showSettingsExportDialog = $state(false);
let includeSensitiveData = $state(false);
function handleSettingsExport() {
showSettingsExportDialog = true;
includeSensitiveData = false;
}
function handleSettingsExportConfirm() {
showSettingsExportDialog = false;
try {
const data = settingsStore.exportSettings(includeSensitiveData);
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `llama_settings_${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showSettingsExportSummary = true;
showSettingsImportSummary = false;
toast.success('Settings exported');
} catch (err) {
console.error('Failed to export settings:', err);
toast.error('Failed to export settings');
}
}
function handleSettingsExportCancel() {
showSettingsExportDialog = false;
}
function handleSettingsImport() {
try {
const input = document.createElement('input');
input.type = HtmlInputType.FILE;
input.accept = FileExtensionText.JSON;
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement)?.files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
if (!data || typeof data !== 'object' || !data.config) {
toast.error('Invalid settings file: missing config');
return;
}
settingsStore.importSettings(data);
showSettingsImportSummary = true;
showSettingsExportSummary = false;
toast.success('Settings imported successfully');
} catch (err) {
console.error('Failed to import settings:', err);
toast.error('Failed to import settings');
}
};
input.click();
} catch (err) {
console.error('Failed to open file picker:', err);
toast.error('Failed to open file picker');
}
}
async function handleExportClick() {
try {
const allConversations = conversations();
@@ -181,94 +254,66 @@
}
</script>
{#snippet summaryList(show: boolean, verb: string, items: DatabaseConversation[])}
{#if show && items.length > 0}
<div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
<h5 class="mb-2 text-sm font-medium">
{verb}
{items.length} conversation{items.length === 1 ? '' : 's'}
</h5>
<div class="space-y-12" in:fade={{ duration: 150 }}>
<SettingsGroup title="Conversations">
<SettingsChatImportExportSection
title="Export"
description="Download your conversations as a JSON file. This includes all messages, attachments, and conversation history."
IconComponent={Download}
buttonText="Export conversations"
onclick={handleExportClick}
summary={{ show: showExportSummary, verb: 'Exported', items: exportedConversations }}
/>
<ul class="space-y-1 text-sm text-muted-foreground">
{#each items.slice(0, 10) as conv (conv.id)}
<li class="truncate">{conv.name || 'Untitled conversation'}</li>
{/each}
<SettingsChatImportExportSection
title="Import"
description="Import one or more conversations from a previously exported JSON file. This will merge with your existing conversations."
IconComponent={Upload}
buttonText="Import conversations"
onclick={handleImportClick}
summary={{ show: showImportSummary, verb: 'Imported', items: importedConversations }}
/>
{#if items.length > 10}
<li class="italic">... and {items.length - 10} more</li>
{/if}
</ul>
</div>
{/if}
{/snippet}
<SettingsChatImportExportSection
title="Delete All"
description="Permanently delete all conversations and their messages. This action cannot be undone. Consider exporting your conversations first if you want to keep a backup."
IconComponent={Trash2}
buttonText="Delete all conversations"
onclick={handleDeleteAllClick}
titleClass="text-destructive"
buttonVariant="destructive"
buttonClass="text-destructive-foreground justify-start justify-self-start bg-destructive hover:bg-destructive/80 md:w-auto"
/>
</SettingsGroup>
{#snippet section(
title: string,
description: string,
IconComponent: Component,
buttonText: string,
onclick: () => void,
opts: SectionOpts
)}
{@const buttonClass = opts?.buttonClass ?? 'justify-start justify-self-start md:w-auto'}
{@const buttonVariant = opts?.buttonVariant ?? 'outline'}
<div class="grid gap-1 {opts?.wrapperClass ?? ''}">
<h4 class="mt-0 mb-2 text-sm font-medium {opts?.titleClass ?? ''}">{title}</h4>
<SettingsGroup title="Settings">
<SettingsChatImportExportSection
title="Export"
description="Export your chat settings and preferences as a JSON file."
IconComponent={Download}
buttonText="Export settings"
onclick={handleSettingsExport}
summary={{ show: showSettingsExportSummary, verb: 'Exported', items: [] }}
/>
<p class="mb-4 text-sm text-muted-foreground">{description}</p>
<Button class={buttonClass} {onclick} variant={buttonVariant}>
<IconComponent class="mr-2 h-4 w-4" />
{buttonText}
</Button>
{#if opts?.summary}
{@render summaryList(opts.summary.show, opts.summary.verb, opts.summary.items)}
{/if}
</div>
{/snippet}
<div class="space-y-6" in:fade={{ duration: 150 }}>
<div class="space-y-6">
{@render section(
'Export Conversations',
'Download all your conversations as a JSON file. This includes all messages, attachments, and conversation history.',
Download,
'Export conversations',
handleExportClick,
{ summary: { show: showExportSummary, verb: 'Exported', items: exportedConversations } }
)}
{@render section(
'Import Conversations',
'Import one or more conversations from a previously exported JSON file. This will merge with your existing conversations.',
Upload,
'Import conversations',
handleImportClick,
{
wrapperClass: 'border-t border-border/30 pt-6',
summary: { show: showImportSummary, verb: 'Imported', items: importedConversations }
}
)}
{@render section(
'Delete All Conversations',
'Permanently delete all conversations and their messages. This action cannot be undone. Consider exporting your conversations first if you want to keep a backup.',
Trash2,
'Delete all conversations',
handleDeleteAllClick,
{
wrapperClass: 'border-t border-border/30 pt-4',
titleClass: 'text-destructive',
buttonVariant: 'destructive',
buttonClass:
'text-destructive-foreground justify-start justify-self-start bg-destructive hover:bg-destructive/80 md:w-auto'
}
)}
</div>
<SettingsChatImportExportSection
title="Import"
description="Import chat settings from a previously exported JSON file. This will merge with your existing settings."
IconComponent={Upload}
buttonText="Import settings"
onclick={handleSettingsImport}
summary={{ show: showSettingsImportSummary, verb: 'Imported', items: [] }}
/>
</SettingsGroup>
</div>
<DialogExportSettings
bind:open={showSettingsExportDialog}
bind:includeSensitiveData
onConfirm={handleSettingsExportConfirm}
onCancel={handleSettingsExportCancel}
/>
<DialogConversationSelection
conversations={availableConversations}
{messageCountMap}
@@ -0,0 +1,18 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
children: Snippet;
}
let { title, children }: Props = $props();
</script>
<div>
<h3 class="mb-6 text-base font-semibold">{title}</h3>
<div class="space-y-8">
{@render children()}
</div>
</div>
@@ -19,32 +19,6 @@ export { default as SettingsChatDesktopSidebar } from './SettingsChatDesktopSide
*/
export { default as SettingsChatMobileHeader } from './SettingsChatMobileHeader.svelte';
/**
* Settings Import/Export panel.
* Provides UI for importing and exporting chat conversations.
*/
export { default as SettingsChatImportExportTab } from './SettingsChat/SettingsChatImportExportTab.svelte';
/**
* MCP Servers configuration panel.
* Provides UI for managing Model Context Protocol (MCP) server connections.
*/
export { default as SettingsMcpServers } from './SettingsMcpServers.svelte';
/**
* Footer with save/cancel buttons for settings panel. Positioned at bottom
* of settings dialog. Save button commits form state to config store,
* cancel button triggers reset and close.
*/
export { default as SettingsChatFooter } from './SettingsChat/SettingsChatFooter.svelte';
/**
* Form fields renderer for individual settings. Generates appropriate input
* components based on field type (text, number, select, checkbox, textarea).
* Handles validation, help text display, and parameter source indicators.
*/
export { default as SettingsChatFields } from './SettingsChat/SettingsChatFields.svelte';
/**
* Badge indicating parameter source for sampling settings. Shows one of:
* - **Custom**: User has explicitly set this value (orange badge)
@@ -54,6 +28,44 @@ export { default as SettingsChatFields } from './SettingsChat/SettingsChatFields
*/
export { default as SettingsChatParameterSourceIndicator } from './SettingsChat/SettingsChatParameterSourceIndicator.svelte';
/**
* Section wrapper for settings panels. Displays a title heading with
* child content in a structured layout.
*/
export { default as SettingsGroup } from './SettingsGroup.svelte';
/**
* Footer with save/cancel buttons for settings panel. Positioned at bottom
* of settings dialog. Save button commits form state to config store,
* cancel button triggers reset and close.
*/
export { default as SettingsFooter } from './SettingsFooter.svelte';
/**
* Settings Import/Export panel.
* Provides UI for importing and exporting chat conversations.
*/
export { default as SettingsChatImportExportTab } from './SettingsChat/SettingsChatImportExportTab.svelte';
/**
* Section wrapper for import/export sections. Displays a title, description,
* icon button, and optional summary of recent actions.
*/
export { default as SettingsChatImportExportSection } from './SettingsChat/SettingsChatImportExportSection.svelte';
/**
* MCP Servers configuration panel.
* Provides UI for managing Model Context Protocol (MCP) server connections.
*/
export { default as SettingsMcpServers } from './SettingsMcpServers.svelte';
/**
* Form fields renderer for individual settings. Generates appropriate input
* components based on field type (text, number, select, checkbox, textarea).
* Handles validation, help text display, and parameter source indicators.
*/
export { default as SettingsChatFields } from './SettingsChat/SettingsChatFields.svelte';
/**
* **SettingsChatToolsTab** - Tools configuration tab for chat settings
*
@@ -29,10 +29,9 @@ export * from './message-export';
export * from './model-id';
export * from './precision';
export * from './processing-info';
export * from './settings-config';
export * from './settings-fields';
export * from './routes';
export * from './settings-keys';
export * from './settings-sections';
export * from './settings-registry';
export * from './supported-file-types';
export * from './table-html-restorer';
export * from './title-generation';
@@ -0,0 +1,26 @@
export const NEW_CHAT_PARAM = 'new_chat';
/** Settings section slugs — used for routes and navigation. */
export const SETTINGS_SECTION_SLUGS = {
GENERAL: 'general',
DISPLAY: 'display',
SAMPLING: 'sampling',
PENALTIES: 'penalties',
AGENTIC: 'agentic',
DEVELOPER: 'developer',
TOOLS: 'tools',
IMPORT_EXPORT: 'import-export'
} as const;
export const ROUTES = {
/** Root — start of the app. */
START: '#/',
/** New chat — root with new chat query param. */
NEW_CHAT: `?${NEW_CHAT_PARAM}=true#/`,
/** Chat base — for dynamic chat URLs use RouterService. */
CHAT: '#/chat',
/** MCP servers. */
MCP_SERVERS: '#/mcp-servers',
/** Settings base — for dynamic settings URLs use RouterService. */
SETTINGS: '#/settings'
} as const;
@@ -1,170 +0,0 @@
import { ColorMode } from '$lib/enums/ui';
import { Monitor, Moon, Sun } from '@lucide/svelte';
import { TITLE } from './title-generation';
export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean | undefined> = {
// Note: in order not to introduce breaking changes, please keep the same data type (number, string, etc) if you want to change the default value.
// Do not use nested objects, keep it single level. Prefix the key if you need to group them.
apiKey: '',
systemMessage: '',
showSystemMessage: true,
theme: ColorMode.SYSTEM,
showThoughtInProgress: true,
disableReasoningParsing: false,
excludeReasoningFromContext: false,
showRawOutputSwitch: false,
keepStatsVisible: false,
showMessageStats: true,
askForTitleConfirmation: false,
titleGenerationUseFirstLine: false,
titleGenerationUseLLM: false,
titleGenerationPrompt: TITLE.DEFAULT_PROMPT,
pasteLongTextToFileLen: 2500,
copyTextAttachmentsAsPlainText: false,
pdfAsImage: false,
disableAutoScroll: false,
renderUserContentAsMarkdown: false,
alwaysShowSidebarOnDesktop: false,
autoShowSidebarOnNewChat: true,
sendOnEnter: true,
autoMicOnEmpty: false,
fullHeightCodeBlocks: false,
showRawModelNames: false,
mcpServers: '[]',
mcpServerUsageStats: '{}', // JSON object: { [serverId]: usageCount }
agenticMaxTurns: 10,
agenticMaxToolPreviewLines: 25,
showToolCallInProgress: false,
alwaysShowAgenticTurns: false,
// sampling params: empty means "use server default"
// the server / preset is the source of truth
// empty values are shown as placeholders from /props in the UI
// and are NOT sent in API requests, letting the server decide
samplers: '',
backend_sampling: false,
temperature: undefined,
dynatemp_range: undefined,
dynatemp_exponent: undefined,
top_k: undefined,
top_p: undefined,
min_p: undefined,
xtc_probability: undefined,
xtc_threshold: undefined,
typ_p: undefined,
repeat_last_n: undefined,
repeat_penalty: undefined,
presence_penalty: undefined,
frequency_penalty: undefined,
dry_multiplier: undefined,
dry_base: undefined,
dry_allowed_length: undefined,
dry_penalty_last_n: undefined,
max_tokens: undefined,
custom: '', // custom json-stringified object
preEncodeConversation: false,
// experimental features
pyInterpreterEnabled: false,
enableContinueGeneration: false
};
export const SETTING_CONFIG_INFO: Record<string, string> = {
apiKey: 'Set the API Key if you are using <code>--api-key</code> option for the server.',
systemMessage: 'The starting message that defines how model should behave.',
showSystemMessage: 'Display the system message at the top of each conversation.',
theme:
'Choose the color theme for the interface. You can choose between System (follows your device settings), Light, or Dark.',
pasteLongTextToFileLen:
'On pasting long text, it will be converted to a file. You can control the file length by setting the value of this parameter. Value 0 means disable.',
copyTextAttachmentsAsPlainText:
'When copying a message with text attachments, combine them into a single plain text string instead of a special format that can be pasted back as attachments.',
samplers:
'The order at which samplers are applied, in simplified way. Default is "top_k;typ_p;top_p;min_p;temperature": top_k->typ_p->top_p->min_p->temperature',
backend_sampling:
'Enable backend-based samplers. When enabled, supported samplers run on the accelerator backend for faster sampling.',
temperature:
'Controls the randomness of the generated text by affecting the probability distribution of the output tokens. Higher = more random, lower = more focused.',
dynatemp_range:
'Addon for the temperature sampler. The added value to the range of dynamic temperature, which adjusts probabilities by entropy of tokens.',
dynatemp_exponent:
'Addon for the temperature sampler. Smoothes out the probability redistribution based on the most probable token.',
top_k: 'Keeps only k top tokens.',
top_p: 'Limits tokens to those that together have a cumulative probability of at least p',
min_p:
'Limits tokens based on the minimum probability for a token to be considered, relative to the probability of the most likely token.',
xtc_probability:
'XTC sampler cuts out top tokens; this parameter controls the chance of cutting tokens at all. 0 disables XTC.',
xtc_threshold:
'XTC sampler cuts out top tokens; this parameter controls the token probability that is required to cut that token.',
typ_p: 'Sorts and limits tokens based on the difference between log-probability and entropy.',
repeat_last_n: 'Last n tokens to consider for penalizing repetition',
repeat_penalty: 'Controls the repetition of token sequences in the generated text',
presence_penalty: 'Limits tokens based on whether they appear in the output or not.',
frequency_penalty: 'Limits tokens based on how often they appear in the output.',
dry_multiplier:
'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling multiplier.',
dry_base:
'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling base value.',
dry_allowed_length:
'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the allowed length for DRY sampling.',
dry_penalty_last_n:
'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets DRY penalty for the last n tokens.',
max_tokens: 'The maximum number of token per output. Use -1 for infinite (no limit).',
custom: 'Custom JSON parameters to send to the API. Must be valid JSON format.',
showThoughtInProgress: 'Expand thought process by default when generating messages.',
disableReasoningParsing:
'Send reasoning_format=none so the server returns thinking tokens inline instead of extracting them into a separate field.',
excludeReasoningFromContext:
'Strip thinking from previous messages before sending. When off, thinking is sent back via the reasoning_content field so the model sees its own chain-of-thought across turns.',
showRawOutputSwitch:
'Show toggle button to display messages as plain text instead of Markdown-formatted content',
keepStatsVisible: 'Keep processing statistics visible after generation finishes.',
showMessageStats:
'Display generation statistics (tokens/second, token count, duration) below each assistant message.',
askForTitleConfirmation:
'Ask for confirmation before automatically changing conversation title when editing the first message.',
titleGenerationUseFirstLine:
'Use only the first non-empty line of the prompt to generate the conversation title.',
titleGenerationUseLLM:
'Use the LLM to automatically generate conversation titles based on the first message exchange.',
titleGenerationPrompt:
'Optional template for the title generation prompt. Use {{USER}} for the user message and {{ASSISTANT}} for the assistant message.',
pdfAsImage:
'Parse PDF as image instead of text. Automatically falls back to text processing for non-vision models.',
disableAutoScroll:
'Disable automatic scrolling while messages stream so you can control the viewport position manually.',
renderUserContentAsMarkdown: 'Render user messages using markdown formatting in the chat.',
alwaysShowSidebarOnDesktop:
'Always keep the sidebar visible on desktop instead of auto-hiding it.',
autoShowSidebarOnNewChat:
'Automatically show sidebar when starting a new chat. Disable to keep the sidebar hidden until you click on it.',
sendOnEnter:
'Use Enter to send messages and Shift + Enter for new lines. When disabled, use Ctrl/Cmd + Enter.',
autoMicOnEmpty:
'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
fullHeightCodeBlocks:
'Always display code blocks at their full natural height, overriding any height limits.',
showRawModelNames:
'Display full raw model identifiers (e.g. "ggml-org/GLM-4.7-Flash-GGUF:Q8_0") instead of parsed names with badges.',
mcpServers:
'Configure MCP servers as a JSON list. Use the form in the MCP Client settings section to edit.',
mcpServerUsageStats:
'Usage statistics for MCP servers. Tracks how many times tools from each server have been used.',
agenticMaxTurns:
'Maximum number of tool execution cycles before stopping (prevents infinite loops).',
agenticMaxToolPreviewLines:
'Number of lines shown in tool output previews (last N lines). Only these previews and the final LLM response persist after the agentic loop completes.',
showToolCallInProgress:
'Automatically expand tool call details while executing and keep them expanded after completion.',
pyInterpreterEnabled:
'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.',
preEncodeConversation:
'After each response, re-submit the conversation to pre-fill the server KV cache. Makes the next turn faster since the prompt is already encoded while you read the response.',
enableContinueGeneration:
'Enable "Continue" button for assistant messages. Currently works only with non-reasoning models.'
};
export const SETTINGS_COLOR_MODES_CONFIG = [
{ value: ColorMode.SYSTEM, label: 'System', icon: Monitor },
{ value: ColorMode.LIGHT, label: 'Light', icon: Sun },
{ value: ColorMode.DARK, label: 'Dark', icon: Moon }
];
@@ -1,33 +0,0 @@
/**
* List of all numeric fields in settings configuration.
* These fields will be converted from strings to numbers during save.
*/
export const NUMERIC_FIELDS = [
'temperature',
'top_k',
'top_p',
'min_p',
'max_tokens',
'pasteLongTextToFileLen',
'dynatemp_range',
'dynatemp_exponent',
'typ_p',
'xtc_probability',
'xtc_threshold',
'repeat_last_n',
'repeat_penalty',
'presence_penalty',
'frequency_penalty',
'dry_multiplier',
'dry_base',
'dry_allowed_length',
'dry_penalty_last_n',
'agenticMaxTurns',
'agenticMaxToolPreviewLines'
] as const;
/**
* Fields that must be positive integers (>= 1).
* These will be clamped to minimum 1 and rounded during save.
*/
export const POSITIVE_INTEGER_FIELDS = ['agenticMaxTurns', 'agenticMaxToolPreviewLines'] as const;
@@ -28,6 +28,7 @@ export const SETTINGS_KEYS = {
ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop',
FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
SHOW_SYSTEM_MESSAGE: 'showSystemMessage',
// Sampling
TEMPERATURE: 'temperature',
DYNATEMP_RANGE: 'dynatemp_range',
@@ -51,6 +52,7 @@ export const SETTINGS_KEYS = {
DRY_ALLOWED_LENGTH: 'dry_allowed_length',
DRY_PENALTY_LAST_N: 'dry_penalty_last_n',
// MCP
MCP_SERVERS: 'mcpServers',
AGENTIC_MAX_TURNS: 'agenticMaxTurns',
ALWAYS_SHOW_AGENTIC_TURNS: 'alwaysShowAgenticTurns',
AGENTIC_MAX_TOOL_PREVIEW_LINES: 'agenticMaxToolPreviewLines',
@@ -61,5 +63,6 @@ export const SETTINGS_KEYS = {
DISABLE_REASONING_PARSING: 'disableReasoningParsing',
EXCLUDE_REASONING_FROM_CONTEXT: 'excludeReasoningFromContext',
SHOW_RAW_OUTPUT_SWITCH: 'showRawOutputSwitch',
// PY_INTERPRETER_ENABLED: 'pyInterpreterEnabled',
CUSTOM: 'custom'
} as const;
@@ -0,0 +1,719 @@
import { ColorMode } from '$lib/enums/ui';
import { SettingsFieldType } from '$lib/enums/settings';
import { SyncableParameterType } from '$lib/enums';
import {
Funnel,
AlertTriangle,
Code,
Monitor,
ListRestart,
Sliders,
PencilRuler,
Database,
Monitor as MonitorIcon,
Sun,
Moon
} from '@lucide/svelte';
import type { Component } from 'svelte';
import type {
SettingsConfigValue,
SyncableParameter,
SettingsEntry,
SettingsSectionTitle,
SettingsSectionEntry,
SettingsSection
} from '$lib/types';
import { SETTINGS_KEYS } from './settings-keys';
import { ROUTES, SETTINGS_SECTION_SLUGS } from './routes';
import { TITLE_GENERATION } from './title-generation';
export const SETTINGS_SECTION_TITLES = {
GENERAL: 'General',
DISPLAY: 'Display',
SAMPLING: 'Sampling',
PENALTIES: 'Penalties',
AGENTIC: 'Agentic',
TOOLS: 'Tools',
IMPORT_EXPORT: 'Import/Export',
DEVELOPER: 'Developer'
} as const;
const STANDALONE_SECTIONS: { title: SettingsSectionTitle; slug: string; icon: Component }[] = [
{ title: SETTINGS_SECTION_TITLES.TOOLS, slug: SETTINGS_SECTION_SLUGS.TOOLS, icon: PencilRuler },
{
title: SETTINGS_SECTION_TITLES.IMPORT_EXPORT,
slug: SETTINGS_SECTION_SLUGS.IMPORT_EXPORT,
icon: Database
}
];
const COLOR_MODE_OPTIONS: Array<{ value: string; label: string; icon: Component }> = [
{ value: ColorMode.SYSTEM, label: 'System', icon: MonitorIcon },
{ value: ColorMode.LIGHT, label: 'Light', icon: Sun },
{ value: ColorMode.DARK, label: 'Dark', icon: Moon }
];
const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
[SETTINGS_SECTION_SLUGS.GENERAL]: {
title: SETTINGS_SECTION_TITLES.GENERAL,
slug: SETTINGS_SECTION_SLUGS.GENERAL,
icon: Sliders,
settings: [
{
key: SETTINGS_KEYS.THEME,
label: 'Theme',
help: 'Choose the color theme for the interface. You can choose between System (follows your device settings), Light, or Dark.',
defaultValue: ColorMode.SYSTEM,
type: SettingsFieldType.SELECT,
section: SETTINGS_SECTION_SLUGS.GENERAL,
options: COLOR_MODE_OPTIONS,
sync: { serverKey: SETTINGS_KEYS.THEME, paramType: SyncableParameterType.STRING }
},
{
key: SETTINGS_KEYS.API_KEY,
label: 'API Key',
help: 'Set the API Key if you are using <code>--api-key</code> option for the server.',
defaultValue: '',
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.GENERAL
},
{
key: SETTINGS_KEYS.SYSTEM_MESSAGE,
label: 'System Message',
help: 'The starting message that defines how model should behave.',
defaultValue: '',
type: SettingsFieldType.TEXTAREA,
section: SETTINGS_SECTION_SLUGS.GENERAL,
sync: { serverKey: SETTINGS_KEYS.SYSTEM_MESSAGE, paramType: SyncableParameterType.STRING }
},
{
key: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN,
label: 'Paste long text to file length',
help: 'On pasting long text, it will be converted to a file. You can control the file length by setting the value of this parameter. Value 0 means disable.',
defaultValue: 2500,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.GENERAL,
sync: {
serverKey: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN,
paramType: SyncableParameterType.NUMBER
}
},
{
key: SETTINGS_KEYS.SEND_ON_ENTER,
label: 'Send message on Enter',
help: 'Use Enter to send messages and Shift + Enter for new lines. When disabled, use Ctrl/Cmd + Enter.',
defaultValue: true,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.GENERAL,
sync: { serverKey: SETTINGS_KEYS.SEND_ON_ENTER, paramType: SyncableParameterType.BOOLEAN }
},
{
key: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT,
label: 'Copy text attachments as plain text',
help: 'When copying a message with text attachments, combine them into a single plain text string instead of a special format that can be pasted back as attachments.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.GENERAL,
sync: {
serverKey: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT,
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION,
label: 'Enable "Continue" button',
help: 'Enable "Continue" button for assistant messages. Currently works only with non-reasoning models.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.GENERAL,
isExperimental: true,
sync: {
serverKey: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION,
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.PDF_AS_IMAGE,
label: 'Parse PDF as image',
help: 'Parse PDF as image instead of text. Automatically falls back to text processing for non-vision models.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.GENERAL,
sync: { serverKey: SETTINGS_KEYS.PDF_AS_IMAGE, paramType: SyncableParameterType.BOOLEAN }
},
{
key: SETTINGS_KEYS.ASK_FOR_TITLE_CONFIRMATION,
label: 'Ask for confirmation before changing conversation title',
help: 'Ask for confirmation before automatically changing conversation title when editing the first message.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.GENERAL,
sync: {
serverKey: SETTINGS_KEYS.ASK_FOR_TITLE_CONFIRMATION,
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
label: 'Use first non-empty line for conversation title',
help: 'Use only the first non-empty line of the prompt to generate the conversation title.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.GENERAL,
sync: {
serverKey: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
label: 'Use LLM to generate conversation title',
help: 'Use the LLM to automatically generate conversation titles based on the first message exchange.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.GENERAL,
isExperimental: true
},
{
key: SETTINGS_KEYS.TITLE_GENERATION_PROMPT,
label: 'LLM title generation prompt',
help: 'Optional template for the title generation prompt. Use {{USER}} for the user message and {{ASSISTANT}} for the assistant message.',
defaultValue: TITLE_GENERATION.DEFAULT_PROMPT,
type: SettingsFieldType.TEXTAREA,
section: SETTINGS_SECTION_SLUGS.GENERAL
}
]
},
[SETTINGS_SECTION_SLUGS.DISPLAY]: {
title: SETTINGS_SECTION_TITLES.DISPLAY,
slug: SETTINGS_SECTION_SLUGS.DISPLAY,
icon: Monitor,
settings: [
{
key: SETTINGS_KEYS.SHOW_MESSAGE_STATS,
label: 'Show message generation statistics',
help: 'Display generation statistics (tokens/second, token count, duration) below each assistant message.',
defaultValue: true,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY,
sync: {
serverKey: SETTINGS_KEYS.SHOW_MESSAGE_STATS,
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS,
label: 'Show thought in progress',
help: 'Expand thought process by default when generating messages.',
defaultValue: true,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY,
sync: {
serverKey: SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS,
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS,
label: 'Show tool call in progress',
help: 'Automatically expand tool call details while executing and keep them expanded after completion.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY,
sync: {
serverKey: SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS,
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.KEEP_STATS_VISIBLE,
label: 'Keep stats visible after generation',
help: 'Keep processing statistics visible after generation finishes.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY,
sync: {
serverKey: SETTINGS_KEYS.KEEP_STATS_VISIBLE,
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
label: 'Show microphone on empty input',
help: 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY,
isExperimental: true,
sync: {
serverKey: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
label: 'Render user content as Markdown',
help: 'Render user messages using markdown formatting in the chat.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY,
sync: {
serverKey: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS,
label: 'Use full height code blocks',
help: 'Always display code blocks at their full natural height, overriding any height limits.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY,
sync: {
serverKey: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS,
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.DISABLE_AUTO_SCROLL,
label: 'Disable automatic scroll',
help: 'Disable automatic scrolling while messages stream so you can control the viewport position manually.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY,
sync: {
serverKey: SETTINGS_KEYS.DISABLE_AUTO_SCROLL,
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.ALWAYS_SHOW_SIDEBAR_ON_DESKTOP,
label: 'Always show sidebar on desktop',
help: 'Always keep the sidebar visible on desktop instead of auto-hiding it.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY,
sync: {
serverKey: SETTINGS_KEYS.ALWAYS_SHOW_SIDEBAR_ON_DESKTOP,
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES,
label: 'Show raw model names',
help: 'Display full raw model identifiers (e.g. "ggml-org/GLM-4.7-Flash-GGUF:Q8_0") instead of parsed names with badges.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY,
sync: {
serverKey: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES,
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS,
label: 'Always show agentic turns in conversation',
help: 'Always expand and display agentic loop turns in conversation messages.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY,
sync: {
serverKey: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS,
paramType: SyncableParameterType.BOOLEAN
}
}
]
},
[SETTINGS_SECTION_SLUGS.SAMPLING]: {
title: SETTINGS_SECTION_TITLES.SAMPLING,
slug: SETTINGS_SECTION_SLUGS.SAMPLING,
icon: Funnel,
settings: [
{
key: SETTINGS_KEYS.TEMPERATURE,
label: 'Temperature',
help: 'Controls the randomness of the generated text by affecting the probability distribution of the output tokens. Higher = more random, lower = more focused.',
defaultValue: undefined,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.SAMPLING,
sync: { serverKey: SETTINGS_KEYS.TEMPERATURE, paramType: SyncableParameterType.NUMBER }
},
{
key: SETTINGS_KEYS.DYNATEMP_RANGE,
label: 'Dynamic temperature range',
help: 'Addon for the temperature sampler. The added value to the range of dynamic temperature, which adjusts probabilities by entropy of tokens.',
defaultValue: undefined,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.SAMPLING,
sync: { serverKey: SETTINGS_KEYS.DYNATEMP_RANGE, paramType: SyncableParameterType.NUMBER }
},
{
key: SETTINGS_KEYS.DYNATEMP_EXPONENT,
label: 'Dynamic temperature exponent',
help: 'Addon for the temperature sampler. Smoothes out the probability redistribution based on the most probable token.',
defaultValue: undefined,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.SAMPLING,
sync: {
serverKey: SETTINGS_KEYS.DYNATEMP_EXPONENT,
paramType: SyncableParameterType.NUMBER
}
},
{
key: SETTINGS_KEYS.TOP_K,
label: 'Top K',
help: 'Keeps only k top tokens.',
defaultValue: undefined,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.SAMPLING,
sync: { serverKey: SETTINGS_KEYS.TOP_K, paramType: SyncableParameterType.NUMBER }
},
{
key: SETTINGS_KEYS.TOP_P,
label: 'Top P',
help: 'Limits tokens to those that together have a cumulative probability of at least p',
defaultValue: undefined,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.SAMPLING,
sync: { serverKey: SETTINGS_KEYS.TOP_P, paramType: SyncableParameterType.NUMBER }
},
{
key: SETTINGS_KEYS.MIN_P,
label: 'Min P',
help: 'Limits tokens based on the minimum probability for a token to be considered, relative to the probability of the most likely token.',
defaultValue: undefined,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.SAMPLING,
sync: { serverKey: SETTINGS_KEYS.MIN_P, paramType: SyncableParameterType.NUMBER }
},
{
key: SETTINGS_KEYS.XTC_PROBABILITY,
label: 'XTC probability',
help: 'XTC sampler cuts out top tokens; this parameter controls the chance of cutting tokens at all. 0 disables XTC.',
defaultValue: undefined,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.SAMPLING,
sync: { serverKey: SETTINGS_KEYS.XTC_PROBABILITY, paramType: SyncableParameterType.NUMBER }
},
{
key: SETTINGS_KEYS.XTC_THRESHOLD,
label: 'XTC threshold',
help: 'XTC sampler cuts out top tokens; this parameter controls the token probability that is required to cut that token.',
defaultValue: undefined,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.SAMPLING,
sync: { serverKey: SETTINGS_KEYS.XTC_THRESHOLD, paramType: SyncableParameterType.NUMBER }
},
{
key: SETTINGS_KEYS.TYP_P,
label: 'Typical P',
help: 'Sorts and limits tokens based on the difference between log-probability and entropy.',
defaultValue: undefined,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.SAMPLING,
sync: { serverKey: SETTINGS_KEYS.TYP_P, paramType: SyncableParameterType.NUMBER }
},
{
key: SETTINGS_KEYS.MAX_TOKENS,
label: 'Max tokens',
help: 'The maximum number of token per output. Use -1 for infinite (no limit).',
defaultValue: undefined,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.SAMPLING,
sync: { serverKey: SETTINGS_KEYS.MAX_TOKENS, paramType: SyncableParameterType.NUMBER }
},
{
key: SETTINGS_KEYS.SAMPLERS,
label: 'Samplers',
help: 'The order at which samplers are applied, in simplified way. Default is "top_k;typ_p;top_p;min_p;temperature": top_k->typ_p->top_p->min_p->temperature',
defaultValue: '',
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.SAMPLING,
sync: { serverKey: SETTINGS_KEYS.SAMPLERS, paramType: SyncableParameterType.STRING }
},
{
key: SETTINGS_KEYS.BACKEND_SAMPLING,
label: 'Backend sampling',
help: 'Enable backend-based samplers. When enabled, supported samplers run on the accelerator backend for faster sampling.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.SAMPLING,
sync: {
serverKey: SETTINGS_KEYS.BACKEND_SAMPLING,
paramType: SyncableParameterType.BOOLEAN
}
}
]
},
[SETTINGS_SECTION_SLUGS.PENALTIES]: {
title: SETTINGS_SECTION_TITLES.PENALTIES,
slug: SETTINGS_SECTION_SLUGS.PENALTIES,
icon: AlertTriangle,
settings: [
{
key: SETTINGS_KEYS.REPEAT_LAST_N,
label: 'Repeat last N',
help: 'Last n tokens to consider for penalizing repetition',
defaultValue: undefined,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.PENALTIES,
sync: { serverKey: SETTINGS_KEYS.REPEAT_LAST_N, paramType: SyncableParameterType.NUMBER }
},
{
key: SETTINGS_KEYS.REPEAT_PENALTY,
label: 'Repeat penalty',
help: 'Controls the repetition of token sequences in the generated text',
defaultValue: undefined,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.PENALTIES,
sync: { serverKey: SETTINGS_KEYS.REPEAT_PENALTY, paramType: SyncableParameterType.NUMBER }
},
{
key: SETTINGS_KEYS.PRESENCE_PENALTY,
label: 'Presence penalty',
help: 'Limits tokens based on whether they appear in the output or not.',
defaultValue: undefined,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.PENALTIES,
sync: { serverKey: SETTINGS_KEYS.PRESENCE_PENALTY, paramType: SyncableParameterType.NUMBER }
},
{
key: SETTINGS_KEYS.FREQUENCY_PENALTY,
label: 'Frequency penalty',
help: 'Limits tokens based on how often they appear in the output.',
defaultValue: undefined,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.PENALTIES,
sync: {
serverKey: SETTINGS_KEYS.FREQUENCY_PENALTY,
paramType: SyncableParameterType.NUMBER
}
},
{
key: SETTINGS_KEYS.DRY_MULTIPLIER,
label: 'DRY multiplier',
help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling multiplier.',
defaultValue: undefined,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.PENALTIES,
sync: { serverKey: SETTINGS_KEYS.DRY_MULTIPLIER, paramType: SyncableParameterType.NUMBER }
},
{
key: SETTINGS_KEYS.DRY_BASE,
label: 'DRY base',
help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling base value.',
defaultValue: undefined,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.PENALTIES,
sync: { serverKey: SETTINGS_KEYS.DRY_BASE, paramType: SyncableParameterType.NUMBER }
},
{
key: SETTINGS_KEYS.DRY_ALLOWED_LENGTH,
label: 'DRY allowed length',
help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the allowed length for DRY sampling.',
defaultValue: undefined,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.PENALTIES,
sync: {
serverKey: SETTINGS_KEYS.DRY_ALLOWED_LENGTH,
paramType: SyncableParameterType.NUMBER
}
},
{
key: SETTINGS_KEYS.DRY_PENALTY_LAST_N,
label: 'DRY penalty last N',
help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets DRY penalty for the last n tokens.',
defaultValue: undefined,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.PENALTIES,
sync: {
serverKey: SETTINGS_KEYS.DRY_PENALTY_LAST_N,
paramType: SyncableParameterType.NUMBER
}
}
]
},
[SETTINGS_SECTION_SLUGS.AGENTIC]: {
title: SETTINGS_SECTION_TITLES.AGENTIC,
slug: SETTINGS_SECTION_SLUGS.AGENTIC,
icon: ListRestart,
settings: [
{
key: SETTINGS_KEYS.AGENTIC_MAX_TURNS,
label: 'Agentic turns',
help: 'Maximum number of tool execution cycles before stopping (prevents infinite loops).',
defaultValue: 10,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.AGENTIC,
isPositiveInteger: true,
sync: {
serverKey: SETTINGS_KEYS.AGENTIC_MAX_TURNS,
paramType: SyncableParameterType.NUMBER
}
},
{
key: SETTINGS_KEYS.AGENTIC_MAX_TOOL_PREVIEW_LINES,
label: 'Max lines per tool preview',
help: 'Number of lines shown in tool output previews (last N lines). Only these previews and the final LLM response persist after the agentic loop completes.',
defaultValue: 25,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.AGENTIC,
isPositiveInteger: true,
sync: {
serverKey: SETTINGS_KEYS.AGENTIC_MAX_TOOL_PREVIEW_LINES,
paramType: SyncableParameterType.NUMBER
}
}
]
},
[SETTINGS_SECTION_SLUGS.DEVELOPER]: {
title: SETTINGS_SECTION_TITLES.DEVELOPER,
slug: SETTINGS_SECTION_SLUGS.DEVELOPER,
icon: Code,
settings: [
{
key: SETTINGS_KEYS.PRE_ENCODE_CONVERSATION,
label: 'Pre-fill KV cache after response',
help: 'After each response, re-submit the conversation to pre-fill the server KV cache. Makes the next turn faster since the prompt is already encoded while you read the response.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DEVELOPER
},
{
key: SETTINGS_KEYS.DISABLE_REASONING_PARSING,
label: 'Disable reasoning content parsing',
help: 'Send reasoning_format=none so the server returns thinking tokens inline instead of extracting them into a separate field.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DEVELOPER
},
{
key: SETTINGS_KEYS.EXCLUDE_REASONING_FROM_CONTEXT,
label: 'Exclude reasoning from context',
help: 'Strip thinking from previous messages before sending. When off, thinking is sent back via the reasoning_content field so the model sees its own chain-of-thought across turns.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DEVELOPER,
sync: {
serverKey: SETTINGS_KEYS.EXCLUDE_REASONING_FROM_CONTEXT,
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH,
label: 'Enable raw output toggle',
help: 'Show toggle button to display messages as plain text instead of Markdown-formatted content',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DEVELOPER,
sync: {
serverKey: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH,
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.CUSTOM,
label: 'Custom JSON',
help: 'Custom JSON parameters to send to the API. Must be valid JSON format.',
defaultValue: '',
type: SettingsFieldType.TEXTAREA,
section: SETTINGS_SECTION_SLUGS.DEVELOPER
}
]
}
} as const;
const NON_UI_SETTINGS: SettingsEntry[] = [
{
key: SETTINGS_KEYS.SHOW_SYSTEM_MESSAGE,
label: 'Show system message',
help: 'Display the system message at the top of each conversation.',
defaultValue: true,
type: SettingsFieldType.CHECKBOX,
sync: { serverKey: SETTINGS_KEYS.SHOW_SYSTEM_MESSAGE, paramType: SyncableParameterType.BOOLEAN }
},
{
key: SETTINGS_KEYS.MCP_SERVERS,
label: 'MCP servers',
help: 'Configure MCP servers as a JSON list. Use the form in the MCP Client settings section to edit.',
defaultValue: '[]',
type: SettingsFieldType.INPUT,
sync: { serverKey: SETTINGS_KEYS.MCP_SERVERS, paramType: SyncableParameterType.STRING }
}
// {
// key: SETTINGS_KEYS.PY_INTERPRETER_ENABLED,
// label: 'Python interpreter enabled',
// help: 'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.',
// defaultValue: false,
// type: SettingsFieldType.CHECKBOX,
// isExperimental: true,
// sync: { serverKey: SETTINGS_KEYS.PY_INTERPRETER_ENABLED, paramType: SyncableParameterType.BOOLEAN }
// }
];
function getAllSettings(): SettingsEntry[] {
const result: SettingsEntry[] = [];
for (const section of Object.values(SETTINGS_REGISTRY)) {
result.push(...section.settings);
}
result.push(...NON_UI_SETTINGS);
return result;
}
/** Flat config object stored in localStorage. */
export const SETTING_CONFIG_DEFAULT: Record<string, SettingsConfigValue> = Object.fromEntries(
getAllSettings().map((s) => [s.key, s.defaultValue])
) as Record<string, SettingsConfigValue>;
/** Help text for every setting (including non-UI). */
export const SETTING_CONFIG_INFO: Record<string, string> = Object.fromEntries(
getAllSettings().map((s) => [s.key, s.help])
) as Record<string, string>;
/** Theme select options. */
export const SETTINGS_COLOR_MODES_CONFIG = COLOR_MODE_OPTIONS;
export type { SettingsSectionTitle } from '$lib/types';
export type { SettingsSection } from '$lib/types';
/** Sidebar sections + field configs (as consumed by UI). */
export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [
...Object.values(SETTINGS_REGISTRY).map((section) => ({
title: section.title,
slug: section.slug,
icon: section.icon,
fields: section.settings.map((s) => ({
key: s.key,
label: s.label,
type: s.type,
isExperimental: s.isExperimental,
help: s.help,
options: s.options
}))
})),
...STANDALONE_SECTIONS
];
/** INPUT-type settings whose value is a number. */
export const NUMERIC_FIELDS = getAllSettings()
.filter((s) => s.type === SettingsFieldType.INPUT && typeof s.defaultValue !== 'string')
.map((s) => s.key) as readonly string[];
/** Numeric fields clamped to ≥ 1 and rounded. */
export const POSITIVE_INTEGER_FIELDS = getAllSettings()
.filter((s) => s.isPositiveInteger)
.map((s) => s.key) as readonly string[];
/** Derived for the parameter sync service. */
export const SYNCABLE_PARAMETERS: SyncableParameter[] = getAllSettings()
.filter((s) => s.sync !== undefined)
.map((s) => ({
key: s.key,
serverKey: s.sync!.serverKey,
type: s.sync!.paramType,
canSync: true
}));
export const SETTINGS_FALLBACK_EXIT_ROUTE = ROUTES.START;
export { SETTINGS_KEYS } from './settings-keys';
@@ -1,348 +0,0 @@
/**
* Settings section titles constants for ChatSettings component.
*
* These titles define the navigation sections in the settings dialog.
* Used for both sidebar navigation and mobile horizontal scroll menu.
*/
export const SETTINGS_SECTION_TITLES = {
GENERAL: 'General',
DISPLAY: 'Display',
SAMPLING: 'Sampling',
PENALTIES: 'Penalties',
AGENTIC: 'Agentic',
TOOLS: 'Tools',
MCP: 'MCP',
IMPORT_EXPORT: 'Import/Export',
DEVELOPER: 'Developer'
} as const;
/** Type for settings section titles */
export type SettingsSectionTitle =
(typeof SETTINGS_SECTION_TITLES)[keyof typeof SETTINGS_SECTION_TITLES];
import {
Funnel,
AlertTriangle,
Code,
Monitor,
ListRestart,
Sliders,
PencilRuler,
Database
} from '@lucide/svelte';
import { SettingsFieldType } from '$lib/enums/settings';
import { SETTINGS_COLOR_MODES_CONFIG } from '$lib/constants/settings-config';
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
import type { Component } from 'svelte';
export interface SettingsSection {
fields?: SettingsFieldConfig[];
icon: Component;
slug: string;
title: SettingsSectionTitle;
}
export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [
{
title: SETTINGS_SECTION_TITLES.GENERAL,
slug: 'general',
icon: Sliders,
fields: [
{
key: SETTINGS_KEYS.THEME,
label: 'Theme',
type: SettingsFieldType.SELECT,
options: SETTINGS_COLOR_MODES_CONFIG
},
{ key: SETTINGS_KEYS.API_KEY, label: 'API Key', type: SettingsFieldType.INPUT },
{
key: SETTINGS_KEYS.SYSTEM_MESSAGE,
label: 'System Message',
type: SettingsFieldType.TEXTAREA
},
{
key: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN,
label: 'Paste long text to file length',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.SEND_ON_ENTER,
label: 'Send message on Enter',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT,
label: 'Copy text attachments as plain text',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION,
label: 'Enable "Continue" button',
type: SettingsFieldType.CHECKBOX,
isExperimental: true
},
{
key: SETTINGS_KEYS.PDF_AS_IMAGE,
label: 'Parse PDF as image',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.ASK_FOR_TITLE_CONFIRMATION,
label: 'Ask for confirmation before changing conversation title',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
label: 'Use first non-empty line for conversation title',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
label: 'Use LLM to generate conversation title',
type: SettingsFieldType.CHECKBOX,
isExperimental: true
},
{
key: SETTINGS_KEYS.TITLE_GENERATION_PROMPT,
type: SettingsFieldType.TEXTAREA,
isExperimental: true
}
]
},
{
title: SETTINGS_SECTION_TITLES.DISPLAY,
slug: 'display',
icon: Monitor,
fields: [
{
key: SETTINGS_KEYS.SHOW_MESSAGE_STATS,
label: 'Show message generation statistics',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS,
label: 'Show thought in progress',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS,
label: 'Show tool call in progress',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.KEEP_STATS_VISIBLE,
label: 'Keep stats visible after generation',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
label: 'Show microphone on empty input',
type: SettingsFieldType.CHECKBOX,
isExperimental: true
},
{
key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
label: 'Render user content as Markdown',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS,
label: 'Use full height code blocks',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.DISABLE_AUTO_SCROLL,
label: 'Disable automatic scroll',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.ALWAYS_SHOW_SIDEBAR_ON_DESKTOP,
label: 'Always show sidebar on desktop',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES,
label: 'Show raw model names',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS,
label: 'Always show agentic turns in conversation',
type: SettingsFieldType.CHECKBOX
}
]
},
{
title: SETTINGS_SECTION_TITLES.SAMPLING,
slug: 'sampling',
icon: Funnel,
fields: [
{
key: SETTINGS_KEYS.TEMPERATURE,
label: 'Temperature',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.DYNATEMP_RANGE,
label: 'Dynamic temperature range',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.DYNATEMP_EXPONENT,
label: 'Dynamic temperature exponent',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.TOP_K,
label: 'Top K',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.TOP_P,
label: 'Top P',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.MIN_P,
label: 'Min P',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.XTC_PROBABILITY,
label: 'XTC probability',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.XTC_THRESHOLD,
label: 'XTC threshold',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.TYP_P,
label: 'Typical P',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.MAX_TOKENS,
label: 'Max tokens',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.SAMPLERS,
label: 'Samplers',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.BACKEND_SAMPLING,
label: 'Backend sampling',
type: SettingsFieldType.CHECKBOX
}
]
},
{
title: SETTINGS_SECTION_TITLES.PENALTIES,
slug: 'penalties',
icon: AlertTriangle,
fields: [
{
key: SETTINGS_KEYS.REPEAT_LAST_N,
label: 'Repeat last N',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.REPEAT_PENALTY,
label: 'Repeat penalty',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.PRESENCE_PENALTY,
label: 'Presence penalty',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.FREQUENCY_PENALTY,
label: 'Frequency penalty',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.DRY_MULTIPLIER,
label: 'DRY multiplier',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.DRY_BASE,
label: 'DRY base',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.DRY_ALLOWED_LENGTH,
label: 'DRY allowed length',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.DRY_PENALTY_LAST_N,
label: 'DRY penalty last N',
type: SettingsFieldType.INPUT
}
]
},
{
title: SETTINGS_SECTION_TITLES.AGENTIC,
slug: 'agentic',
icon: ListRestart,
fields: [
{
key: SETTINGS_KEYS.AGENTIC_MAX_TURNS,
label: 'Agentic turns',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.AGENTIC_MAX_TOOL_PREVIEW_LINES,
label: 'Max lines per tool preview',
type: SettingsFieldType.INPUT
}
]
},
{
title: SETTINGS_SECTION_TITLES.TOOLS,
slug: 'tools',
icon: PencilRuler
},
{
title: SETTINGS_SECTION_TITLES.IMPORT_EXPORT,
slug: 'import-export',
icon: Database
},
{
title: SETTINGS_SECTION_TITLES.DEVELOPER,
slug: 'developer',
icon: Code,
fields: [
{
key: SETTINGS_KEYS.PRE_ENCODE_CONVERSATION,
label: 'Pre-fill KV cache after response',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.DISABLE_REASONING_PARSING,
label: 'Disable reasoning content parsing',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.EXCLUDE_REASONING_FROM_CONTEXT,
label: 'Exclude reasoning from context',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH,
label: 'Enable raw output toggle',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.CUSTOM,
label: 'Custom JSON',
type: SettingsFieldType.TEXTAREA
}
]
}
];
@@ -1,5 +1,5 @@
/* Title generation constants */
export const TITLE = {
export const TITLE_GENERATION = {
MIN_LENGTH: 3,
FALLBACK: 'New Chat',
DEFAULT_PROMPT:
+6 -5
View File
@@ -1,6 +1,7 @@
import { Settings, Search, SquarePen } from '@lucide/svelte';
import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
import type { Component } from 'svelte';
import { ROUTES } from './routes';
export const FORK_TREE_DEPTH_PADDING = 8;
export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
@@ -19,18 +20,18 @@ export interface DesktopIconStripItem {
}
export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
{ icon: SquarePen, tooltip: 'New chat', route: '?new_chat=true#/', keys: ['shift', 'cmd', 'o'] },
{ icon: SquarePen, tooltip: 'New chat', route: ROUTES.NEW_CHAT, keys: ['shift', 'cmd', 'o'] },
{ icon: Search, tooltip: 'Search', keys: ['cmd', 'k'] },
{
icon: McpLogo,
tooltip: 'MCP Servers',
route: '#/settings/mcp',
activeRouteId: '/settings/mcp'
route: ROUTES.MCP_SERVERS,
activeRouteId: '/mcp-servers'
},
{
icon: Settings,
tooltip: 'Settings',
route: '#/settings/chat/general',
activeRoutePrefix: '/settings/chat'
route: ROUTES.SETTINGS,
activeRoutePrefix: '/settings'
}
];
@@ -1,5 +1,6 @@
import { goto } from '$app/navigation';
import { KeyboardKey } from '$lib/enums';
import { ROUTES } from '$lib/constants/routes';
interface KeyboardShortcutsCallbacks {
activateSearchMode?: () => void;
@@ -27,7 +28,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutsCallbacks) {
) {
event.preventDefault();
goto('?new_chat=true#/');
goto(ROUTES.NEW_CHAT);
}
if (event.shiftKey && isCmdOrCtrl && event.key === KeyboardKey.E_UPPER) {
@@ -1,6 +1,7 @@
import { page } from '$app/state';
import { beforeNavigate } from '$app/navigation';
import { settingsReferrer } from '$lib/stores/settings-referrer.svelte';
import { ROUTES } from '$lib/constants/routes';
export interface ChatSettings {
reset: () => void;
@@ -15,11 +16,8 @@ export function useSettingsNavigation() {
const isSettingsRoute = $derived(!!page.route.id?.startsWith('/settings'));
beforeNavigate(({ to, from }) => {
if (
to?.route?.id?.startsWith('/settings/chat') &&
!from?.route?.id?.startsWith('/settings/chat')
) {
settingsReferrer.url = window.location.hash || '#/';
if (to?.route?.id?.startsWith('/settings') && !from?.route?.id?.startsWith('/settings')) {
settingsReferrer.url = window.location.hash || ROUTES.START;
}
});
@@ -513,7 +513,7 @@ export class ChatService {
const serializedToolCalls = JSON.stringify(aggregatedToolCalls);
if (import.meta.env.DEV) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log('[ChatService] Aggregated tool calls:', serializedToolCalls);
}
@@ -260,3 +260,26 @@ export { ParameterSyncService } from './parameter-sync.service';
* @see MCP Protocol Specification: https://modelcontextprotocol.io/specification/2025-06-18
*/
export { MCPService } from './mcp.service';
/**
* **RouterService** — Dynamic route URL construction utility
*
* Stateless utility for building dynamic route URLs from ROUTES base paths.
* Static routes (START, NEW_CHAT, MCP_SERVERS) live in ROUTES constants;
* dynamic routes (CHAT, SETTINGS) are constructed here by appending parameters.
*
* **Architecture & Relationships:**
* - **RouterService** (this class): Stateless URL construction
* - Builds dynamic route URLs from ROUTES base paths
* - No side effects — receives route parameters, returns route strings
*
* - **ROUTES constant** (constants/routes.ts): Static route base paths
* - **All components/stores**: Call RouterService for dynamic route URLs
*
* **Key Responsibilities:**
* - Build chat URLs for specific conversations: `RouterService.chat(id)` → `#/chat/:id`
* - Build settings URLs for sections: `RouterService.settings(section)` → `#/settings/:section`
*
* @see ROUTES in constants/routes.ts — static route base paths
*/
export { RouterService } from './router.service';
@@ -1,253 +1,8 @@
import { normalizeFloatingPoint } from '$lib/utils';
import type { SyncableParameter, ParameterRecord, ParameterInfo, ParameterValue } from '$lib/types';
import { SETTINGS_KEYS, SYNCABLE_PARAMETERS } from '$lib/constants';
import type { ParameterRecord, ParameterInfo, ParameterValue } from '$lib/types';
import { SyncableParameterType, ParameterSource } from '$lib/enums';
/**
* Mapping of webui setting keys to server parameter keys.
* Only parameters listed here can be synced from the server `/props` endpoint.
* Each entry defines the webui key, corresponding server key, value type,
* and whether sync is enabled.
*/
export const SYNCABLE_PARAMETERS: SyncableParameter[] = [
{
key: 'temperature',
serverKey: 'temperature',
type: SyncableParameterType.NUMBER,
canSync: true
},
{ key: 'top_k', serverKey: 'top_k', type: SyncableParameterType.NUMBER, canSync: true },
{ key: 'top_p', serverKey: 'top_p', type: SyncableParameterType.NUMBER, canSync: true },
{ key: 'min_p', serverKey: 'min_p', type: SyncableParameterType.NUMBER, canSync: true },
{
key: 'dynatemp_range',
serverKey: 'dynatemp_range',
type: SyncableParameterType.NUMBER,
canSync: true
},
{
key: 'dynatemp_exponent',
serverKey: 'dynatemp_exponent',
type: SyncableParameterType.NUMBER,
canSync: true
},
{
key: 'xtc_probability',
serverKey: 'xtc_probability',
type: SyncableParameterType.NUMBER,
canSync: true
},
{
key: 'xtc_threshold',
serverKey: 'xtc_threshold',
type: SyncableParameterType.NUMBER,
canSync: true
},
{ key: 'typ_p', serverKey: 'typ_p', type: SyncableParameterType.NUMBER, canSync: true },
{
key: 'repeat_last_n',
serverKey: 'repeat_last_n',
type: SyncableParameterType.NUMBER,
canSync: true
},
{
key: 'repeat_penalty',
serverKey: 'repeat_penalty',
type: SyncableParameterType.NUMBER,
canSync: true
},
{
key: 'presence_penalty',
serverKey: 'presence_penalty',
type: SyncableParameterType.NUMBER,
canSync: true
},
{
key: 'frequency_penalty',
serverKey: 'frequency_penalty',
type: SyncableParameterType.NUMBER,
canSync: true
},
{
key: 'dry_multiplier',
serverKey: 'dry_multiplier',
type: SyncableParameterType.NUMBER,
canSync: true
},
{ key: 'dry_base', serverKey: 'dry_base', type: SyncableParameterType.NUMBER, canSync: true },
{
key: 'dry_allowed_length',
serverKey: 'dry_allowed_length',
type: SyncableParameterType.NUMBER,
canSync: true
},
{
key: 'dry_penalty_last_n',
serverKey: 'dry_penalty_last_n',
type: SyncableParameterType.NUMBER,
canSync: true
},
{ key: 'max_tokens', serverKey: 'max_tokens', type: SyncableParameterType.NUMBER, canSync: true },
{ key: 'samplers', serverKey: 'samplers', type: SyncableParameterType.STRING, canSync: true },
{
key: 'backend_sampling',
serverKey: 'backend_sampling',
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{
key: 'pasteLongTextToFileLen',
serverKey: 'pasteLongTextToFileLen',
type: SyncableParameterType.NUMBER,
canSync: true
},
{
key: 'pdfAsImage',
serverKey: 'pdfAsImage',
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{
key: 'showThoughtInProgress',
serverKey: 'showThoughtInProgress',
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{
key: 'keepStatsVisible',
serverKey: 'keepStatsVisible',
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{
key: 'showMessageStats',
serverKey: 'showMessageStats',
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{
key: 'askForTitleConfirmation',
serverKey: 'askForTitleConfirmation',
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{
key: 'titleGenerationUseFirstLine',
serverKey: 'titleGenerationUseFirstLine',
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{
key: 'disableAutoScroll',
serverKey: 'disableAutoScroll',
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{
key: 'renderUserContentAsMarkdown',
serverKey: 'renderUserContentAsMarkdown',
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{
key: 'autoMicOnEmpty',
serverKey: 'autoMicOnEmpty',
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{
key: 'pyInterpreterEnabled',
serverKey: 'pyInterpreterEnabled',
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{
key: 'enableContinueGeneration',
serverKey: 'enableContinueGeneration',
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{
key: 'fullHeightCodeBlocks',
serverKey: 'fullHeightCodeBlocks',
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{
key: 'systemMessage',
serverKey: 'systemMessage',
type: SyncableParameterType.STRING,
canSync: true
},
{
key: 'showSystemMessage',
serverKey: 'showSystemMessage',
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{ key: 'theme', serverKey: 'theme', type: SyncableParameterType.STRING, canSync: true },
{
key: 'copyTextAttachmentsAsPlainText',
serverKey: 'copyTextAttachmentsAsPlainText',
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{
key: 'showRawOutputSwitch',
serverKey: 'showRawOutputSwitch',
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{
key: 'alwaysShowSidebarOnDesktop',
serverKey: 'alwaysShowSidebarOnDesktop',
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{
key: 'showRawModelNames',
serverKey: 'showRawModelNames',
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{ key: 'mcpServers', serverKey: 'mcpServers', type: SyncableParameterType.STRING, canSync: true },
{
key: 'agenticMaxTurns',
serverKey: 'agenticMaxTurns',
type: SyncableParameterType.NUMBER,
canSync: true
},
{
key: 'agenticMaxToolPreviewLines',
serverKey: 'agenticMaxToolPreviewLines',
type: SyncableParameterType.NUMBER,
canSync: true
},
{
key: 'showToolCallInProgress',
serverKey: 'showToolCallInProgress',
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{
key: 'alwaysShowAgenticTurns',
serverKey: 'alwaysShowAgenticTurns',
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{
key: 'excludeReasoningFromContext',
serverKey: 'excludeReasoningFromContext',
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{
key: 'sendOnEnter',
serverKey: 'sendOnEnter',
type: SyncableParameterType.BOOLEAN,
canSync: true
}
];
export class ParameterSyncService {
/**
*
@@ -298,7 +53,7 @@ export class ParameterSyncService {
// Handle samplers array conversion to string
if (serverParams.samplers && Array.isArray(serverParams.samplers)) {
extracted.samplers = serverParams.samplers.join(';');
extracted[SETTINGS_KEYS.SAMPLERS] = serverParams.samplers.join(';');
}
}
@@ -0,0 +1,11 @@
import { ROUTES } from '$lib/constants/routes';
export class RouterService {
static chat(id: string): string {
return `${ROUTES.CHAT}/${id}`;
}
static settings(section: string): string {
return `${ROUTES.SETTINGS}/${section}`;
}
}
@@ -413,8 +413,6 @@ class AgenticStore {
const tools = toolsStore.getEnabledToolsForLLM();
if (tools.length === 0) {
console.log('[AgenticStore] No tools available, falling back to standard chat');
return { handled: false };
}
@@ -37,7 +37,7 @@ import {
MAX_INACTIVE_CONVERSATION_STATES,
INACTIVE_CONVERSATION_STATE_MAX_AGE_MS,
SYSTEM_MESSAGE_PLACEHOLDER,
TITLE
TITLE_GENERATION
} from '$lib/constants';
import type {
ChatMessageTimings,
@@ -265,7 +265,7 @@ class ChatStore {
}
private isChatLoadingInternal(convId: string): boolean {
return this.chatStreamingStates.has(convId);
return this.chatLoadingStates.has(convId) || this.chatStreamingStates.has(convId);
}
hasPendingMessage(convId: string): boolean {
@@ -950,7 +950,7 @@ class ChatStore {
typeof configValue.titleGenerationPrompt === 'string' &&
configValue.titleGenerationPrompt.trim()
? configValue.titleGenerationPrompt
: TITLE.DEFAULT_PROMPT;
: TITLE_GENERATION.DEFAULT_PROMPT;
const titlePrompt = titlePromptTemplate
.replace('{{USER}}', String(userContent || ''))
@@ -969,14 +969,14 @@ class ChatStore {
let cleanTitle = titleResponse.trim();
cleanTitle = cleanTitle
.replace(TITLE.PREFIX_PATTERN, '')
.replace(TITLE.QUOTE_PATTERN, '')
.replace(TITLE_GENERATION.PREFIX_PATTERN, '')
.replace(TITLE_GENERATION.QUOTE_PATTERN, '')
.trim();
if (!cleanTitle || cleanTitle.length < TITLE.MIN_LENGTH) {
if (!cleanTitle || cleanTitle.length < TITLE_GENERATION.MIN_LENGTH) {
const firstLine = userContent.split('\n').find((l) => l.trim().length > 0);
cleanTitle = firstLine ? firstLine.trim() : TITLE.FALLBACK;
cleanTitle = firstLine ? firstLine.trim() : TITLE_GENERATION.FALLBACK;
}
if (cleanTitle && cleanTitle.length >= TITLE.MIN_LENGTH) {
if (cleanTitle && cleanTitle.length >= TITLE_GENERATION.MIN_LENGTH) {
await conversationsStore.updateConversationName(convId, cleanTitle);
}
}
@@ -44,6 +44,8 @@ import {
MULTIPLE_UNDERSCORE_REGEX,
MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY
} from '$lib/constants';
import { ROUTES } from '$lib/constants/routes';
import { RouterService } from '$lib/services/router.service';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
export interface ConversationTreeItem {
@@ -260,7 +262,7 @@ class ConversationsStore {
this.activeConversation = conversation;
this.activeMessages = [];
await goto(`#/chat/${conversation.id}`);
await goto(RouterService.chat(conversation.id));
return conversation.id;
}
@@ -336,7 +338,7 @@ class ConversationsStore {
if (this.activeConversation && idsToRemove.has(this.activeConversation.id)) {
this.clearActiveConversation();
await goto(`?new_chat=true#/`);
await goto(ROUTES.NEW_CHAT);
}
} else {
// Reparent direct children to deleted conv's parent (or promote to top-level)
@@ -352,7 +354,7 @@ class ConversationsStore {
if (this.activeConversation?.id === convId) {
this.clearActiveConversation();
await goto(`?new_chat=true#/`);
await goto(ROUTES.NEW_CHAT);
}
}
} catch (error) {
@@ -376,7 +378,7 @@ class ConversationsStore {
toast.success('All conversations deleted');
await goto(`?new_chat=true#/`);
await goto(ROUTES.NEW_CHAT);
} catch (error) {
console.error('Failed to delete all conversations:', error);
toast.error('Failed to delete conversations');
@@ -729,7 +731,7 @@ class ConversationsStore {
this.conversations = [newConv, ...this.conversations];
await goto(`#/chat/${newConv.id}`);
await goto(RouterService.chat(newConv.id));
toast.success('Conversation forked');
@@ -21,6 +21,7 @@
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';
@@ -556,13 +557,13 @@ class MCPStore {
requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
useProxy: serverData.useProxy
};
settingsStore.updateConfig('mcpServers', JSON.stringify([...servers, newServer]));
settingsStore.updateConfig(SETTINGS_KEYS.MCP_SERVERS, JSON.stringify([...servers, newServer]));
}
updateServer(id: string, updates: Partial<MCPServerSettingsEntry>): void {
const servers = this.getServers();
settingsStore.updateConfig(
'mcpServers',
SETTINGS_KEYS.MCP_SERVERS,
JSON.stringify(
servers.map((server) => (server.id === id ? { ...server, ...updates } : server))
)
@@ -571,7 +572,10 @@ class MCPStore {
removeServer(id: string): void {
const servers = this.getServers();
settingsStore.updateConfig('mcpServers', JSON.stringify(servers.filter((s) => s.id !== id)));
settingsStore.updateConfig(
SETTINGS_KEYS.MCP_SERVERS,
JSON.stringify(servers.filter((s) => s.id !== id))
);
this.clearHealthCheck(id);
}
@@ -10,6 +10,7 @@ import {
MODEL_PROPS_CACHE_MAX_ENTRIES,
FAVORITE_MODELS_LOCALSTORAGE_KEY
} from '$lib/constants';
import { conversationsStore } from '$lib/stores/conversations.svelte';
/**
* modelsStore - Reactive store for model management in both MODEL and ROUTER modes
@@ -424,6 +425,103 @@ class ModelsStore {
}
}
/**
* 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;
}
}
return null;
}
/**
* 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;
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;
}
try {
await this.selectModelById(matchingModel.id);
console.log(`[modelsStore] Automatically selected model: ${lastModel} from last message`);
return true;
} catch (error) {
console.warn('[modelsStore] Failed to automatically select model from last message:', error);
return false;
}
}
/**
* Auto-selects the first available model if none is selected, and fetches its props.
* 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 webui
const availableModels = this.models.filter((option) => {
const modelProps = this.getModelProps(option.model);
return modelProps?.webui !== false;
});
if (availableModels.length === 0) return;
// Try to select model from last assistant response first
const lastModel = this.getModelFromLastAssistantResponse();
if (lastModel) {
const lastModelOption = availableModels.find((m) => m.model === lastModel);
if (lastModelOption) {
await this.selectModelById(lastModelOption.id);
if (this.isModelLoaded(lastModel)) {
await this.fetchModelProps(lastModel);
}
return;
}
}
// Try to find a loaded model first
const loadedModel = availableModels.find((m) => this.isModelLoaded(m.model));
if (loadedModel) {
await this.selectModelById(loadedModel.id);
await this.fetchModelProps(loadedModel.model);
return;
}
// 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
@@ -1,4 +1,6 @@
let _url = $state('#/');
import { SETTINGS_FALLBACK_EXIT_ROUTE } from '$lib/constants';
let _url = $state<string>(SETTINGS_FALLBACK_EXIT_ROUTE);
export const settingsReferrer = {
get url() {
@@ -32,9 +32,13 @@
*/
import { browser } from '$app/environment';
import { ColorMode } from '$lib/enums';
import type { SettingsExportType } from '$lib/types';
import { setMode } from 'mode-watcher';
import {
CONFIG_LOCALSTORAGE_KEY,
SETTING_CONFIG_DEFAULT,
SETTINGS_KEYS,
USER_OVERRIDES_LOCALSTORAGE_KEY
} from '$lib/constants';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
@@ -57,7 +61,6 @@ class SettingsStore {
*/
config = $state<SettingsConfigType>({ ...SETTING_CONFIG_DEFAULT });
theme = $state<string>('auto');
isInitialized = $state(false);
userOverrides = $state<Set<string>>(new Set());
@@ -99,7 +102,9 @@ class SettingsStore {
initialize() {
try {
this.loadConfig();
this.loadTheme();
this.migrateLegacyTheme();
// Apply the persisted theme from config on initial load
setMode(this.config[SETTINGS_KEYS.THEME] as ColorMode);
this.isInitialized = true;
} catch (error) {
console.error('Failed to initialize settings store:', error);
@@ -124,9 +129,9 @@ class SettingsStore {
};
// Default sendOnEnter to false on mobile when the user has no saved preference
if (!('sendOnEnter' in savedVal)) {
if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
if (new IsMobile().current) {
this.config.sendOnEnter = false;
this.config[SETTINGS_KEYS.SEND_ON_ENTER] = false;
}
}
@@ -143,12 +148,21 @@ class SettingsStore {
}
/**
* Load theme from localStorage
* Migrate the legacy un-namespaced "theme" localStorage key into config.
* Previously theme was stored separately in localStorage("theme") — now it lives
* inside the config object alongside all other settings.
* After migration the legacy key is removed.
*/
private loadTheme() {
private migrateLegacyTheme() {
if (!browser) return;
this.theme = localStorage.getItem('theme') || 'auto';
const legacyTheme = localStorage.getItem('theme');
if (legacyTheme) {
this.config[SETTINGS_KEYS.THEME] = legacyTheme;
localStorage.removeItem('theme');
this.saveConfig();
setMode(legacyTheme as ColorMode);
}
}
/**
*
@@ -233,29 +247,13 @@ class SettingsStore {
}
/**
* Update the theme setting
* Update the theme setting.
* @param newTheme - The new theme value
*/
updateTheme(newTheme: string) {
this.theme = newTheme;
this.saveTheme();
}
this.updateConfig(SETTINGS_KEYS.THEME, newTheme);
/**
* Save the current theme to localStorage
*/
private saveTheme() {
if (!browser) return;
try {
if (this.theme === 'auto') {
localStorage.removeItem('theme');
} else {
localStorage.setItem('theme', this.theme);
}
} catch (error) {
console.error('Failed to save theme to localStorage:', error);
}
setMode(newTheme as ColorMode);
}
/**
@@ -271,22 +269,26 @@ class SettingsStore {
*/
resetConfig() {
this.config = { ...SETTING_CONFIG_DEFAULT };
this.saveConfig();
}
/**
* Reset theme to auto
* Reset theme to default value.
* Theme is now stored inside the config object.
*/
resetTheme() {
this.theme = 'auto';
this.saveTheme();
this.updateConfig(SETTINGS_KEYS.THEME, SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.THEME]);
setMode(SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.THEME] as ColorMode);
}
/**
* Reset all settings to defaults
* Reset all settings to defaults.
*/
resetAll() {
this.resetConfig();
this.resetTheme();
}
@@ -456,10 +458,86 @@ class SettingsStore {
this.saveConfig();
console.log('Cleared all user overrides');
}
/**
*
*
* Import / Export
*
*
*/
/**
* Export all settings as a versioned JSON-compatible object.
* The export captures the full config (excluding sensitive values like API key)
* and user overrides. Sensitive fields are filtered out for security by default.
* @param includeSensitiveData - If true, include sensitive fields (apiKey, MCP server headers) in export
*/
exportSettings(includeSensitiveData: boolean = false): SettingsExportType {
// Build config excluding sensitive data unless user opts in
const configToExport: Record<string, string | number | boolean | undefined> =
includeSensitiveData
? { ...this.config }
: Object.fromEntries(Object.entries(this.config).filter(([key]) => key !== 'apiKey'));
// Handle MCP servers: exclude custom headers unless user opts in
if ('mcpServers' in configToExport && !includeSensitiveData) {
try {
const mcpServers = JSON.parse(configToExport.mcpServers as string) as Array<
Record<string, unknown>
>;
const safeServers = mcpServers.map((server) => {
delete server.headers;
return server;
});
configToExport.mcpServers = JSON.stringify(safeServers);
} catch {
// If parsing fails, just exclude the entire mcpServers field
delete (configToExport as Record<string, unknown>).mcpServers;
}
}
return {
version: 1,
timestamp: Date.now(),
config: configToExport,
userOverrides: Array.from(this.userOverrides)
};
}
/**
* Import settings from a previously exported object.
* Restores config (including theme) and user overrides.
* @param data - The exported settings object
*/
importSettings(data: SettingsExportType): void {
if (!browser) return;
if (!data || !data.config) {
throw new Error('Invalid settings data: missing config');
}
// Restore config (theme is included in config)
this.config = {
...SETTING_CONFIG_DEFAULT,
...data.config
};
// Restore user overrides (derived state — may be stale if server defaults differ)
this.userOverrides = new Set(data.userOverrides ?? []);
// Persist to localStorage
this.saveConfig();
// Apply theme for immediate visual feedback
setMode(this.config[SETTINGS_KEYS.THEME] as ColorMode);
console.log('Settings imported successfully');
}
}
export const settingsStore = new SettingsStore();
export const config = () => settingsStore.config;
export const theme = () => settingsStore.theme;
export const theme = () => settingsStore.config[SETTINGS_KEYS.THEME];
export const isInitialized = () => settingsStore.isInitialized;
+6 -1
View File
@@ -76,10 +76,15 @@ export type {
SettingsFieldConfig,
SettingsChatServiceOptions,
SettingsConfigType,
SettingsExportType,
ParameterValue,
ParameterRecord,
ParameterInfo,
SyncableParameter
SyncableParameter,
SettingsEntry,
SettingsSectionTitle,
SettingsSectionEntry,
SettingsSection
} from './settings';
// Common types
+54 -1
View File
@@ -1,12 +1,42 @@
import type { SETTING_CONFIG_DEFAULT } from '$lib/constants';
import type { SETTING_CONFIG_DEFAULT, SETTINGS_SECTION_TITLES } from '$lib/constants';
import type { ChatMessagePromptProgress, ChatMessageTimings } from './chat';
import type { OpenAIToolDefinition } from './mcp';
import type { DatabaseMessageExtra } from './database';
import type { ParameterSource, SyncableParameterType, SettingsFieldType } from '$lib/enums';
import type { Icon } from '@lucide/svelte';
import type { Component } from 'svelte';
export type SettingsConfigValue = string | number | boolean | undefined;
/** Section title type derived from registry section titles. */
export type SettingsSectionTitle =
(typeof SETTINGS_SECTION_TITLES)[keyof typeof SETTINGS_SECTION_TITLES];
/** Per-setting metadata — one entry per setting. */
export interface SettingsEntry {
key: string;
label: string;
help: string;
defaultValue: SettingsConfigValue;
type: SettingsFieldType;
section?: string;
options?: Array<{ value: string; label: string; icon: Component }>;
isExperimental?: boolean;
isPositiveInteger?: boolean;
sync?: {
serverKey: string;
paramType: SyncableParameterType;
};
}
/** A settings section with its icon, slug, title, and ordered settings. */
export interface SettingsSectionEntry {
title: SettingsSectionTitle;
slug: string;
icon: Component;
settings: SettingsEntry[];
}
export interface SettingsFieldConfig {
key: string;
label: string;
@@ -16,6 +46,14 @@ export interface SettingsFieldConfig {
options?: Array<{ value: string; label: string; icon?: typeof Icon }>;
}
/** Re-exported for backward compatibility. */
export interface SettingsSection {
fields?: SettingsFieldConfig[];
icon: Component;
slug: string;
title: SettingsSectionTitle;
}
export interface SettingsChatServiceOptions {
stream?: boolean;
// Model (required in ROUTER mode, optional in MODEL mode)
@@ -94,3 +132,18 @@ export interface SyncableParameter {
type: SyncableParameterType;
canSync: boolean;
}
/**
* Shape of the settings JSON export file.
* Versioned to allow future schema evolution.
*/
export interface SettingsExportType {
/** Export format version — bumped on breaking changes */
version: number;
/** Unix timestamp of export */
timestamp: number;
/** Full settings config (includes theme as a config key) */
config: SettingsConfigType;
/** Keys that differ from server defaults (derived, but persisted for fidelity) */
userOverrides: string[];
}
@@ -2,6 +2,7 @@ import { convertPDFToImage, convertPDFToText } from './pdf-processing';
import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
import { FileTypeCategory, AttachmentType, SpecialFileType } from '$lib/enums';
import { SETTINGS_KEYS } from '$lib/constants';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { getFileTypeCategory } from '$lib/utils';
@@ -106,7 +107,7 @@ export async function parseFilesToMessageExtras(
console.log('Non-vision model detected: forcing PDF-to-text mode and updating settings');
// Update the setting in localStorage
settingsStore.updateConfig('pdfAsImage', false);
settingsStore.updateConfig(SETTINGS_KEYS.PDF_AS_IMAGE, false);
// Show toast notification to user
toast.warning(
@@ -1,6 +1,7 @@
import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
import { FileTypeCategory } from '$lib/enums';
import { SETTINGS_KEYS } from '$lib/constants';
import { modelsStore } from '$lib/stores/models.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { toast } from 'svelte-sonner';
@@ -104,7 +105,7 @@ export async function processFilesToChatUploaded(
action: {
label: 'Enable PDF as Images',
onClick: () => {
settingsStore.updateConfig('pdfAsImage', true);
settingsStore.updateConfig(SETTINGS_KEYS.PDF_AS_IMAGE, true);
toast.success('PDF parsing as images enabled!', {
duration: 3000
});
@@ -7,11 +7,11 @@
import { onMount } from 'svelte';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import { APP_NAME } from '$lib/constants';
import { APP_NAME, NEW_CHAT_PARAM } from '$lib/constants';
let qParam = $derived(page.url.searchParams.get('q'));
let modelParam = $derived(page.url.searchParams.get('model'));
let newChatParam = $derived(page.url.searchParams.get('new_chat'));
let newChatParam = $derived(page.url.searchParams.get(NEW_CHAT_PARAM));
// Dialog state for model not available error
let showModelNotAvailable = $state(false);
@@ -26,7 +26,7 @@
url.searchParams.delete('q');
url.searchParams.delete('model');
url.searchParams.delete('new_chat');
url.searchParams.delete(NEW_CHAT_PARAM);
replaceState(url.toString(), {});
}
@@ -3,13 +3,10 @@
import { page } from '$app/state';
import { afterNavigate } from '$app/navigation';
import { DialogModelNotAvailable } from '$lib/components/app';
import { ROUTES } from '$lib/constants/routes';
import { chatStore, isLoading } from '$lib/stores/chat.svelte';
import {
conversationsStore,
activeConversation,
activeMessages
} from '$lib/stores/conversations.svelte';
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
import { modelsStore, modelOptions } from '$lib/stores/models.svelte';
let chatId = $derived(page.params.id);
let currentChatId: string | undefined = undefined;
@@ -73,47 +70,9 @@
urlParamsProcessed = true;
}
async function selectModelFromLastAssistantResponse() {
const messages = activeMessages();
if (messages.length === 0) return;
let lastMessageWithModel: DatabaseMessage | undefined;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].model) {
lastMessageWithModel = messages[i];
break;
}
}
if (!lastMessageWithModel) return;
const currentModelId = selectedModelId();
const currentModelName = modelOptions().find((m) => m.id === currentModelId)?.model;
if (currentModelName === lastMessageWithModel.model) {
return;
}
const matchingModel = modelOptions().find(
(option) => option.model === lastMessageWithModel.model
);
if (matchingModel && modelsStore.isModelLoaded(matchingModel.model)) {
try {
await modelsStore.selectModelById(matchingModel.id);
console.log(
`Automatically selected model: ${lastMessageWithModel.model} from last message`
);
} catch (error) {
console.warn('Failed to automatically select model from last message:', error);
}
}
}
afterNavigate(() => {
setTimeout(() => {
selectModelFromLastAssistantResponse();
void modelsStore.selectModelFromLastAssistantResponse();
}, 100);
});
@@ -141,7 +100,7 @@
await handleUrlParams();
}
} else {
await goto('#/');
await goto(ROUTES.START);
}
})();
}
+3 -2
View File
@@ -2,6 +2,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { ServerErrorSplash } from '$lib/components/app';
import { ROUTES } from '$lib/constants/routes';
let error = $derived($page.error);
let status = $derived($page.status);
@@ -17,7 +18,7 @@
function handleRetry() {
// Navigate back to home page after successful API key validation
goto('#/');
goto(ROUTES.START);
}
</script>
@@ -60,7 +61,7 @@
</p>
</div>
<button
onclick={() => goto('#/')}
onclick={() => goto(ROUTES.START)}
class="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
Go Home
+5 -3
View File
@@ -18,6 +18,8 @@
import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { ModeWatcher } from 'mode-watcher';
import { ROUTES } from '$lib/constants/routes';
import { RouterService } from '$lib/services/router.service';
import { Toaster } from 'svelte-sonner';
import { modelsStore } from '$lib/stores/models.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
@@ -53,7 +55,7 @@
const currentId = page.params.id;
if (!currentId) {
goto(`#/chat/${allConvs[direction === 1 ? 0 : allConvs.length - 1].id}`);
goto(RouterService.chat(allConvs[direction === 1 ? 0 : allConvs.length - 1].id));
return;
}
@@ -64,9 +66,9 @@
const targetIdx = idx + direction;
if (targetIdx >= 0 && targetIdx < allConvs.length) {
goto(`#/chat/${allConvs[targetIdx].id}`);
goto(RouterService.chat(allConvs[targetIdx].id));
} else {
goto('?new_chat=true#/');
goto(ROUTES.NEW_CHAT);
}
}
@@ -4,6 +4,7 @@
import { browser } from '$app/environment';
import { page } from '$app/state';
import { ActionIcon } from '$lib/components/app';
import { SETTINGS_FALLBACK_EXIT_ROUTE } from '$lib/constants';
let { children } = $props();
@@ -21,7 +22,7 @@
if (browser && window.history.length > 1 && !prevIsSettings) {
history.back();
} else {
goto('#/');
goto(SETTINGS_FALLBACK_EXIT_ROUTE);
}
}
</script>
@@ -0,0 +1,16 @@
<script lang="ts">
import { SettingsChat } from '$lib/components/app/settings';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import { RouterService } from '$lib/services';
import { SETTINGS_SECTION_SLUGS } from '$lib/constants';
import { onMount } from 'svelte';
onMount(() => {
if (!page.params.section) {
replaceState(RouterService.settings(SETTINGS_SECTION_SLUGS.GENERAL), {});
}
});
</script>
<SettingsChat initialSection={(page.params as Record<string, string | undefined>).section} />
@@ -1,6 +0,0 @@
<script lang="ts">
import { SettingsChat } from '$lib/components/app/settings';
import { page } from '$app/state';
</script>
<SettingsChat initialSection={(page.params as Record<string, string | undefined>).section} />
+1
View File
@@ -96,6 +96,7 @@ export default defineConfig({
'/props': 'http://localhost:8080',
'/models': 'http://localhost:8080',
'/tools': 'http://localhost:8080',
'/slots': 'http://localhost:8080',
'/cors-proxy': 'http://localhost:8080'
},
headers: {