mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2025-10-27 08:21:30 +00:00
feat: Improve mobile UI for Settings Dialog (#16084)
* feat: Improve mobile UI for Settings Dialog * chore: update webui build output * fix: Linting errors * chore: update webui build output
This commit is contained in:
committed by
GitHub
parent
4b8560ab56
commit
4067f07fc5
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Script to install pre-commit and post-commit hooks for webui
|
# Script to install pre-commit and post-commit hooks for webui
|
||||||
# Pre-commit: formats code and builds, stashes unstaged changes
|
# Pre-commit: formats, lints, checks, and builds code, stashes unstaged changes
|
||||||
# Post-commit: automatically unstashes changes
|
# Post-commit: automatically unstashes changes
|
||||||
|
|
||||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||||
@@ -44,6 +44,18 @@ if git diff --cached --name-only | grep -q "^tools/server/webui/"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Run the lint command
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
# Check if lint command succeeded
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Error: npm run lint failed"
|
||||||
|
if [ $STASH_CREATED -eq 0 ]; then
|
||||||
|
echo "You can restore your unstaged changes with: git stash pop"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Run the check command
|
# Run the check command
|
||||||
npm run check
|
npm run check
|
||||||
|
|
||||||
@@ -112,7 +124,7 @@ if [ $? -eq 0 ]; then
|
|||||||
echo " Post-commit: $POST_COMMIT_HOOK"
|
echo " Post-commit: $POST_COMMIT_HOOK"
|
||||||
echo ""
|
echo ""
|
||||||
echo "The hooks will automatically:"
|
echo "The hooks will automatically:"
|
||||||
echo " • Format and build webui code before commits"
|
echo " • Format, lint, check, and build webui code before commits"
|
||||||
echo " • Stash unstaged changes during the process"
|
echo " • Stash unstaged changes during the process"
|
||||||
echo " • Restore your unstaged changes after the commit"
|
echo " • Restore your unstaged changes after the commit"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -121,3 +121,15 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.scrollbar-hide {
|
||||||
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
/* Hide scrollbar for IE, Edge and Firefox */
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Settings, Funnel, AlertTriangle, Brain, Cog, Monitor, Sun, Moon } from '@lucide/svelte';
|
import {
|
||||||
import { ChatSettingsFooter, ChatSettingsSection } from '$lib/components/app';
|
Settings,
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
Funnel,
|
||||||
|
AlertTriangle,
|
||||||
|
Brain,
|
||||||
|
Cog,
|
||||||
|
Monitor,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight
|
||||||
|
} from '@lucide/svelte';
|
||||||
|
import { ChatSettingsFooter, ChatSettingsFields } from '$lib/components/app';
|
||||||
import * as Dialog from '$lib/components/ui/dialog';
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
import { Input } from '$lib/components/ui/input';
|
|
||||||
import Label from '$lib/components/ui/label/label.svelte';
|
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area';
|
import { ScrollArea } from '$lib/components/ui/scroll-area';
|
||||||
import * as Select from '$lib/components/ui/select';
|
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
|
||||||
import { Textarea } from '$lib/components/ui/textarea';
|
|
||||||
import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO } from '$lib/constants/settings-config';
|
|
||||||
import { supportsVision } from '$lib/stores/server.svelte';
|
|
||||||
import { config, updateMultipleConfig, resetConfig } from '$lib/stores/settings.svelte';
|
import { config, updateMultipleConfig, resetConfig } from '$lib/stores/settings.svelte';
|
||||||
import { setMode } from 'mode-watcher';
|
import { setMode } from 'mode-watcher';
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte';
|
||||||
@@ -224,12 +229,20 @@
|
|||||||
let localConfig: SettingsConfigType = $state({ ...config() });
|
let localConfig: SettingsConfigType = $state({ ...config() });
|
||||||
let originalTheme: string = $state('');
|
let originalTheme: string = $state('');
|
||||||
|
|
||||||
|
let canScrollLeft = $state(false);
|
||||||
|
let canScrollRight = $state(false);
|
||||||
|
let scrollContainer: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
function handleThemeChange(newTheme: string) {
|
function handleThemeChange(newTheme: string) {
|
||||||
localConfig.theme = newTheme;
|
localConfig.theme = newTheme;
|
||||||
|
|
||||||
setMode(newTheme as 'light' | 'dark' | 'system');
|
setMode(newTheme as 'light' | 'dark' | 'system');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleConfigChange(key: string, value: string | boolean) {
|
||||||
|
localConfig[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
if (localConfig.theme !== originalTheme) {
|
if (localConfig.theme !== originalTheme) {
|
||||||
setMode(originalTheme as 'light' | 'dark' | 'system');
|
setMode(originalTheme as 'light' | 'dark' | 'system');
|
||||||
@@ -298,18 +311,63 @@
|
|||||||
onOpenChange?.(false);
|
onOpenChange?.(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scrollToCenter(element: HTMLElement) {
|
||||||
|
if (!scrollContainer) return;
|
||||||
|
|
||||||
|
const containerRect = scrollContainer.getBoundingClientRect();
|
||||||
|
const elementRect = element.getBoundingClientRect();
|
||||||
|
|
||||||
|
const elementCenter = elementRect.left + elementRect.width / 2;
|
||||||
|
const containerCenter = containerRect.left + containerRect.width / 2;
|
||||||
|
const scrollOffset = elementCenter - containerCenter;
|
||||||
|
|
||||||
|
scrollContainer.scrollBy({ left: scrollOffset, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollLeft() {
|
||||||
|
if (!scrollContainer) return;
|
||||||
|
|
||||||
|
scrollContainer.scrollBy({ left: -250, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollRight() {
|
||||||
|
if (!scrollContainer) return;
|
||||||
|
|
||||||
|
scrollContainer.scrollBy({ left: 250, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScrollButtons() {
|
||||||
|
if (!scrollContainer) return;
|
||||||
|
|
||||||
|
const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
|
||||||
|
canScrollLeft = scrollLeft > 0;
|
||||||
|
canScrollRight = scrollLeft < scrollWidth - clientWidth - 1; // -1 for rounding
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
localConfig = { ...config() };
|
localConfig = { ...config() };
|
||||||
originalTheme = config().theme as string;
|
originalTheme = config().theme as string;
|
||||||
|
|
||||||
|
setTimeout(updateScrollButtons, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (scrollContainer) {
|
||||||
|
updateScrollButtons();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root {open} onOpenChange={handleClose}>
|
<Dialog.Root {open} onOpenChange={handleClose}>
|
||||||
<Dialog.Content class="flex h-[64vh] flex-col gap-0 p-0" style="max-width: 48rem;">
|
<Dialog.Content
|
||||||
<div class="flex flex-1 overflow-hidden">
|
class="z-999999 flex h-[100vh] flex-col gap-0 rounded-none p-0 md:h-[64vh] md:rounded-lg"
|
||||||
<div class="w-64 border-r border-border/30 p-6">
|
style="max-width: 48rem;"
|
||||||
|
>
|
||||||
|
<div class="flex flex-1 flex-col overflow-hidden md:flex-row">
|
||||||
|
<!-- Desktop Sidebar -->
|
||||||
|
<div class="hidden w-64 border-r border-border/30 p-6 md:block">
|
||||||
<nav class="space-y-1 py-2">
|
<nav class="space-y-1 py-2">
|
||||||
<Dialog.Title class="mb-6 flex items-center gap-2">Settings</Dialog.Title>
|
<Dialog.Title class="mb-6 flex items-center gap-2">Settings</Dialog.Title>
|
||||||
|
|
||||||
@@ -329,134 +387,79 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea class="flex-1">
|
<!-- Mobile Header with Horizontal Scrollable Menu -->
|
||||||
<div class="space-y-6 p-6">
|
<div class="flex flex-col md:hidden">
|
||||||
<ChatSettingsSection title={currentSection.title} Icon={currentSection.icon}>
|
<div class="border-b border-border/30 py-4">
|
||||||
{#each currentSection.fields as field (field.key)}
|
<Dialog.Title class="mb-6 flex items-center gap-2 px-4">Settings</Dialog.Title>
|
||||||
<div class="space-y-2">
|
|
||||||
{#if field.type === 'input'}
|
|
||||||
<Label for={field.key} class="block text-sm font-medium">
|
|
||||||
{field.label}
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Input
|
<!-- Horizontal Scrollable Category Menu with Navigation -->
|
||||||
id={field.key}
|
<div class="relative flex items-center" style="scroll-padding: 1rem;">
|
||||||
value={String(localConfig[field.key] || '')}
|
<button
|
||||||
onchange={(e) => (localConfig[field.key] = e.currentTarget.value)}
|
class="absolute left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollLeft
|
||||||
placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] || 'none'}`}
|
? 'opacity-100'
|
||||||
class="max-w-md"
|
: 'pointer-events-none opacity-0'}"
|
||||||
/>
|
onclick={scrollLeft}
|
||||||
{#if field.help || SETTING_CONFIG_INFO[field.key]}
|
aria-label="Scroll left"
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
>
|
||||||
{field.help || SETTING_CONFIG_INFO[field.key]}
|
<ChevronLeft class="h-4 w-4" />
|
||||||
</p>
|
</button>
|
||||||
{/if}
|
|
||||||
{:else if field.type === 'textarea'}
|
|
||||||
<Label for={field.key} class="block text-sm font-medium">
|
|
||||||
{field.label}
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Textarea
|
<div
|
||||||
id={field.key}
|
class="scrollbar-hide overflow-x-auto py-2"
|
||||||
value={String(localConfig[field.key] || '')}
|
bind:this={scrollContainer}
|
||||||
onchange={(e) => (localConfig[field.key] = e.currentTarget.value)}
|
onscroll={updateScrollButtons}
|
||||||
placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] || 'none'}`}
|
>
|
||||||
class="min-h-[100px] max-w-2xl"
|
<div class="flex min-w-max gap-2">
|
||||||
/>
|
{#each settingSections as section (section.title)}
|
||||||
{#if field.help || SETTING_CONFIG_INFO[field.key]}
|
<button
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {activeSection ===
|
||||||
{field.help || SETTING_CONFIG_INFO[field.key]}
|
section.title
|
||||||
</p>
|
? 'bg-accent text-accent-foreground'
|
||||||
{/if}
|
: 'text-muted-foreground'}"
|
||||||
{:else if field.type === 'select'}
|
onclick={(e: MouseEvent) => {
|
||||||
{@const selectedOption = field.options?.find(
|
activeSection = section.title;
|
||||||
(opt: { value: string; label: string; icon?: Component }) =>
|
scrollToCenter(e.currentTarget as HTMLElement);
|
||||||
opt.value === localConfig[field.key]
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Label for={field.key} class="block text-sm font-medium">
|
|
||||||
{field.label}
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Select.Root
|
|
||||||
type="single"
|
|
||||||
value={localConfig[field.key]}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (field.key === 'theme' && value) {
|
|
||||||
handleThemeChange(value);
|
|
||||||
} else {
|
|
||||||
localConfig[field.key] = value;
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Select.Trigger class="max-w-md">
|
<section.icon class="h-4 w-4 flex-shrink-0" />
|
||||||
<div class="flex items-center gap-2">
|
<span>{section.title}</span>
|
||||||
{#if selectedOption?.icon}
|
</button>
|
||||||
{@const IconComponent = selectedOption.icon}
|
{/each}
|
||||||
<IconComponent class="h-4 w-4" />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{selectedOption?.label || `Select ${field.label.toLowerCase()}`}
|
|
||||||
</div>
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#if field.options}
|
|
||||||
{#each field.options as option (option.value)}
|
|
||||||
<Select.Item value={option.value} label={option.label}>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{#if option.icon}
|
|
||||||
{@const IconComponent = option.icon}
|
|
||||||
<IconComponent class="h-4 w-4" />
|
|
||||||
{/if}
|
|
||||||
{option.label}
|
|
||||||
</div>
|
|
||||||
</Select.Item>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
{#if field.help || SETTING_CONFIG_INFO[field.key]}
|
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
|
||||||
{field.help || SETTING_CONFIG_INFO[field.key]}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
{:else if field.type === 'checkbox'}
|
|
||||||
{@const isDisabled = field.key === 'pdfAsImage' && !supportsVision()}
|
|
||||||
<div class="flex items-start space-x-3">
|
|
||||||
<Checkbox
|
|
||||||
id={field.key}
|
|
||||||
checked={Boolean(localConfig[field.key])}
|
|
||||||
disabled={isDisabled}
|
|
||||||
onCheckedChange={(checked) => (localConfig[field.key] = checked)}
|
|
||||||
class="mt-1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="space-y-1">
|
|
||||||
<label
|
|
||||||
for={field.key}
|
|
||||||
class="cursor-pointer text-sm leading-none font-medium {isDisabled
|
|
||||||
? 'text-muted-foreground'
|
|
||||||
: ''}"
|
|
||||||
>
|
|
||||||
{field.label}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{#if field.help || SETTING_CONFIG_INFO[field.key]}
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
{field.help || SETTING_CONFIG_INFO[field.key]}
|
|
||||||
</p>
|
|
||||||
{:else if field.key === 'pdfAsImage' && !supportsVision()}
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
PDF-to-image processing requires a vision-capable model. PDFs will be
|
|
||||||
processed as text.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
</div>
|
||||||
</ChatSettingsSection>
|
|
||||||
|
<button
|
||||||
|
class="absolute right-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollRight
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'pointer-events-none opacity-0'}"
|
||||||
|
onclick={scrollRight}
|
||||||
|
aria-label="Scroll right"
|
||||||
|
>
|
||||||
|
<ChevronRight class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea class="max-h-[calc(100vh-13.5rem)] flex-1">
|
||||||
|
<div class="space-y-6 p-4 md:p-6">
|
||||||
|
<div>
|
||||||
|
<div class="mb-6 flex hidden items-center gap-2 border-b border-border/30 pb-6 md:flex">
|
||||||
|
<currentSection.icon class="h-5 w-5" />
|
||||||
|
|
||||||
|
<h3 class="text-lg font-semibold">{currentSection.title}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<ChatSettingsFields
|
||||||
|
fields={currentSection.fields}
|
||||||
|
{localConfig}
|
||||||
|
onConfigChange={handleConfigChange}
|
||||||
|
onThemeChange={handleThemeChange}
|
||||||
|
isMobile={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 border-t pt-6">
|
<div class="mt-8 border-t pt-6">
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
@@ -467,6 +470,6 @@
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ChatSettingsFooter onClose={handleClose} onReset={handleReset} onSave={handleSave} />
|
<ChatSettingsFooter onReset={handleReset} onSave={handleSave} />
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import Label from '$lib/components/ui/label/label.svelte';
|
||||||
|
import * as Select from '$lib/components/ui/select';
|
||||||
|
import { Textarea } from '$lib/components/ui/textarea';
|
||||||
|
import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO } from '$lib/constants/settings-config';
|
||||||
|
import { supportsVision } from '$lib/stores/server.svelte';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
fields: SettingsFieldConfig[];
|
||||||
|
localConfig: SettingsConfigType;
|
||||||
|
onConfigChange: (key: string, value: string | boolean) => void;
|
||||||
|
onThemeChange?: (theme: string) => void;
|
||||||
|
isMobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { fields, localConfig, onConfigChange, onThemeChange, isMobile = false }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#each fields as field (field.key)}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#if field.type === 'input'}
|
||||||
|
<Label for={field.key} class="block text-sm font-medium">
|
||||||
|
{field.label}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id={field.key}
|
||||||
|
value={String(localConfig[field.key] || '')}
|
||||||
|
onchange={(e) => onConfigChange(field.key, e.currentTarget.value)}
|
||||||
|
placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] || 'none'}`}
|
||||||
|
class={isMobile ? 'w-full' : 'max-w-md'}
|
||||||
|
/>
|
||||||
|
{#if field.help || SETTING_CONFIG_INFO[field.key]}
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
{field.help || SETTING_CONFIG_INFO[field.key]}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{:else if field.type === 'textarea'}
|
||||||
|
<Label for={field.key} class="block text-sm font-medium">
|
||||||
|
{field.label}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
id={field.key}
|
||||||
|
value={String(localConfig[field.key] || '')}
|
||||||
|
onchange={(e) => onConfigChange(field.key, e.currentTarget.value)}
|
||||||
|
placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] || 'none'}`}
|
||||||
|
class={isMobile ? 'min-h-[100px] w-full' : 'min-h-[100px] max-w-2xl'}
|
||||||
|
/>
|
||||||
|
{#if field.help || SETTING_CONFIG_INFO[field.key]}
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
{field.help || SETTING_CONFIG_INFO[field.key]}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{:else if field.type === 'select'}
|
||||||
|
{@const selectedOption = field.options?.find(
|
||||||
|
(opt: { value: string; label: string; icon?: Component }) =>
|
||||||
|
opt.value === localConfig[field.key]
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Label for={field.key} class="block text-sm font-medium">
|
||||||
|
{field.label}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
value={localConfig[field.key]}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (field.key === 'theme' && value && onThemeChange) {
|
||||||
|
onThemeChange(value);
|
||||||
|
} else {
|
||||||
|
onConfigChange(field.key, value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select.Trigger class={isMobile ? 'w-full' : 'max-w-md'}>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if selectedOption?.icon}
|
||||||
|
{@const IconComponent = selectedOption.icon}
|
||||||
|
<IconComponent class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{selectedOption?.label || `Select ${field.label.toLowerCase()}`}
|
||||||
|
</div>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#if field.options}
|
||||||
|
{#each field.options as option (option.value)}
|
||||||
|
<Select.Item value={option.value} label={option.label}>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if option.icon}
|
||||||
|
{@const IconComponent = option.icon}
|
||||||
|
<IconComponent class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
</Select.Item>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
{#if field.help || SETTING_CONFIG_INFO[field.key]}
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
{field.help || SETTING_CONFIG_INFO[field.key]}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{:else if field.type === 'checkbox'}
|
||||||
|
{@const isDisabled = field.key === 'pdfAsImage' && !supportsVision()}
|
||||||
|
<div class="flex items-start space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
id={field.key}
|
||||||
|
checked={Boolean(localConfig[field.key])}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onCheckedChange={(checked) => onConfigChange(field.key, checked)}
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label
|
||||||
|
for={field.key}
|
||||||
|
class="cursor-pointer text-sm leading-none font-medium {isDisabled
|
||||||
|
? 'text-muted-foreground'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{field.label}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if field.help || SETTING_CONFIG_INFO[field.key]}
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{field.help || SETTING_CONFIG_INFO[field.key]}
|
||||||
|
</p>
|
||||||
|
{:else if field.key === 'pdfAsImage' && !supportsVision()}
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
PDF-to-image processing requires a vision-capable model. PDFs will be processed as
|
||||||
|
text.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
@@ -2,16 +2,11 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose?: () => void;
|
|
||||||
onReset?: () => void;
|
onReset?: () => void;
|
||||||
onSave?: () => void;
|
onSave?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onClose, onReset, onSave }: Props = $props();
|
let { onReset, onSave }: Props = $props();
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
onClose?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
onReset?.();
|
onReset?.();
|
||||||
@@ -25,9 +20,5 @@
|
|||||||
<div class="flex justify-between border-t border-border/30 p-6">
|
<div class="flex justify-between border-t border-border/30 p-6">
|
||||||
<Button variant="outline" onclick={handleReset}>Reset to default</Button>
|
<Button variant="outline" onclick={handleReset}>Reset to default</Button>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<Button onclick={handleSave}>Save settings</Button>
|
||||||
<Button variant="outline" onclick={handleClose}>Close</Button>
|
|
||||||
|
|
||||||
<Button onclick={handleSave}>Save</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Component, Snippet } from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: Snippet;
|
|
||||||
title: string;
|
|
||||||
Icon: Component;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { children, title, Icon }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="mb-6 flex items-center gap-2 border-b border-border/30 pb-6">
|
|
||||||
<Icon class="h-5 w-5" />
|
|
||||||
|
|
||||||
<h3 class="text-lg font-semibold">{title}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
|
||||||
{@render children()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -22,8 +22,8 @@ export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.
|
|||||||
export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
|
export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
|
||||||
|
|
||||||
export { default as ChatSettingsDialog } from './chat/ChatSettings/ChatSettingsDialog.svelte';
|
export { default as ChatSettingsDialog } from './chat/ChatSettings/ChatSettingsDialog.svelte';
|
||||||
export { default as ChatSettingsSection } from './chat/ChatSettings/ChatSettingsSection.svelte';
|
|
||||||
export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte';
|
export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte';
|
||||||
|
export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.svelte';
|
||||||
|
|
||||||
export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
|
export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
|
||||||
export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte';
|
export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte';
|
||||||
|
|||||||
Reference in New Issue
Block a user