ui: Restructure repo to use tools/ui folder and ui / UI / llama-ui / LLAMA_UI naming (#23064)
* webui: Move static build output from `tools/server/public` to `build/ui` directory * refactor: Move to `tools/ui` * refactor: rename CMake variables and preprocessor defines - Rename LLAMA_BUILD_WEBUI -> LLAMA_BUILD_UI (old kept as deprecated) - Rename LLAMA_USE_PREBUILT_WEBUI -> LLAMA_USE_PREBUILT_UI (old kept as deprecated) - Backward compat: old vars auto-forward to new ones with DEPRECATION warning - Rename internal vars: WEBUI_SOURCE -> UI_SOURCE, WEBUI_SOURCE_DIR -> UI_SOURCE_DIR, etc. - Rename HF bucket: LLAMA_WEBUI_HF_BUCKET -> LLAMA_UI_HF_BUCKET - Emit both LLAMA_BUILD_WEBUI and LLAMA_BUILD_UI preprocessor defines - Emit both LLAMA_WEBUI_DEFAULT_ENABLED and LLAMA_UI_DEFAULT_ENABLED * refactor: rename CLI flags (--webui -> --ui) with backward compat - Add --ui/--no-ui (old --webui/--no-webui kept as deprecated aliases) - Add --ui-config (old --webui-config kept as deprecated alias) - Add --ui-config-file (old --webui-config-file kept as deprecated alias) - Add --ui-mcp-proxy/--no-ui-mcp-proxy (old --webui-mcp-proxy kept as deprecated) - Add new env vars: LLAMA_ARG_UI, LLAMA_ARG_UI_CONFIG, LLAMA_ARG_UI_CONFIG_FILE, LLAMA_ARG_UI_MCP_PROXY - C++ struct fields: params.ui, params.ui_config_json, params.ui_mcp_proxy added alongside old fields - Backward compat: old fields synced to new ones in g_params_to_internals * refactor: update C++ server internals with backward compat - Rename json_webui_settings -> json_ui_settings (both kept in server_context_meta) - Rename params.webui usage -> params.ui (both synced, old still works) - JSON API emits both "ui"/"ui_settings" and "webui"/"webui_settings" keys - Server routes use params.ui_mcp_proxy || params.webui_mcp_proxy - Preprocessor guards use #if defined(LLAMA_BUILD_UI) || defined(LLAMA_BUILD_WEBUI) * refactor: rename CI/CD workflows, artifacts, and build script - Rename webui-build.yml -> ui-build.yml; artifact webui-build -> ui-build - Rename webui-publish.yml -> ui-publish.yml; var HF_BUCKET_WEBUI_STATIC_OUTPUT -> HF_BUCKET_UI_STATIC_OUTPUT - Rename server-webui.yml -> server-ui.yml; job webui-build/checks -> ui-build/checks - Update server.yml: job/artifact refs webui-build -> ui-build - Update release.yml: all webui-build/publish refs -> ui-build/publish; HF_TOKEN_WEBUI_STATIC_OUTPUT -> HF_TOKEN_UI_STATIC_OUTPUT - Update server-self-hosted.yml: webui-build -> ui-build - Update build-self-hosted.yml: HF_WEBUI_VERSION -> HF_UI_VERSION - Rename webui-download.cmake -> ui-download.cmake (internal refs updated) - Update labeler.yml: server/webui -> server/ui path label * docs: update CODEOWNERS and server README docs - Update CODEOWNERS: team ggml-org/llama-webui -> ggml-org/llama-ui, path /tools/server/webui/ -> /tools/ui/ - Update server README.md: CLI tables show --ui flags with deprecated --webui aliases - Update server README-dev.md: "WebUI" -> "UI", paths updated to tools/ui/ * fix: Small fixes for UI build * fix: CMake.txt syntax * chore: Formatting * fix: `.editorconfig` for llama-ui * chore: Formatting * refactor: Use `APP_NAME` in Error route * refactor: Cleanup * refactor: Single migration service * make llama-ui a linkable target * fix: UI Build output * fix: Missing change * fix: separate llama-ui npm build output into build/tools/ui/dist subfolder + use cmake npm build instead of downloading ui-build.yml artifacts in CI * refactor: UI workflows cleanup --------- Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
This commit is contained in:
co-authored by
Xuan Son Nguyen
parent
49d1701bd2
commit
59778f0196
@@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
SettingsChatDesktopSidebar,
|
||||
SettingsChatFields,
|
||||
SettingsChatImportExportTab,
|
||||
SettingsChatMobileHeader,
|
||||
SettingsChatToolsTab,
|
||||
SettingsFooter
|
||||
} from '$lib/components/app/settings';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import {
|
||||
NUMERIC_FIELDS,
|
||||
POSITIVE_INTEGER_FIELDS,
|
||||
SETTINGS_CHAT_SECTIONS,
|
||||
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';
|
||||
import { goto } from '$app/navigation';
|
||||
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;
|
||||
}
|
||||
|
||||
let { initialSection, getSectionHref }: Props = $props();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function handleConfigChange(key: string, value: string | boolean) {
|
||||
localConfig[key] = value;
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
localConfig = { ...config() };
|
||||
setMode(localConfig.theme as ColorMode);
|
||||
mobileHeader?.updateCarousel();
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (localConfig.custom && typeof localConfig.custom === 'string' && localConfig.custom.trim()) {
|
||||
try {
|
||||
JSON.parse(localConfig.custom);
|
||||
} catch (error) {
|
||||
alert('Invalid JSON in custom parameters. Please check the format and try again.');
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const processedConfig = { ...localConfig };
|
||||
|
||||
for (const field of NUMERIC_FIELDS) {
|
||||
if (processedConfig[field] !== undefined && processedConfig[field] !== '') {
|
||||
const numValue = Number(processedConfig[field]);
|
||||
if (!isNaN(numValue)) {
|
||||
if ((POSITIVE_INTEGER_FIELDS as readonly string[]).includes(field)) {
|
||||
processedConfig[field] = Math.max(1, Math.round(numValue));
|
||||
} else {
|
||||
processedConfig[field] = numValue;
|
||||
}
|
||||
} else {
|
||||
alert(`Invalid numeric value for ${field}. Please enter a valid number.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
settingsStore.updateMultipleConfig(processedConfig);
|
||||
goto(settingsReferrer.url);
|
||||
}
|
||||
|
||||
export function reset() {
|
||||
localConfig = { ...config() };
|
||||
}
|
||||
|
||||
setChatSettingsConfigContext({
|
||||
get localConfig() {
|
||||
return localConfig;
|
||||
},
|
||||
handleConfigChange,
|
||||
handleThemeChange
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="mx-auto flex h-full max-h-[100dvh] w-full flex-col overflow-y-auto md:pl-8"
|
||||
in:fade={{ duration: 150 }}
|
||||
>
|
||||
<div class="flex flex-1 flex-col gap-4 md:flex-row">
|
||||
<SettingsChatDesktopSidebar
|
||||
sections={SETTINGS_CHAT_SECTIONS}
|
||||
isActive={(section: SettingsSection) => section.slug === activeSlug}
|
||||
getHref={getSectionHref ??
|
||||
((section: SettingsSection) => RouterService.settings(section.slug))}
|
||||
/>
|
||||
|
||||
<SettingsChatMobileHeader
|
||||
sections={SETTINGS_CHAT_SECTIONS}
|
||||
isActive={(section: SettingsSection) => section.slug === activeSlug}
|
||||
getHref={getSectionHref ??
|
||||
((section: SettingsSection) => RouterService.settings(section.slug))}
|
||||
bind:this={mobileHeader}
|
||||
/>
|
||||
|
||||
<div class="mx-auto max-w-3xl flex-1">
|
||||
<div class="space-y-6 p-4 md:p-6 md:pt-28">
|
||||
<div class="grid">
|
||||
<div class="mb-6 flex 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>
|
||||
|
||||
{#if currentSection.title === SETTINGS_SECTION_TITLES.TOOLS}
|
||||
<SettingsChatToolsTab />
|
||||
{:else if currentSection.title === SETTINGS_SECTION_TITLES.IMPORT_EXPORT}
|
||||
<SettingsChatImportExportTab />
|
||||
{:else if currentSection.fields}
|
||||
<div class="space-y-6">
|
||||
<SettingsChatFields
|
||||
fields={currentSection.fields}
|
||||
{localConfig}
|
||||
onConfigChange={handleConfigChange}
|
||||
onThemeChange={handleThemeChange}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 border-t border-border/30 pt-6">
|
||||
<p class="text-xs text-muted-foreground">Settings are saved in browser's localStorage</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsFooter onReset={handleReset} onSave={handleSave} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,265 @@
|
||||
<script lang="ts">
|
||||
import { RotateCcw, FlaskConical } from '@lucide/svelte';
|
||||
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_INFO, SETTINGS_KEYS } from '$lib/constants';
|
||||
import { SettingsFieldType } from '$lib/enums/settings';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { modelsStore, selectedModelName, propsCacheVersion } from '$lib/stores/models.svelte';
|
||||
import { normalizeFloatingPoint } from '$lib/utils/precision';
|
||||
import { SettingsChatParameterSourceIndicator } from '$lib/components/app/settings';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
fields: SettingsFieldConfig[];
|
||||
localConfig: SettingsConfigType;
|
||||
onConfigChange: (key: string, value: string | boolean) => void;
|
||||
onThemeChange?: (theme: string) => void;
|
||||
}
|
||||
|
||||
let { fields, localConfig, onConfigChange, onThemeChange }: Props = $props();
|
||||
|
||||
let currentModelParams = $derived.by(() => {
|
||||
propsCacheVersion();
|
||||
|
||||
if (serverStore.isRouterMode) {
|
||||
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>;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#each fields as field (field.key)}
|
||||
<div class="space-y-2">
|
||||
{#if field.type === SettingsFieldType.INPUT}
|
||||
{@const currentValue = String(localConfig[field.key] ?? '')}
|
||||
{@const serverDefault = currentModelParams[field.key]}
|
||||
{@const isCustomRealTime = (() => {
|
||||
if (serverDefault == null) return false;
|
||||
if (currentValue === '') return false;
|
||||
|
||||
const numericInput = parseFloat(currentValue);
|
||||
const normalizedInput = !isNaN(numericInput)
|
||||
? Math.round(numericInput * 1000000) / 1000000
|
||||
: currentValue;
|
||||
const normalizedDefault =
|
||||
typeof serverDefault === 'number'
|
||||
? Math.round(serverDefault * 1000000) / 1000000
|
||||
: serverDefault;
|
||||
|
||||
return normalizedInput !== normalizedDefault;
|
||||
})()}
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Label for={field.key} class="flex items-center gap-1.5 text-sm font-medium">
|
||||
{field.label}
|
||||
|
||||
{#if field.isExperimental}
|
||||
<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{/if}
|
||||
</Label>
|
||||
{#if isCustomRealTime}
|
||||
<SettingsChatParameterSourceIndicator />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="relative w-full">
|
||||
<Input
|
||||
id={field.key}
|
||||
value={currentValue}
|
||||
oninput={(e) => {
|
||||
// Update local config immediately for real-time badge feedback
|
||||
onConfigChange(field.key, e.currentTarget.value);
|
||||
}}
|
||||
placeholder={currentModelParams[field.key] != null
|
||||
? `Default: ${normalizeFloatingPoint(currentModelParams[field.key])}`
|
||||
: ''}
|
||||
class="w-full {isCustomRealTime ? 'pr-8' : ''}"
|
||||
/>
|
||||
{#if isCustomRealTime}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
settingsStore.resetParameterToServerDefault(field.key);
|
||||
onConfigChange(field.key, '');
|
||||
}}
|
||||
class="absolute top-1/2 right-2 inline-flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded transition-colors hover:bg-muted"
|
||||
aria-label="Reset to default"
|
||||
title="Reset to default"
|
||||
>
|
||||
<RotateCcw class="h-3 w-3" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if field.help || SETTING_CONFIG_INFO[field.key]}
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{@html field.help || SETTING_CONFIG_INFO[field.key]}
|
||||
</p>
|
||||
{/if}
|
||||
{:else if field.type === SettingsFieldType.TEXTAREA}
|
||||
{#if field.label}
|
||||
<Label for={field.key} class="block flex items-center gap-1.5 text-sm font-medium">
|
||||
{field.label}
|
||||
|
||||
{#if field.isExperimental}
|
||||
<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{/if}
|
||||
</Label>
|
||||
{/if}
|
||||
|
||||
<Textarea
|
||||
id={field.key}
|
||||
value={String(localConfig[field.key] ?? '')}
|
||||
onchange={(e) => onConfigChange(field.key, e.currentTarget.value)}
|
||||
placeholder=""
|
||||
class="min-h-[10rem] w-full md:max-w-3xl"
|
||||
/>
|
||||
|
||||
{#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}
|
||||
|
||||
{#if field.key === SETTINGS_KEYS.SYSTEM_MESSAGE}
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="showSystemMessage"
|
||||
checked={Boolean(localConfig.showSystemMessage ?? true)}
|
||||
onCheckedChange={(checked) =>
|
||||
onConfigChange(SETTINGS_KEYS.SHOW_SYSTEM_MESSAGE, Boolean(checked))}
|
||||
/>
|
||||
|
||||
<Label for="showSystemMessage" class="cursor-pointer text-sm font-normal">
|
||||
Show system message in conversations
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if field.type === SettingsFieldType.SELECT}
|
||||
{@const selectedOption = field.options?.find(
|
||||
(opt: { value: string; label: string; icon?: Component }) =>
|
||||
opt.value === localConfig[field.key]
|
||||
)}
|
||||
{@const currentValue = localConfig[field.key]}
|
||||
{@const serverDefault = currentModelParams[field.key]}
|
||||
{@const isCustomRealTime = (() => {
|
||||
if (serverDefault == null) return false;
|
||||
if (currentValue === '' || currentValue === undefined) return false;
|
||||
return currentValue !== serverDefault;
|
||||
})()}
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Label for={field.key} class="flex items-center gap-1.5 text-sm font-medium">
|
||||
{field.label}
|
||||
|
||||
{#if field.isExperimental}
|
||||
<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{/if}
|
||||
</Label>
|
||||
{#if isCustomRealTime}
|
||||
<SettingsChatParameterSourceIndicator />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={currentValue}
|
||||
onValueChange={(value) => {
|
||||
if (field.key === SETTINGS_KEYS.THEME && value && onThemeChange) {
|
||||
onThemeChange(value);
|
||||
} else {
|
||||
onConfigChange(field.key, value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="relative w-full md:w-auto">
|
||||
<Select.Trigger class="w-full">
|
||||
<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>
|
||||
{#if isCustomRealTime}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
settingsStore.resetParameterToServerDefault(field.key);
|
||||
onConfigChange(field.key, '');
|
||||
}}
|
||||
class="absolute top-1/2 right-8 inline-flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded transition-colors hover:bg-muted"
|
||||
aria-label="Reset to default"
|
||||
title="Reset to default"
|
||||
>
|
||||
<RotateCcw class="h-3 w-3" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<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 === SettingsFieldType.CHECKBOX}
|
||||
<div class="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id={field.key}
|
||||
checked={Boolean(localConfig[field.key])}
|
||||
onCheckedChange={(checked) => onConfigChange(field.key, checked)}
|
||||
class="mt-1"
|
||||
/>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for={field.key}
|
||||
class="flex cursor-pointer items-center gap-1.5 pt-1 pb-0.5 text-sm leading-none font-medium"
|
||||
>
|
||||
{field.label}
|
||||
|
||||
{#if field.isExperimental}
|
||||
<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
{#if field.help || SETTING_CONFIG_INFO[field.key]}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{field.help || SETTING_CONFIG_INFO[field.key]}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
+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>
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
<script lang="ts">
|
||||
import { Download, Upload, Trash2 } from '@lucide/svelte';
|
||||
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';
|
||||
import SettingsChatImportExportSection from './SettingsChatImportExportSection.svelte';
|
||||
import SettingsGroup from '$lib/components/app/settings/SettingsGroup.svelte';
|
||||
|
||||
let exportedConversations = $state<DatabaseConversation[]>([]);
|
||||
let importedConversations = $state<DatabaseConversation[]>([]);
|
||||
let showExportSummary = $state(false);
|
||||
let showImportSummary = $state(false);
|
||||
|
||||
let showExportDialog = $state(false);
|
||||
let showImportDialog = $state(false);
|
||||
let availableConversations = $state<DatabaseConversation[]>([]);
|
||||
let messageCountMap = $state<Map<string, number>>(new Map());
|
||||
let fullImportData = $state<Array<{ conv: DatabaseConversation; messages: DatabaseMessage[] }>>(
|
||||
[]
|
||||
);
|
||||
|
||||
// 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();
|
||||
if (allConversations.length === 0) {
|
||||
toast.info('No conversations to export');
|
||||
return;
|
||||
}
|
||||
|
||||
const conversationsWithMessages = await Promise.all(
|
||||
allConversations.map(async (conv: DatabaseConversation) => {
|
||||
const messages = await conversationsStore.getConversationMessages(conv.id);
|
||||
return { conv, messages };
|
||||
})
|
||||
);
|
||||
|
||||
messageCountMap = createMessageCountMap(conversationsWithMessages);
|
||||
availableConversations = allConversations;
|
||||
showExportDialog = true;
|
||||
} catch (err) {
|
||||
console.error('Failed to load conversations:', err);
|
||||
alert('Failed to load conversations');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportConfirm(selectedConversations: DatabaseConversation[]) {
|
||||
try {
|
||||
const allData: ExportedConversations = await Promise.all(
|
||||
selectedConversations.map(async (conv) => {
|
||||
const messages = await conversationsStore.getConversationMessages(conv.id);
|
||||
return { conv: $state.snapshot(conv), messages: $state.snapshot(messages) };
|
||||
})
|
||||
);
|
||||
|
||||
conversationsStore.downloadConversationFile(allData);
|
||||
|
||||
exportedConversations = selectedConversations;
|
||||
showExportSummary = true;
|
||||
showImportSummary = false;
|
||||
showExportDialog = false;
|
||||
} catch (err) {
|
||||
console.error('Export failed:', err);
|
||||
alert('Failed to export conversations');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportClick() {
|
||||
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 parsedData = JSON.parse(text);
|
||||
let importedData: ExportedConversations;
|
||||
|
||||
if (Array.isArray(parsedData)) {
|
||||
importedData = parsedData;
|
||||
} else if (
|
||||
parsedData &&
|
||||
typeof parsedData === 'object' &&
|
||||
'conv' in parsedData &&
|
||||
'messages' in parsedData
|
||||
) {
|
||||
// Single conversation object
|
||||
importedData = [parsedData];
|
||||
} else {
|
||||
throw new Error(
|
||||
'Invalid file format: expected array of conversations or single conversation object'
|
||||
);
|
||||
}
|
||||
|
||||
fullImportData = importedData;
|
||||
availableConversations = importedData.map(
|
||||
(item: { conv: DatabaseConversation; messages: DatabaseMessage[] }) => item.conv
|
||||
);
|
||||
messageCountMap = createMessageCountMap(importedData);
|
||||
showImportDialog = true;
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
|
||||
console.error('Failed to parse file:', err);
|
||||
alert(`Failed to parse file: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
} catch (err) {
|
||||
console.error('Import failed:', err);
|
||||
alert('Failed to import conversations');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportConfirm(selectedConversations: DatabaseConversation[]) {
|
||||
try {
|
||||
const selectedIds = new Set(selectedConversations.map((c) => c.id));
|
||||
const selectedData = $state
|
||||
.snapshot(fullImportData)
|
||||
.filter((item) => selectedIds.has(item.conv.id));
|
||||
|
||||
await conversationsStore.importConversationsData(selectedData);
|
||||
|
||||
importedConversations = selectedConversations;
|
||||
showImportSummary = true;
|
||||
showExportSummary = false;
|
||||
showImportDialog = false;
|
||||
} catch (err) {
|
||||
console.error('Import failed:', err);
|
||||
alert('Failed to import conversations. Please check the file format.');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAllClick() {
|
||||
try {
|
||||
const allConversations = conversations();
|
||||
|
||||
if (allConversations.length === 0) {
|
||||
toast.info('No conversations to delete');
|
||||
return;
|
||||
}
|
||||
|
||||
showDeleteDialog = true;
|
||||
} catch (err) {
|
||||
console.error('Failed to load conversations for deletion:', err);
|
||||
toast.error('Failed to load conversations');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAllConfirm() {
|
||||
try {
|
||||
await conversationsStore.deleteAll();
|
||||
|
||||
showDeleteDialog = false;
|
||||
} catch (err) {
|
||||
console.error('Failed to delete conversations:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteAllCancel() {
|
||||
showDeleteDialog = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<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 }}
|
||||
/>
|
||||
|
||||
<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 }}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<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: [] }}
|
||||
/>
|
||||
|
||||
<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}
|
||||
mode={ConversationSelectionMode.EXPORT}
|
||||
bind:open={showExportDialog}
|
||||
onCancel={() => (showExportDialog = false)}
|
||||
onConfirm={handleExportConfirm}
|
||||
/>
|
||||
|
||||
<DialogConversationSelection
|
||||
conversations={availableConversations}
|
||||
{messageCountMap}
|
||||
mode={ConversationSelectionMode.IMPORT}
|
||||
bind:open={showImportDialog}
|
||||
onCancel={() => (showImportDialog = false)}
|
||||
onConfirm={handleImportConfirm}
|
||||
/>
|
||||
|
||||
<DialogConfirmation
|
||||
bind:open={showDeleteDialog}
|
||||
title="Delete all conversations"
|
||||
description="Are you sure you want to delete all conversations? This action cannot be undone and will permanently remove all your conversations and messages."
|
||||
confirmText="Delete All"
|
||||
cancelText="Cancel"
|
||||
variant="destructive"
|
||||
icon={Trash2}
|
||||
onConfirm={handleDeleteAllConfirm}
|
||||
onCancel={handleDeleteAllCancel}
|
||||
/>
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Wrench } from '@lucide/svelte';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="h-5 bg-orange-100 px-1.5 py-0.5 text-xs text-orange-800 dark:bg-orange-900 dark:text-orange-200 {className}"
|
||||
>
|
||||
<Wrench class="mr-1 h-3 w-3" />
|
||||
|
||||
Custom
|
||||
</Badge>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, ChevronRight } from '@lucide/svelte';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import { TruncatedText, McpServerIdentity } from '$lib/components/app';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { permissionsStore } from '$lib/stores/permissions.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
let expandedGroups = new SvelteSet<string>();
|
||||
let groups = $derived(toolsStore.toolGroups);
|
||||
|
||||
function toggleExpanded(label: string) {
|
||||
if (expandedGroups.has(label)) {
|
||||
expandedGroups.delete(label);
|
||||
} else {
|
||||
expandedGroups.add(label);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if groups.length === 0}
|
||||
<div class="py-8 text-center text-sm text-muted-foreground">No tools available</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each groups as group (group.label)}
|
||||
{@const isExpanded = expandedGroups.has(group.label)}
|
||||
<Collapsible.Root open={isExpanded} onOpenChange={() => toggleExpanded(group.label)}>
|
||||
<Collapsible.Trigger
|
||||
class="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm hover:bg-muted/50"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDown class="h-3.5 w-3.5 shrink-0" />
|
||||
{:else}
|
||||
<ChevronRight class="h-3.5 w-3.5 shrink-0" />
|
||||
{/if}
|
||||
|
||||
{@const faviconUrl = group.serverId ? mcpStore.getServerFavicon(group.serverId) : null}
|
||||
|
||||
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
|
||||
<McpServerIdentity
|
||||
iconClass="h-4 w-4"
|
||||
iconRounded="rounded-sm"
|
||||
showVersion={false}
|
||||
displayName={group.label}
|
||||
{faviconUrl}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
{group.tools.length} tool{group.tools.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content>
|
||||
<div class="ml-4 border-l border-border/50 pl-2">
|
||||
<!-- Header row -->
|
||||
<div class="flex items-center gap-2 px-2 py-1 text-xs text-muted-foreground">
|
||||
<span class="min-w-0 flex-1">Tool</span>
|
||||
<span class="w-16 shrink-0 text-center">Enabled</span>
|
||||
<span class="w-20 shrink-0 text-center">Always allow</span>
|
||||
</div>
|
||||
|
||||
{#each group.tools as tool (tool.function.name)}
|
||||
{@const toolName = tool.function.name}
|
||||
{@const isEnabled = toolsStore.isToolEnabled(toolName)}
|
||||
{@const permissionKey = toolsStore.getPermissionKey(toolName)}
|
||||
{@const isAlwaysAllowed = permissionKey
|
||||
? permissionsStore.hasTool(permissionKey)
|
||||
: false}
|
||||
|
||||
<div class="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50">
|
||||
<TruncatedText text={toolName} class="flex-1" showTooltip={true} />
|
||||
|
||||
<div class="flex w-16 shrink-0 justify-center">
|
||||
<Checkbox
|
||||
checked={isEnabled}
|
||||
onCheckedChange={() => toolsStore.toggleTool(toolName)}
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex w-20 shrink-0 justify-center">
|
||||
<Checkbox
|
||||
checked={isAlwaysAllowed}
|
||||
onCheckedChange={() => {
|
||||
if (isAlwaysAllowed) {
|
||||
permissionsStore.revokeTool(permissionKey!);
|
||||
} else {
|
||||
permissionsStore.allowTool(permissionKey!);
|
||||
}
|
||||
}}
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { Settings } from '@lucide/svelte';
|
||||
import type { SettingsSection, SettingsSectionTitle } from '$lib/constants';
|
||||
|
||||
interface Props {
|
||||
sections: SettingsSection[];
|
||||
isActive: (section: SettingsSection) => boolean;
|
||||
getHref?: (section: SettingsSection) => string;
|
||||
onSectionChange?: (section: SettingsSectionTitle) => void;
|
||||
}
|
||||
|
||||
let { sections, isActive, getHref, onSectionChange }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="sticky top-0 hidden w-64 flex-col self-start bg-background pt-10 pb-4 md:flex">
|
||||
<div class="flex items-center gap-2 pb-10">
|
||||
<Settings class="h-6 w-6" />
|
||||
<h1 class="text-2xl font-semibold">Settings</h1>
|
||||
</div>
|
||||
<nav class="space-y-1">
|
||||
{#each sections as section (section.title)}
|
||||
{#if getHref}
|
||||
<a
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm no-underline transition-colors hover:bg-accent {isActive(
|
||||
section
|
||||
)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
href={getHref(section)}
|
||||
>
|
||||
<section.icon class="h-4 w-4" />
|
||||
<span class="ml-2">{section.title}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent {isActive(
|
||||
section
|
||||
)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
onclick={() => onSectionChange?.(section.title)}
|
||||
>
|
||||
<section.icon class="h-4 w-4" />
|
||||
<span class="ml-2">{section.title}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import { Settings, ChevronLeft, ChevronRight } from '@lucide/svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import type { SettingsSection, SettingsSectionTitle } from '$lib/constants';
|
||||
import { useScrollCarousel } from '$lib/hooks/use-scroll-carousel.svelte';
|
||||
|
||||
interface Props {
|
||||
sections: SettingsSection[];
|
||||
isActive: (section: SettingsSection) => boolean;
|
||||
getHref?: (section: SettingsSection) => string;
|
||||
onSectionChange?: (section: SettingsSectionTitle) => void;
|
||||
}
|
||||
|
||||
let { sections, isActive, getHref, onSectionChange }: Props = $props();
|
||||
|
||||
const carousel = useScrollCarousel();
|
||||
|
||||
onMount(async () => {
|
||||
await tick();
|
||||
if (carousel.scrollContainer) {
|
||||
const activeTab = carousel.scrollContainer.querySelector('[data-active="true"]');
|
||||
if (activeTab instanceof HTMLElement) {
|
||||
carousel.scrollToCenter(activeTab);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export function updateCarousel() {
|
||||
setTimeout(carousel.updateScrollButtons, 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sticky top-0 z-10 flex flex-col bg-background md:hidden">
|
||||
<div class="flex items-center gap-2 px-4 pt-4 pb-2 md:pt-6">
|
||||
<Settings class="h-5 w-5 md:h-6 md:w-6" />
|
||||
|
||||
<h1 class="text-xl font-semibold md:text-2xl">Settings</h1>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-border/30 py-2">
|
||||
<div class="relative flex items-center" style="scroll-padding: 1rem;">
|
||||
<button
|
||||
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 {carousel.canScrollLeft
|
||||
? 'opacity-100'
|
||||
: 'pointer-events-none opacity-0'}"
|
||||
onclick={carousel.scrollLeft}
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="scrollbar-hide overflow-x-auto py-2"
|
||||
bind:this={carousel.scrollContainer}
|
||||
onscroll={carousel.updateScrollButtons}
|
||||
>
|
||||
<div class="flex min-w-max gap-2">
|
||||
{#each sections as section (section.title)}
|
||||
{#if getHref}
|
||||
<a
|
||||
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap no-underline transition-colors first:ml-4 last:mr-4 hover:bg-accent {isActive(
|
||||
section
|
||||
)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
data-active={isActive(section)}
|
||||
href={getHref(section)}
|
||||
onclick={(e: MouseEvent) => {
|
||||
carousel.scrollToCenter(e.currentTarget as HTMLElement);
|
||||
}}
|
||||
>
|
||||
<section.icon class="h-4 w-4 flex-shrink-0" />
|
||||
<span>{section.title}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
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 {isActive(
|
||||
section
|
||||
)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
data-active={isActive(section)}
|
||||
onclick={(e: MouseEvent) => {
|
||||
onSectionChange?.(section.title);
|
||||
carousel.scrollToCenter(e.currentTarget as HTMLElement);
|
||||
}}
|
||||
>
|
||||
<section.icon class="h-4 w-4 flex-shrink-0" />
|
||||
<span>{section.title}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 {carousel.canScrollRight
|
||||
? 'opacity-100'
|
||||
: 'pointer-events-none opacity-0'}"
|
||||
onclick={carousel.scrollRight}
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { RotateCcw } from '@lucide/svelte';
|
||||
|
||||
interface Props {
|
||||
onReset?: () => void;
|
||||
onSave?: () => void;
|
||||
}
|
||||
|
||||
let { onReset, onSave }: Props = $props();
|
||||
|
||||
let showResetDialog = $state(false);
|
||||
|
||||
function handleResetClick() {
|
||||
showResetDialog = true;
|
||||
}
|
||||
|
||||
function handleConfirmReset() {
|
||||
settingsStore.forceSyncWithServerDefaults();
|
||||
onReset?.();
|
||||
|
||||
showResetDialog = false;
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
onSave?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sticky bottom-0 mx-auto mt-4 flex w-full justify-between p-6">
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" onclick={handleResetClick}>
|
||||
<RotateCcw class="h-3 w-3" />
|
||||
|
||||
Reset to default
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button onclick={handleSave}>Save settings</Button>
|
||||
</div>
|
||||
|
||||
<AlertDialog.Root bind:open={showResetDialog}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Reset Settings to Default</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
Are you sure you want to reset all settings to their default values? This will reset all
|
||||
parameters to the values provided by the server's /props endpoint and remove all your custom
|
||||
configurations.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||
<AlertDialog.Action onclick={handleConfirmReset}>Reset to Default</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { Plus } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { McpServerCard, McpServerCardSkeleton } from '$lib/components/app/mcp';
|
||||
import { DialogMcpServerAddNew } from '$lib/components/app/dialogs';
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { onMount } from 'svelte';
|
||||
import McpLogo from '../mcp/McpLogo.svelte';
|
||||
import { page } from '$app/state';
|
||||
import { replaceState } from '$app/navigation';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className }: Props = $props();
|
||||
|
||||
let servers = $derived(mcpStore.getServersSorted());
|
||||
|
||||
let initialLoadComplete = $state(false);
|
||||
let isAddingServer = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if (page.url.searchParams.has('add')) {
|
||||
isAddingServer = true;
|
||||
|
||||
const newUrl = new URL(page.url);
|
||||
newUrl.searchParams.delete('add');
|
||||
|
||||
replaceState(newUrl, {});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (initialLoadComplete) return;
|
||||
|
||||
const allChecked =
|
||||
servers.length > 0 &&
|
||||
servers.every((server) => {
|
||||
const state = mcpStore.getHealthCheckState(server.id);
|
||||
|
||||
return (
|
||||
state.status === HealthCheckStatus.SUCCESS || state.status === HealthCheckStatus.ERROR
|
||||
);
|
||||
});
|
||||
|
||||
if (allChecked) {
|
||||
initialLoadComplete = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div in:fade={{ duration: 150 }} class="h-full max-h-[100dvh] overflow-y-auto">
|
||||
<div class="flex items-center gap-2 p-4 md:absolute md:top-8 md:left-8 md:px-0 md:py-2">
|
||||
<McpLogo class="h-5 w-5 md:h-6 md:w-6" />
|
||||
|
||||
<h1 class="text-xl font-semibold md:text-2xl">MCP Servers</h1>
|
||||
</div>
|
||||
|
||||
<div class="sticky top-0 z-10 mt-4 flex items-start gap-4 p-4 md:justify-end md:px-8">
|
||||
<Button variant="outline" size="sm" class="shrink-0" onclick={() => (isAddingServer = true)}>
|
||||
<Plus class="h-4 w-4" />
|
||||
|
||||
Add New Server
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogMcpServerAddNew bind:open={isAddingServer} />
|
||||
|
||||
<div class="grid gap-5 md:space-y-4 {className}">
|
||||
{#if servers.length === 0 && !isAddingServer}
|
||||
<div class="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
|
||||
No MCP Servers configured yet. Add one to enable agentic features.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if servers.length > 0}
|
||||
<div
|
||||
class="grid gap-3"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax(min(32rem, calc(100dvw - 2rem)), 1fr));"
|
||||
>
|
||||
{#each servers as server (server.id)}
|
||||
{#if !initialLoadComplete}
|
||||
<McpServerCardSkeleton />
|
||||
{:else}
|
||||
<McpServerCard
|
||||
{server}
|
||||
enabled={conversationsStore.isMcpServerEnabledForChat(server.id)}
|
||||
onToggle={async () => {
|
||||
const wasEnabled = conversationsStore.isMcpServerEnabledForChat(server.id);
|
||||
await conversationsStore.toggleMcpServerForChat(server.id);
|
||||
if (!wasEnabled) {
|
||||
toolsStore.enableAllToolsForServer(server.id);
|
||||
}
|
||||
}}
|
||||
onUpdate={(updates) => mcpStore.updateServer(server.id, updates)}
|
||||
onDelete={() => mcpStore.removeServer(server.id)}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Full chat settings page layout with sidebar, mobile header, and content area.
|
||||
* Manages local configuration state, section navigation, and context setup.
|
||||
* Accepts an optional `initialSection` prop to override the URL-based section resolution.
|
||||
*/
|
||||
export { default as SettingsChat } from './SettingsChat/SettingsChat.svelte';
|
||||
|
||||
/**
|
||||
* Desktop sidebar navigation for chat settings.
|
||||
* Displays a list of settings sections with icons and titles.
|
||||
* Supports both hash-link navigation (via `getHref`) and in-app section switching (via `onSectionChange`).
|
||||
*/
|
||||
export { default as SettingsChatDesktopSidebar } from './SettingsChatDesktopSidebar.svelte';
|
||||
|
||||
/**
|
||||
* Mobile header with a horizontally scrollable section picker for chat settings.
|
||||
* Shows chevron buttons for scroll navigation and highlights the active section.
|
||||
* Supports both hash-link navigation (via `getHref`) and in-app section switching (via `onSectionChange`).
|
||||
*/
|
||||
export { default as SettingsChatMobileHeader } from './SettingsChatMobileHeader.svelte';
|
||||
|
||||
/**
|
||||
* Badge indicating parameter source for sampling settings. Shows one of:
|
||||
* - **Custom**: User has explicitly set this value (orange badge)
|
||||
* - **Server Props**: Using default from `/props` endpoint (blue badge)
|
||||
* - **Default**: Using app default, server props unavailable (gray badge)
|
||||
* Updates in real-time as user types to show immediate feedback.
|
||||
*/
|
||||
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
|
||||
*
|
||||
* Displays available tools grouped by source (built-in, MCP, custom) with
|
||||
* toggles to enable/disable individual tools and tool groups. Shows MCP
|
||||
* server favicons and permission management controls.
|
||||
*/
|
||||
export { default as SettingsChatToolsTab } from './SettingsChat/SettingsChatToolsTab.svelte';
|
||||
Reference in New Issue
Block a user