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:
committed by
GitHub
parent
a8fd165fec
commit
9b2925e1e0
File diff suppressed because one or more lines are too long
+3538
-3507
File diff suppressed because it is too large
Load Diff
Generated
+18
-18
@@ -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",
|
||||
|
||||
+2
-1
@@ -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>
|
||||
|
||||
|
||||
+7
-2
@@ -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>
|
||||
|
||||
+2
-1
@@ -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}
|
||||
|
||||
+4
-3
@@ -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;
|
||||
}
|
||||
|
||||
+1
-6
@@ -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
|
||||
|
||||
+4
-2
@@ -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>
|
||||
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+29
-9
@@ -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>
|
||||
|
||||
+20
-13
@@ -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;
|
||||
|
||||
+62
@@ -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>
|
||||
+138
-93
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user