Merge d24e0ed6db into 277ff5fff7
This commit is contained in:
commit
8f8cb1609d
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -18,7 +18,7 @@
|
|||
<div style="display: contents">
|
||||
<script>
|
||||
{
|
||||
__sveltekit_1trm5n9 = {
|
||||
__sveltekit_e2p3su = {
|
||||
base: new URL('.', location).pathname.slice(0, -1)
|
||||
};
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -29,7 +29,7 @@
|
|||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.987 0 0);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
|
|
@ -77,7 +77,7 @@
|
|||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.19 0 0);
|
||||
--sidebar: oklch(0.2 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
disabled?: boolean;
|
||||
onclick: (e?: MouseEvent) => void;
|
||||
'aria-label'?: string;
|
||||
tooltipSide?: 'top' | 'right' | 'bottom' | 'left';
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -23,6 +24,7 @@
|
|||
class: className = '',
|
||||
disabled = false,
|
||||
iconSize = 'h-3 w-3',
|
||||
tooltipSide = 'top',
|
||||
onclick,
|
||||
'aria-label': ariaLabel
|
||||
}: Props = $props();
|
||||
|
|
@ -35,7 +37,7 @@
|
|||
{size}
|
||||
{disabled}
|
||||
{onclick}
|
||||
class="h-6 w-6 p-0 {className} flex"
|
||||
class="h-6 w-6 p-0 {className} flex hover:bg-transparent! data-[state=open]:bg-transparent!"
|
||||
aria-label={ariaLabel || tooltip}
|
||||
>
|
||||
{@const IconComponent = icon}
|
||||
|
|
@ -44,7 +46,7 @@
|
|||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<Tooltip.Content side={tooltipSide}>
|
||||
<p>{tooltip}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,28 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { Plus, MessageSquare, Settings, Zap, FolderOpen } from '@lucide/svelte';
|
||||
import {
|
||||
Plus,
|
||||
MessageSquare,
|
||||
Zap,
|
||||
FolderOpen,
|
||||
PencilRuler,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2
|
||||
} from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { FILE_TYPE_ICONS, TOOLTIP_DELAY_DURATION } from '$lib/constants';
|
||||
import { McpLogo, DropdownMenuSearchable } from '$lib/components/app';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { toolsStore, type ToolGroup } from '$lib/stores/tools.svelte';
|
||||
import { ToolSource } from '$lib/enums';
|
||||
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
import type { MCPServerSettingsEntry } from '$lib/types';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
|
|
@ -23,7 +34,6 @@
|
|||
onFileUpload?: () => void;
|
||||
onSystemPromptClick?: () => void;
|
||||
onMcpPromptClick?: () => void;
|
||||
onMcpSettingsClick?: () => void;
|
||||
onMcpResourcesClick?: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -37,7 +47,6 @@
|
|||
onFileUpload,
|
||||
onSystemPromptClick,
|
||||
onMcpPromptClick,
|
||||
onMcpSettingsClick,
|
||||
onMcpResourcesClick
|
||||
}: Props = $props();
|
||||
|
||||
|
|
@ -51,54 +60,77 @@
|
|||
|
||||
let dropdownOpen = $state(false);
|
||||
|
||||
let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
|
||||
let hasMcpServers = $derived(mcpServers.length > 0);
|
||||
let mcpSearchQuery = $state('');
|
||||
let filteredMcpServers = $derived.by(() => {
|
||||
const query = mcpSearchQuery.toLowerCase().trim();
|
||||
if (!query) return mcpServers;
|
||||
return mcpServers.filter((s) => {
|
||||
const name = getServerLabel(s).toLowerCase();
|
||||
const url = s.url.toLowerCase();
|
||||
return name.includes(query) || url.includes(query);
|
||||
});
|
||||
});
|
||||
let expandedGroups = new SvelteSet<string>();
|
||||
let groups = $derived(toolsStore.toolGroups);
|
||||
let activeGroups = $derived(
|
||||
groups.filter(
|
||||
(g) =>
|
||||
g.source !== ToolSource.MCP ||
|
||||
!g.serverId ||
|
||||
conversationsStore.isMcpServerEnabledForChat(g.serverId)
|
||||
)
|
||||
);
|
||||
let totalToolCount = $derived(activeGroups.reduce((n, g) => n + g.tools.length, 0));
|
||||
|
||||
function getServerLabel(server: MCPServerSettingsEntry): string {
|
||||
return mcpStore.getServerLabel(server);
|
||||
function isGroupDisabled(group: ToolGroup): boolean {
|
||||
return (
|
||||
group.source === ToolSource.MCP &&
|
||||
!!group.serverId &&
|
||||
!conversationsStore.isMcpServerEnabledForChat(group.serverId)
|
||||
);
|
||||
}
|
||||
let hoveredGroup = $state<string | null>(null);
|
||||
|
||||
const fileUploadTooltipText = 'Add files, system prompt or MCP Servers';
|
||||
|
||||
function getGroupCheckedState(group: (typeof groups)[number]): {
|
||||
checked: boolean;
|
||||
indeterminate: boolean;
|
||||
} {
|
||||
return {
|
||||
checked: toolsStore.isGroupFullyEnabled(group),
|
||||
indeterminate: toolsStore.isGroupPartiallyEnabled(group)
|
||||
};
|
||||
}
|
||||
|
||||
function isServerEnabledForChat(serverId: string): boolean {
|
||||
return conversationsStore.isMcpServerEnabledForChat(serverId);
|
||||
function getFavicon(group: { source: ToolSource; label: string }): string | null {
|
||||
if (group.source !== ToolSource.MCP) return null;
|
||||
|
||||
for (const server of mcpStore.getServersSorted()) {
|
||||
if (mcpStore.getServerLabel(server) === group.label) {
|
||||
return mcpStore.getServerFavicon(server.id);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getEnabledToolCount(group: ToolGroup): number {
|
||||
if (isGroupDisabled(group)) return 0;
|
||||
return group.tools.filter((tool) => toolsStore.isToolEnabled(tool.function.name)).length;
|
||||
}
|
||||
|
||||
function handleToolsSubMenuOpen(open: boolean) {
|
||||
if (open) {
|
||||
if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) {
|
||||
toolsStore.fetchBuiltinTools();
|
||||
}
|
||||
mcpStore.runHealthChecksForServers(mcpStore.getServersSorted().filter((s) => s.enabled));
|
||||
}
|
||||
}
|
||||
async function toggleServerForChat(serverId: string) {
|
||||
await conversationsStore.toggleMcpServerForChat(serverId);
|
||||
}
|
||||
|
||||
function handleMcpSubMenuOpen(open: boolean) {
|
||||
if (open) {
|
||||
mcpSearchQuery = '';
|
||||
mcpStore.runHealthChecksForServers(mcpServers);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMcpPromptClick() {
|
||||
dropdownOpen = false;
|
||||
onMcpPromptClick?.();
|
||||
}
|
||||
|
||||
function handleMcpSettingsClick() {
|
||||
dropdownOpen = false;
|
||||
onMcpSettingsClick?.();
|
||||
}
|
||||
|
||||
function handleMcpResourcesClick() {
|
||||
dropdownOpen = false;
|
||||
onMcpResourcesClick?.();
|
||||
}
|
||||
|
||||
const fileUploadTooltipText = 'Add files, system prompt or MCP Servers';
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1 {className}">
|
||||
|
|
@ -215,6 +247,8 @@
|
|||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
|
||||
<Tooltip.Trigger class="w-full">
|
||||
<DropdownMenu.Item
|
||||
|
|
@ -232,78 +266,159 @@
|
|||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Sub onOpenChange={handleMcpSubMenuOpen}>
|
||||
<DropdownMenu.Sub onOpenChange={handleToolsSubMenuOpen}>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
<McpLogo class="h-4 w-4" />
|
||||
<PencilRuler class="h-4 w-4" />
|
||||
|
||||
<span>MCP Servers</span>
|
||||
<span>Tools</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent class="w-72 pt-0">
|
||||
<DropdownMenuSearchable
|
||||
placeholder="Search servers..."
|
||||
bind:searchValue={mcpSearchQuery}
|
||||
emptyMessage={hasMcpServers ? 'No servers found' : 'No MCP servers configured'}
|
||||
isEmpty={filteredMcpServers.length === 0}
|
||||
>
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
{#each filteredMcpServers as server (server.id)}
|
||||
{@const healthState = mcpStore.getHealthCheckState(server.id)}
|
||||
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
|
||||
{@const isEnabledForChat = isServerEnabledForChat(server.id)}
|
||||
<DropdownMenu.SubContent class="w-72 p-0">
|
||||
{#if totalToolCount === 0 && groups.length === 0}
|
||||
<div class="px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
{#if toolsStore.loading}
|
||||
<Loader2 class="mx-auto mb-1 h-4 w-4 animate-spin" />
|
||||
Loading tools...
|
||||
{:else if toolsStore.error}
|
||||
Failed to load tools
|
||||
{:else}
|
||||
No tools available
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="max-h-80 overflow-y-auto p-2 pr-1">
|
||||
{#each groups as group (group.label)}
|
||||
{@const groupDisabled = isGroupDisabled(group)}
|
||||
{@const isExpanded = expandedGroups.has(group.label)}
|
||||
{@const { checked, indeterminate } = groupDisabled
|
||||
? { checked: false, indeterminate: false }
|
||||
: getGroupCheckedState(group)}
|
||||
{@const favicon = getFavicon(group)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-2 rounded-sm px-2 py-2 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => !hasError && toggleServerForChat(server.id)}
|
||||
disabled={hasError}
|
||||
<Collapsible.Root
|
||||
open={isExpanded}
|
||||
onOpenChange={() => {
|
||||
if (expandedGroups.has(group.label)) {
|
||||
expandedGroups.delete(group.label);
|
||||
} else {
|
||||
expandedGroups.add(group.label);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
{#if mcpStore.getServerFavicon(server.id)}
|
||||
<img
|
||||
src={mcpStore.getServerFavicon(server.id)}
|
||||
alt=""
|
||||
class="h-4 w-4 shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="flex items-center gap-1"
|
||||
onmouseenter={() => {
|
||||
if (groupDisabled) hoveredGroup = group.label;
|
||||
}}
|
||||
onmouseleave={() => {
|
||||
if (hoveredGroup === group.label) hoveredGroup = null;
|
||||
}}
|
||||
>
|
||||
<Collapsible.Trigger
|
||||
class="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50 {groupDisabled
|
||||
? 'opacity-40'
|
||||
: ''}"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDown class="h-3.5 w-3.5 shrink-0" />
|
||||
{:else}
|
||||
<ChevronRight class="h-3.5 w-3.5 shrink-0" />
|
||||
{/if}
|
||||
|
||||
<span class="truncate text-sm">{getServerLabel(server)}</span>
|
||||
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
|
||||
{#if favicon}
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
class="h-4 w-4 shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if hasError}
|
||||
<span
|
||||
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
|
||||
>
|
||||
Error
|
||||
<span class="truncate">{group.label}</span>
|
||||
</span>
|
||||
|
||||
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
{getEnabledToolCount(group)}/{group.tools.length}
|
||||
</span>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
{#if groupDisabled && hoveredGroup === group.label && group.serverId}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Switch
|
||||
checked={false}
|
||||
onclick={(e: MouseEvent) => e.stopPropagation()}
|
||||
onCheckedChange={() =>
|
||||
group.serverId && toggleServerForChat(group.serverId)}
|
||||
class="mr-2 shrink-0"
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="left">
|
||||
<p>Enable {group.label}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Checkbox
|
||||
{checked}
|
||||
{indeterminate}
|
||||
disabled={groupDisabled}
|
||||
onCheckedChange={() => toolsStore.toggleGroup(group)}
|
||||
class="mr-2 h-4 w-4 shrink-0 {groupDisabled ? 'opacity-40' : ''}"
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content side="right">
|
||||
<p>
|
||||
{checked ? 'Disable' : 'Enable'}
|
||||
{group.tools.length} tool{group.tools.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={isEnabledForChat}
|
||||
disabled={hasError}
|
||||
onclick={(e: MouseEvent) => e.stopPropagation()}
|
||||
onCheckedChange={() => toggleServerForChat(server.id)}
|
||||
/>
|
||||
</button>
|
||||
<Collapsible.Content>
|
||||
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
|
||||
{#each group.tools as tool (tool.function.name)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors {groupDisabled
|
||||
? 'pointer-events-none opacity-40'
|
||||
: 'hover:bg-muted/50'}"
|
||||
onclick={() =>
|
||||
!groupDisabled && toolsStore.toggleTool(tool.function.name)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={groupDisabled
|
||||
? false
|
||||
: toolsStore.isToolEnabled(tool.function.name)}
|
||||
disabled={groupDisabled}
|
||||
onCheckedChange={() =>
|
||||
!groupDisabled && toolsStore.toggleTool(tool.function.name)}
|
||||
class="h-4 w-4 shrink-0"
|
||||
/>
|
||||
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-[12px]">
|
||||
{tool.function.name}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={handleMcpSettingsClick}
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
|
||||
<span>Manage MCP Servers</span>
|
||||
</DropdownMenu.Item>
|
||||
{/snippet}
|
||||
</DropdownMenuSearchable>
|
||||
<!-- <div class="px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
{enabledToolCount}/{totalToolCount} tools enabled
|
||||
</div> -->
|
||||
{/if}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,29 @@
|
|||
<script lang="ts">
|
||||
import { Plus, MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
|
||||
import { page } from '$app/state';
|
||||
import {
|
||||
Plus,
|
||||
MessageSquare,
|
||||
Zap,
|
||||
FolderOpen,
|
||||
PencilRuler,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2
|
||||
} from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import { FILE_TYPE_ICONS } from '$lib/constants';
|
||||
import { McpLogo } from '$lib/components/app';
|
||||
import { FILE_TYPE_ICONS, TOOLTIP_DELAY_DURATION } from '$lib/constants';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { toolsStore, type ToolGroup } from '$lib/stores/tools.svelte';
|
||||
import { ToolSource } from '$lib/enums';
|
||||
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { TruncatedText } from '$lib/components/app';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
|
|
@ -15,7 +35,6 @@
|
|||
onFileUpload?: () => void;
|
||||
onSystemPromptClick?: () => void;
|
||||
onMcpPromptClick?: () => void;
|
||||
onMcpSettingsClick?: () => void;
|
||||
onMcpResourcesClick?: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -29,21 +48,71 @@
|
|||
onFileUpload,
|
||||
onSystemPromptClick,
|
||||
onMcpPromptClick,
|
||||
onMcpSettingsClick,
|
||||
onMcpResourcesClick
|
||||
}: Props = $props();
|
||||
|
||||
let sheetOpen = $state(false);
|
||||
|
||||
let expandedGroups = new SvelteSet<string>();
|
||||
let groups = $derived(toolsStore.toolGroups);
|
||||
let activeGroups = $derived(
|
||||
groups.filter(
|
||||
(g) =>
|
||||
g.source !== ToolSource.MCP ||
|
||||
!g.serverId ||
|
||||
conversationsStore.isMcpServerEnabledForChat(g.serverId)
|
||||
)
|
||||
);
|
||||
let totalToolCount = $derived(activeGroups.reduce((n, g) => n + g.tools.length, 0));
|
||||
|
||||
function isGroupDisabled(group: ToolGroup): boolean {
|
||||
return (
|
||||
group.source === ToolSource.MCP &&
|
||||
!!group.serverId &&
|
||||
!conversationsStore.isMcpServerEnabledForChat(group.serverId)
|
||||
);
|
||||
}
|
||||
let hoveredGroup = $state<string | null>(null);
|
||||
|
||||
function getGroupCheckedState(group: (typeof groups)[number]): {
|
||||
checked: boolean;
|
||||
indeterminate: boolean;
|
||||
} {
|
||||
return {
|
||||
checked: toolsStore.isGroupFullyEnabled(group),
|
||||
indeterminate: toolsStore.isGroupPartiallyEnabled(group)
|
||||
};
|
||||
}
|
||||
|
||||
function getFavicon(group: { source: ToolSource; label: string }): string | null {
|
||||
if (group.source !== ToolSource.MCP) return null;
|
||||
|
||||
for (const server of mcpStore.getServersSorted()) {
|
||||
if (mcpStore.getServerLabel(server) === group.label) {
|
||||
return mcpStore.getServerFavicon(server.id);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleToolsSubMenuOpen(open: boolean) {
|
||||
if (open) {
|
||||
if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) {
|
||||
toolsStore.fetchBuiltinTools();
|
||||
}
|
||||
mcpStore.runHealthChecksForServers(mcpStore.getServersSorted().filter((s) => s.enabled));
|
||||
}
|
||||
}
|
||||
async function toggleServerForChat(serverId: string) {
|
||||
await conversationsStore.toggleMcpServerForChat(serverId);
|
||||
}
|
||||
|
||||
function handleMcpPromptClick() {
|
||||
sheetOpen = false;
|
||||
onMcpPromptClick?.();
|
||||
}
|
||||
|
||||
function handleMcpSettingsClick() {
|
||||
onMcpSettingsClick?.();
|
||||
}
|
||||
|
||||
function handleMcpResourcesClick() {
|
||||
sheetOpen = false;
|
||||
onMcpResourcesClick?.();
|
||||
|
|
@ -79,7 +148,7 @@
|
|||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Sheet.Content side="bottom" class="max-h-[85vh] gap-0">
|
||||
<Sheet.Content side="bottom" class="max-h-[85vh] gap-0 overflow-y-auto">
|
||||
<Sheet.Header>
|
||||
<Sheet.Title>Add to chat</Sheet.Title>
|
||||
|
||||
|
|
@ -88,38 +157,50 @@
|
|||
</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
|
||||
<div class="flex flex-col gap-1 overflow-y-auto px-1.5 pb-2">
|
||||
<!-- Images -->
|
||||
<button
|
||||
type="button"
|
||||
class={sheetItemClass}
|
||||
disabled={!hasVisionModality}
|
||||
onclick={handleSheetFileUpload}
|
||||
>
|
||||
<FILE_TYPE_ICONS.image class="h-4 w-4 shrink-0" />
|
||||
<div class="flex flex-col gap-1 px-1.5 pb-2">
|
||||
{#if hasVisionModality}
|
||||
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
|
||||
<FILE_TYPE_ICONS.image class="h-4 w-4 shrink-0" />
|
||||
|
||||
<span>Images</span>
|
||||
<span>Images</span>
|
||||
</button>
|
||||
{:else}
|
||||
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
|
||||
<Tooltip.Trigger>
|
||||
<button type="button" class={sheetItemClass} disabled>
|
||||
<FILE_TYPE_ICONS.image class="h-4 w-4 shrink-0" />
|
||||
|
||||
{#if !hasVisionModality}
|
||||
<span class="ml-auto text-xs text-muted-foreground">Requires vision model</span>
|
||||
{/if}
|
||||
</button>
|
||||
<span>Images</span>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<!-- Audio -->
|
||||
<button
|
||||
type="button"
|
||||
class={sheetItemClass}
|
||||
disabled={!hasAudioModality}
|
||||
onclick={handleSheetFileUpload}
|
||||
>
|
||||
<FILE_TYPE_ICONS.audio class="h-4 w-4 shrink-0" />
|
||||
<Tooltip.Content side="right">
|
||||
<p>Image processing requires a vision model</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
<span>Audio Files</span>
|
||||
{#if hasAudioModality}
|
||||
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
|
||||
<FILE_TYPE_ICONS.audio class="h-4 w-4 shrink-0" />
|
||||
|
||||
{#if !hasAudioModality}
|
||||
<span class="ml-auto text-xs text-muted-foreground">Requires audio model</span>
|
||||
{/if}
|
||||
</button>
|
||||
<span>Audio Files</span>
|
||||
</button>
|
||||
{:else}
|
||||
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
|
||||
<Tooltip.Trigger>
|
||||
<button type="button" class={sheetItemClass} disabled>
|
||||
<FILE_TYPE_ICONS.audio class="h-4 w-4 shrink-0" />
|
||||
|
||||
<span>Audio Files</span>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content side="right">
|
||||
<p>Audio files processing requires an audio model</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
|
||||
<FILE_TYPE_ICONS.text class="h-4 w-4 shrink-0" />
|
||||
|
|
@ -127,27 +208,197 @@
|
|||
<span>Text Files</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
|
||||
<FILE_TYPE_ICONS.pdf class="h-4 w-4 shrink-0" />
|
||||
{#if hasVisionModality}
|
||||
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
|
||||
<FILE_TYPE_ICONS.pdf class="h-4 w-4 shrink-0" />
|
||||
|
||||
<span>PDF Files</span>
|
||||
<span>PDF Files</span>
|
||||
</button>
|
||||
{:else}
|
||||
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
|
||||
<Tooltip.Trigger>
|
||||
<button type="button" class={sheetItemClass} disabled>
|
||||
<FILE_TYPE_ICONS.pdf class="h-4 w-4 shrink-0" />
|
||||
|
||||
{#if !hasVisionModality}
|
||||
<span class="ml-auto text-xs text-muted-foreground">Text-only</span>
|
||||
{/if}
|
||||
<span>PDF Files</span>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content side="right">
|
||||
<p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
|
||||
<Tooltip.Trigger>
|
||||
<button type="button" class={sheetItemClass} onclick={handleSheetSystemPromptClick}>
|
||||
<MessageSquare class="h-4 w-4 shrink-0" />
|
||||
|
||||
<span>System Message</span>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content side="right">
|
||||
<p>
|
||||
{#if !page.params.id}
|
||||
Add custom system message for a new conversation
|
||||
{:else}
|
||||
Inject custom system message at the beginning of the conversation
|
||||
{/if}
|
||||
</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<div class="my-2 border-t"></div>
|
||||
|
||||
<button type="button" class={sheetItemClass} onclick={() => handleToolsSubMenuOpen(true)}>
|
||||
<PencilRuler class="h-4 w-4 shrink-0" />
|
||||
|
||||
<span>Tools</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class={sheetItemClass} onclick={handleSheetSystemPromptClick}>
|
||||
<MessageSquare class="h-4 w-4 shrink-0" />
|
||||
{#if totalToolCount === 0 && groups.length === 0}
|
||||
<div class="px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
{#if toolsStore.loading}
|
||||
<Loader2 class="mx-auto mb-1 h-4 w-4 animate-spin" />
|
||||
Loading tools...
|
||||
{:else if toolsStore.error}
|
||||
Failed to load tools
|
||||
{:else}
|
||||
No tools available
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="max-h-80 overflow-y-auto p-2 pr-1">
|
||||
{#each groups as group (group.label)}
|
||||
{@const groupDisabled = isGroupDisabled(group)}
|
||||
{@const isExpanded = expandedGroups.has(group.label)}
|
||||
{@const { checked, indeterminate } = groupDisabled
|
||||
? { checked: false, indeterminate: false }
|
||||
: getGroupCheckedState(group)}
|
||||
{@const favicon = getFavicon(group)}
|
||||
|
||||
<span>System Message</span>
|
||||
</button>
|
||||
<Collapsible.Root
|
||||
open={isExpanded}
|
||||
onOpenChange={() => {
|
||||
if (expandedGroups.has(group.label)) {
|
||||
expandedGroups.delete(group.label);
|
||||
} else {
|
||||
expandedGroups.add(group.label);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="flex items-center gap-1"
|
||||
onmouseenter={() => {
|
||||
if (groupDisabled) hoveredGroup = group.label;
|
||||
}}
|
||||
onmouseleave={() => {
|
||||
if (hoveredGroup === group.label) hoveredGroup = null;
|
||||
}}
|
||||
>
|
||||
<Collapsible.Trigger
|
||||
class="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50 {groupDisabled
|
||||
? 'opacity-40'
|
||||
: ''}"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDown class="h-3.5 w-3.5 shrink-0" />
|
||||
{:else}
|
||||
<ChevronRight class="h-3.5 w-3.5 shrink-0" />
|
||||
{/if}
|
||||
|
||||
<button type="button" class={sheetItemClass} onclick={handleMcpSettingsClick}>
|
||||
<McpLogo class="h-4 w-4 shrink-0" />
|
||||
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
|
||||
{#if favicon}
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
class="h-4 w-4 shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<span>MCP Servers</span>
|
||||
</button>
|
||||
<span class="truncate">{group.label}</span>
|
||||
</span>
|
||||
|
||||
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
{group.tools.length}
|
||||
</span>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
{#if groupDisabled && hoveredGroup === group.label && group.serverId}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Switch
|
||||
checked={false}
|
||||
onclick={(e: MouseEvent) => e.stopPropagation()}
|
||||
onCheckedChange={() =>
|
||||
group.serverId && toggleServerForChat(group.serverId)}
|
||||
class="mr-2 shrink-0"
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="left">
|
||||
<p>Enable {group.label}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Checkbox
|
||||
{checked}
|
||||
{indeterminate}
|
||||
disabled={groupDisabled}
|
||||
onCheckedChange={() => toolsStore.toggleGroup(group)}
|
||||
class="mr-2 h-4 w-4 shrink-0 {groupDisabled ? 'opacity-40' : ''}"
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="right">
|
||||
<p>
|
||||
{checked ? 'Disable' : 'Enable'}
|
||||
{group.tools.length} tool{group.tools.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Collapsible.Content>
|
||||
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
|
||||
{#each group.tools as tool (tool.function.name)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors {groupDisabled
|
||||
? 'pointer-events-none opacity-40'
|
||||
: 'hover:bg-muted/50'}"
|
||||
onclick={() => !groupDisabled && toolsStore.toggleTool(tool.function.name)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={groupDisabled
|
||||
? false
|
||||
: toolsStore.isToolEnabled(tool.function.name)}
|
||||
disabled={groupDisabled}
|
||||
onCheckedChange={() =>
|
||||
!groupDisabled && toolsStore.toggleTool(tool.function.name)}
|
||||
class="h-4 w-4 shrink-0"
|
||||
/>
|
||||
|
||||
<TruncatedText
|
||||
text={tool.function.name}
|
||||
class="min-w-0 flex-1 truncate"
|
||||
showTooltip={true}
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasMcpPromptsSupport}
|
||||
<button type="button" class={sheetItemClass} onclick={handleMcpPromptClick}>
|
||||
|
|
|
|||
|
|
@ -6,21 +6,20 @@
|
|||
ChatFormActionAttachmentsSheet,
|
||||
ChatFormActionRecord,
|
||||
ChatFormActionSubmit,
|
||||
McpServersSelector,
|
||||
McpActiveServersAvatars,
|
||||
ModelsSelector,
|
||||
ModelsSelectorSheet
|
||||
} from '$lib/components/app';
|
||||
import { SETTINGS_SECTION_TITLES } from '$lib/constants';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { getChatSettingsDialogContext } from '$lib/contexts';
|
||||
import { FileTypeCategory } from '$lib/enums';
|
||||
import { getFileTypeCategory } from '$lib/utils';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
|
||||
import { isRouterMode, serverError } from '$lib/stores/server.svelte';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { activeMessages, conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
|
||||
import { getFileTypeCategory } from '$lib/utils';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
interface Props {
|
||||
canSend?: boolean;
|
||||
|
|
@ -169,8 +168,6 @@
|
|||
selectorModelRef?.open();
|
||||
}
|
||||
|
||||
const chatSettingsDialog = getChatSettingsDialogContext();
|
||||
|
||||
let hasMcpPromptsSupport = $derived.by(() => {
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
|
||||
|
|
@ -197,7 +194,6 @@
|
|||
{onSystemPromptClick}
|
||||
{onMcpPromptClick}
|
||||
{onMcpResourcesClick}
|
||||
onMcpSettingsClick={() => chatSettingsDialog.open(SETTINGS_SECTION_TITLES.MCP)}
|
||||
/>
|
||||
{:else}
|
||||
<ChatFormActionAttachmentsDropdown
|
||||
|
|
@ -210,17 +206,13 @@
|
|||
{onSystemPromptClick}
|
||||
{onMcpPromptClick}
|
||||
{onMcpResourcesClick}
|
||||
onMcpSettingsClick={() => chatSettingsDialog.open(SETTINGS_SECTION_TITLES.MCP)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<McpServersSelector
|
||||
{disabled}
|
||||
onSettingsClick={() => chatSettingsDialog.open(SETTINGS_SECTION_TITLES.MCP)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center gap-1.5">
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<McpActiveServersAvatars onClick={() => goto('/#/settings/mcp')} />
|
||||
|
||||
{#if isMobile.current}
|
||||
<ModelsSelectorSheet
|
||||
disabled={disabled || isOffline}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
import { afterNavigate } from '$app/navigation';
|
||||
import {
|
||||
ChatScreenForm,
|
||||
ChatScreenHeader,
|
||||
ChatMessages,
|
||||
ChatScreenProcessingInfo,
|
||||
DialogEmptyFileAlert,
|
||||
|
|
@ -34,7 +33,7 @@
|
|||
import { parseFilesToMessageExtras, processFilesToChatUploaded } from '$lib/utils/browser-only';
|
||||
import { ErrorDialogType } from '$lib/enums';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade, fly, slide } from 'svelte/transition';
|
||||
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
|
||||
import { Trash2, AlertTriangle, RefreshCw } from '@lucide/svelte';
|
||||
import ChatScreenDragOverlay from './ChatScreenDragOverlay.svelte';
|
||||
|
||||
|
|
@ -342,9 +341,10 @@
|
|||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<ChatScreenHeader />
|
||||
|
||||
{#if !isEmpty}
|
||||
{#if isServerLoading}
|
||||
<!-- Server Loading State -->
|
||||
<ServerLoadingSplash />
|
||||
{:else}
|
||||
<div
|
||||
bind:this={chatScrollContainer}
|
||||
aria-label="Chat interface with file drop zone"
|
||||
|
|
@ -357,25 +357,40 @@
|
|||
role="main"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<ChatMessages
|
||||
class="mb-16 md:mb-24"
|
||||
messages={activeMessages()}
|
||||
onUserAction={() => {
|
||||
autoScroll.enable();
|
||||
autoScroll.scrollToBottom();
|
||||
}}
|
||||
/>
|
||||
{#if !isEmpty}
|
||||
<ChatMessages
|
||||
class="mb-16 md:mb-24"
|
||||
messages={activeMessages()}
|
||||
onUserAction={() => {
|
||||
autoScroll.enable();
|
||||
autoScroll.scrollToBottom();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="pointer-events-none sticky right-0 bottom-4 left-0 mt-auto"
|
||||
in:slide={{ duration: 150, axis: 'y' }}
|
||||
class="pointer-events-none {isEmpty
|
||||
? 'absolute bottom-[calc(50dvh-7rem)]'
|
||||
: 'sticky bottom-4'} right-0 left-0 my-auto transition-all duration-200"
|
||||
>
|
||||
{#if isEmpty}
|
||||
<div class="mb-8 px-4 text-center" use:fadeInView={{ duration: 300 }}>
|
||||
<h1 class="mb-2 text-2xl font-semibold tracking-tight md:text-3xl">Hello there</h1>
|
||||
|
||||
<p class="text-muted-foreground md:text-lg">
|
||||
{serverStore.props?.modalities?.audio
|
||||
? 'Record audio, type a message '
|
||||
: 'Type a message'} or upload files to get started
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ChatScreenProcessingInfo />
|
||||
|
||||
{#if hasPropsError}
|
||||
<div
|
||||
class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1"
|
||||
in:fly={{ y: 10, duration: 250 }}
|
||||
use:fadeInView={{ y: 10, duration: 250 }}
|
||||
>
|
||||
<Alert.Root variant="destructive">
|
||||
<AlertTriangle class="h-4 w-4" />
|
||||
|
|
@ -412,10 +427,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if isServerLoading}
|
||||
<!-- Server Loading State -->
|
||||
<ServerLoadingSplash />
|
||||
{:else}
|
||||
<!-- {:else}
|
||||
<div
|
||||
aria-label="Welcome screen with file drop zone"
|
||||
class="flex h-full items-center justify-center"
|
||||
|
|
@ -425,56 +437,58 @@
|
|||
ondrop={handleDrop}
|
||||
role="main"
|
||||
>
|
||||
<div class="w-full max-w-[48rem] px-4">
|
||||
<div class="mb-10 text-center" in:fade={{ duration: 300 }}>
|
||||
<h1 class="mb-2 text-2xl font-semibold tracking-tight md:text-3xl">llama.cpp</h1>
|
||||
{#key introKey}
|
||||
<div class="w-full max-w-[48rem]">
|
||||
<div class="mb-10 px-4 text-center" use:fadeInView={{ duration: 300 }}>
|
||||
<h1 class="mb-2 text-2xl font-semibold tracking-tight md:text-3xl">Hello there</h1>
|
||||
|
||||
<p class="text-muted-foreground md:text-lg">
|
||||
{serverStore.props?.modalities?.audio
|
||||
? 'Record audio, type a message '
|
||||
: 'Type a message'} or upload files to get started
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if hasPropsError}
|
||||
<div class="mb-4" in:fly={{ y: 10, duration: 250 }}>
|
||||
<Alert.Root variant="destructive">
|
||||
<AlertTriangle class="h-4 w-4" />
|
||||
|
||||
<Alert.Title class="flex items-center justify-between">
|
||||
<span>Server unavailable</span>
|
||||
|
||||
<button
|
||||
onclick={() => serverStore.fetch()}
|
||||
disabled={isServerLoading}
|
||||
class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-2 py-1 text-xs font-medium hover:bg-destructive/30 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
|
||||
{isServerLoading ? 'Retrying...' : 'Retry'}
|
||||
</button>
|
||||
</Alert.Title>
|
||||
|
||||
<Alert.Description>{serverError()}</Alert.Description>
|
||||
</Alert.Root>
|
||||
<p class="text-muted-foreground md:text-lg">
|
||||
{serverStore.props?.modalities?.audio
|
||||
? 'Record audio, type a message '
|
||||
: 'Type a message'} or upload files to get started
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div in:fly={{ y: 10, duration: 250, delay: hasPropsError ? 0 : 300 }}>
|
||||
<ChatScreenForm
|
||||
disabled={hasPropsError}
|
||||
{initialMessage}
|
||||
isLoading={isCurrentConversationLoading}
|
||||
onFileRemove={handleFileRemove}
|
||||
onFileUpload={handleFileUpload}
|
||||
onSend={handleSendMessage}
|
||||
onStop={() => chatStore.stopGeneration()}
|
||||
onSystemPromptAdd={handleSystemPromptAdd}
|
||||
showHelperText
|
||||
bind:uploadedFiles
|
||||
/>
|
||||
{#if hasPropsError}
|
||||
<div class="mb-4" use:fadeInView={{ duration: 250, y: 10 }}>
|
||||
<Alert.Root variant="destructive">
|
||||
<AlertTriangle class="h-4 w-4" />
|
||||
|
||||
<Alert.Title class="flex items-center justify-between">
|
||||
<span>Server unavailable</span>
|
||||
|
||||
<button
|
||||
onclick={() => serverStore.fetch()}
|
||||
disabled={isServerLoading}
|
||||
class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-2 py-1 text-xs font-medium hover:bg-destructive/30 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
|
||||
{isServerLoading ? 'Retrying...' : 'Retry'}
|
||||
</button>
|
||||
</Alert.Title>
|
||||
|
||||
<Alert.Description>{serverError()}</Alert.Description>
|
||||
</Alert.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<ChatScreenForm
|
||||
disabled={hasPropsError}
|
||||
{initialMessage}
|
||||
isLoading={isCurrentConversationLoading}
|
||||
onFileRemove={handleFileRemove}
|
||||
onFileUpload={handleFileUpload}
|
||||
onSend={handleSendMessage}
|
||||
onStop={() => chatStore.stopGeneration()}
|
||||
onSystemPromptAdd={handleSystemPromptAdd}
|
||||
showHelperText
|
||||
bind:uploadedFiles
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/key}
|
||||
</div> -->
|
||||
{/if}
|
||||
|
||||
<!-- File Upload Error Alert Dialog -->
|
||||
|
|
@ -575,21 +589,3 @@
|
|||
open={Boolean(activeErrorDialog)}
|
||||
type={activeErrorDialog?.type ?? ErrorDialogType.SERVER}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.conversation-chat-form {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
z-index: -1;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 2.375rem;
|
||||
background-color: var(--background);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<header
|
||||
class="pointer-events-none fixed top-0 right-0 left-0 z-50 flex items-center justify-end p-2 duration-200 ease-linear md:p-4 {sidebar.open
|
||||
? 'md:left-[var(--sidebar-width)]'
|
||||
: ''}"
|
||||
: ''} {sidebar.isResizing ? '!duration-0' : ''}"
|
||||
>
|
||||
<div class="pointer-events-auto flex items-center space-x-2">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -7,16 +7,10 @@
|
|||
Monitor,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Database
|
||||
ListRestart,
|
||||
Sliders
|
||||
} from '@lucide/svelte';
|
||||
import {
|
||||
ChatSettingsFooter,
|
||||
ChatSettingsImportExportTab,
|
||||
ChatSettingsFields,
|
||||
McpLogo,
|
||||
McpServersSettings
|
||||
} from '$lib/components/app';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area';
|
||||
import { ChatSettingsFooter, ChatSettingsFields } from '$lib/components/app';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import {
|
||||
SETTINGS_SECTION_TITLES,
|
||||
|
|
@ -29,14 +23,16 @@
|
|||
import { setMode } from 'mode-watcher';
|
||||
import { ColorMode } from '$lib/enums/ui';
|
||||
import { SettingsFieldType } from '$lib/enums/settings';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
onSave?: () => void;
|
||||
initialSection?: SettingsSectionTitle;
|
||||
}
|
||||
|
||||
let { onSave, initialSection }: Props = $props();
|
||||
let { class: className, onSave, initialSection }: Props = $props();
|
||||
|
||||
const settingSections: Array<{
|
||||
fields: SettingsFieldConfig[];
|
||||
|
|
@ -45,7 +41,7 @@
|
|||
}> = [
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.GENERAL,
|
||||
icon: Settings,
|
||||
icon: Sliders,
|
||||
fields: [
|
||||
{
|
||||
key: SETTINGS_KEYS.THEME,
|
||||
|
|
@ -101,6 +97,11 @@
|
|||
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',
|
||||
|
|
@ -133,13 +134,13 @@
|
|||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.AUTO_SHOW_SIDEBAR_ON_NEW_CHAT,
|
||||
label: 'Auto-show sidebar on new chat',
|
||||
key: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES,
|
||||
label: 'Show raw model names',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES,
|
||||
label: 'Show raw model names',
|
||||
key: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS,
|
||||
label: 'Always show agentic turns in conversation',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
}
|
||||
]
|
||||
|
|
@ -257,33 +258,18 @@
|
|||
]
|
||||
},
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.IMPORT_EXPORT,
|
||||
icon: Database,
|
||||
fields: []
|
||||
},
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.MCP,
|
||||
icon: McpLogo,
|
||||
title: SETTINGS_SECTION_TITLES.AGENTIC,
|
||||
icon: ListRestart,
|
||||
fields: [
|
||||
{
|
||||
key: SETTINGS_KEYS.AGENTIC_MAX_TURNS,
|
||||
label: 'Agentic loop max turns',
|
||||
label: 'Agentic turns',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS,
|
||||
label: 'Always show agentic turns in conversation',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.AGENTIC_MAX_TOOL_PREVIEW_LINES,
|
||||
label: 'Max lines per tool preview',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS,
|
||||
label: 'Show tool call in progress',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -442,119 +428,116 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full 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">
|
||||
{#each settingSections as section (section.title)}
|
||||
<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 {activeSection ===
|
||||
section.title
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
onclick={() => (activeSection = section.title)}
|
||||
>
|
||||
<section.icon class="h-4 w-4" />
|
||||
|
||||
<span class="ml-2">{section.title}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Header with Horizontal Scrollable Menu -->
|
||||
<div class="flex flex-col pt-6 md:hidden">
|
||||
<div class="border-b border-border/30 pt-4 md:py-4">
|
||||
<!-- Horizontal Scrollable Category Menu with Navigation -->
|
||||
<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 {canScrollLeft
|
||||
? 'opacity-100'
|
||||
: 'pointer-events-none opacity-0'}"
|
||||
onclick={scrollLeft}
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="scrollbar-hide overflow-x-auto py-2"
|
||||
bind:this={scrollContainer}
|
||||
onscroll={updateScrollButtons}
|
||||
>
|
||||
<div class="flex min-w-max gap-2">
|
||||
{#each settingSections as section (section.title)}
|
||||
<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 {activeSection ===
|
||||
section.title
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
onclick={(e: MouseEvent) => {
|
||||
activeSection = section.title;
|
||||
scrollToCenter(e.currentTarget as HTMLElement);
|
||||
}}
|
||||
>
|
||||
<section.icon class="h-4 w-4 flex-shrink-0" />
|
||||
<span>{section.title}</span>
|
||||
</button>
|
||||
{/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 {canScrollRight
|
||||
? 'opacity-100'
|
||||
: 'pointer-events-none opacity-0'}"
|
||||
onclick={scrollRight}
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
<div class="flex h-full flex-col overflow-y-auto {className} w-full" in:fade={{ duration: 150 }}>
|
||||
<div class="flex flex-1 flex-col gap-4 md:flex-row">
|
||||
<!-- Desktop Sidebar -->
|
||||
<div class="sticky top-0 hidden w-64 flex-col self-start bg-background pt-8 pb-4 md:flex">
|
||||
<div class="flex items-center gap-2 pb-12">
|
||||
<Settings class="h-6 w-6" />
|
||||
<h1 class="text-2xl font-semibold">Settings</h1>
|
||||
</div>
|
||||
<nav class="space-y-1">
|
||||
{#each settingSections as section (section.title)}
|
||||
<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 {activeSection ===
|
||||
section.title
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
onclick={() => (activeSection = section.title)}
|
||||
>
|
||||
<section.icon class="h-4 w-4" />
|
||||
|
||||
<span class="ml-2">{section.title}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea class="max-h-[calc(100dvh-13.5rem)] flex-1 md:max-h-[calc(100vh-13.5rem)]">
|
||||
<div class="space-y-6 p-4 md:p-6">
|
||||
<div class="grid">
|
||||
<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" />
|
||||
<!-- Mobile Header with Horizontal Scrollable Menu -->
|
||||
<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" />
|
||||
|
||||
<h3 class="text-lg font-semibold">{currentSection.title}</h3>
|
||||
</div>
|
||||
<h1 class="text-xl font-semibold md:text-2xl">Settings</h1>
|
||||
</div>
|
||||
|
||||
{#if currentSection.title === SETTINGS_SECTION_TITLES.IMPORT_EXPORT}
|
||||
<ChatSettingsImportExportTab />
|
||||
{:else if currentSection.title === SETTINGS_SECTION_TITLES.MCP}
|
||||
<div class="space-y-6">
|
||||
<ChatSettingsFields
|
||||
fields={currentSection.fields}
|
||||
{localConfig}
|
||||
onConfigChange={handleConfigChange}
|
||||
onThemeChange={handleThemeChange}
|
||||
/>
|
||||
<div class="border-b border-border/30 py-2">
|
||||
<!-- Horizontal Scrollable Category Menu with Navigation -->
|
||||
<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 {canScrollLeft
|
||||
? 'opacity-100'
|
||||
: 'pointer-events-none opacity-0'}"
|
||||
onclick={scrollLeft}
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div class="border-t border-border/30 pt-6">
|
||||
<McpServersSettings />
|
||||
<div
|
||||
class="scrollbar-hide overflow-x-auto py-2"
|
||||
bind:this={scrollContainer}
|
||||
onscroll={updateScrollButtons}
|
||||
>
|
||||
<div class="flex min-w-max gap-2">
|
||||
{#each settingSections as section (section.title)}
|
||||
<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 {activeSection ===
|
||||
section.title
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
onclick={(e: MouseEvent) => {
|
||||
activeSection = section.title;
|
||||
scrollToCenter(e.currentTarget as HTMLElement);
|
||||
}}
|
||||
>
|
||||
<section.icon class="h-4 w-4 flex-shrink-0" />
|
||||
<span>{section.title}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<ChatSettingsFields
|
||||
fields={currentSection.fields}
|
||||
{localConfig}
|
||||
onConfigChange={handleConfigChange}
|
||||
onThemeChange={handleThemeChange}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 border-t pt-6">
|
||||
<p class="text-xs text-muted-foreground">Settings are saved in browser's localStorage</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<ChatSettingsFooter onReset={handleReset} onSave={handleSave} />
|
||||
<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 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>
|
||||
|
||||
{#if currentSection.fields}
|
||||
<div class="space-y-6">
|
||||
<ChatSettingsFields
|
||||
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>
|
||||
|
||||
<ChatSettingsFooter onReset={handleReset} onSave={handleSave} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="relative w-full md:max-w-md">
|
||||
<div class="relative w-full">
|
||||
<Input
|
||||
id={field.key}
|
||||
value={currentValue}
|
||||
|
|
@ -117,7 +117,7 @@
|
|||
value={String(localConfig[field.key] ?? '')}
|
||||
onchange={(e) => onConfigChange(field.key, e.currentTarget.value)}
|
||||
placeholder=""
|
||||
class="min-h-[10rem] w-full md:max-w-2xl"
|
||||
class="min-h-[10rem] w-full md:max-w-3xl"
|
||||
/>
|
||||
|
||||
{#if field.help || SETTING_CONFIG_INFO[field.key]}
|
||||
|
|
@ -176,7 +176,7 @@
|
|||
}
|
||||
}}
|
||||
>
|
||||
<div class="relative w-full md:w-auto md:max-w-md">
|
||||
<div class="relative w-full md:w-auto">
|
||||
<Select.Trigger class="w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if selectedOption?.icon}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="flex justify-between border-t border-border/30 p-6">
|
||||
<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" />
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { Download, Upload, Trash2 } from '@lucide/svelte';
|
||||
import { Download, Upload, Trash2, Database } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { DialogConversationSelection, DialogConfirmation } from '$lib/components/app';
|
||||
import { createMessageCountMap } from '$lib/utils';
|
||||
import { ISO_DATE_TIME_SEPARATOR } from '$lib/constants';
|
||||
import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let exportedConversations = $state<DatabaseConversation[]>([]);
|
||||
let importedConversations = $state<DatabaseConversation[]>([]);
|
||||
|
|
@ -174,10 +175,16 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-6" in:fade={{ duration: 150 }}>
|
||||
<div class="flex items-center gap-2 pb-4">
|
||||
<Database class="h-5 w-5 md:h-6 md:w-6" />
|
||||
|
||||
<h1 class="text-xl font-semibold md:text-2xl">Import / Export</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid">
|
||||
<h4 class="mb-2 text-sm font-medium">Export Conversations</h4>
|
||||
<h4 class="mt-0 mb-2 text-sm font-medium">Export Conversations</h4>
|
||||
|
||||
<p class="mb-4 text-sm text-muted-foreground">
|
||||
Download all your conversations as a JSON file. This includes all messages, attachments, and
|
||||
|
|
@ -218,7 +225,7 @@
|
|||
</div>
|
||||
|
||||
<div class="grid border-t border-border/30 pt-4">
|
||||
<h4 class="mb-2 text-sm font-medium">Import Conversations</h4>
|
||||
<h4 class="mt-0 mb-2 text-sm font-medium">Import Conversations</h4>
|
||||
|
||||
<p class="mb-4 text-sm text-muted-foreground">
|
||||
Import one or more conversations from a previously exported JSON file. This will merge with
|
||||
|
|
@ -258,7 +265,7 @@
|
|||
</div>
|
||||
|
||||
<div class="grid border-t border-border/30 pt-4">
|
||||
<h4 class="mb-2 text-sm font-medium text-destructive">Delete All Conversations</h4>
|
||||
<h4 class="mt-0 mb-2 text-sm font-medium text-destructive">Delete All Conversations</h4>
|
||||
|
||||
<p class="mb-4 text-sm text-muted-foreground">
|
||||
Permanently delete all conversations and their messages. This action cannot be undone.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { Trash2, Pencil } from '@lucide/svelte';
|
||||
import { Trash2, Pencil, X } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ChatSidebarConversationItem, DialogConfirmation } from '$lib/components/app';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
|
|
@ -16,6 +17,8 @@
|
|||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { getPreviewText } from '$lib/utils';
|
||||
import ChatSidebarActions from './ChatSidebarActions.svelte';
|
||||
import ChatSidebarFooter from './ChatSidebarFooter.svelte';
|
||||
import { APP_NAME } from '$lib/constants';
|
||||
|
||||
const sidebar = Sidebar.useSidebar();
|
||||
|
||||
|
|
@ -107,10 +110,19 @@
|
|||
}
|
||||
}
|
||||
|
||||
let chatSidebarActions: { activateSearch?: () => void } | undefined = $state();
|
||||
|
||||
export function activateSearchMode() {
|
||||
isSearchModeActive = true;
|
||||
chatSidebarActions?.activateSearch?.();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!sidebar.open) {
|
||||
isSearchModeActive = false;
|
||||
searchQuery = '';
|
||||
}
|
||||
});
|
||||
|
||||
export function editActiveConversation() {
|
||||
if (currentChatId) {
|
||||
const activeConversation = filteredConversations.find((conv) => conv.id === currentChatId);
|
||||
|
|
@ -130,6 +142,7 @@
|
|||
searchQuery = '';
|
||||
}
|
||||
|
||||
handleMobileSidebarItemClick();
|
||||
await goto(`#/chat/${id}`);
|
||||
}
|
||||
|
||||
|
|
@ -138,60 +151,85 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<ScrollArea class="h-[100vh]">
|
||||
<Sidebar.Header class=" top-0 z-10 gap-4 bg-sidebar/50 p-4 pb-2 backdrop-blur-lg md:sticky">
|
||||
<a href="#/" onclick={handleMobileSidebarItemClick}>
|
||||
<h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">llama.cpp</h1>
|
||||
</a>
|
||||
<div class="flex h-full flex-col">
|
||||
<ScrollArea class="h-full flex-1">
|
||||
<Sidebar.Header
|
||||
class=" sticky top-0 z-10 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}>
|
||||
<h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">{APP_NAME}</h1>
|
||||
</a>
|
||||
|
||||
<ChatSidebarActions {handleMobileSidebarItemClick} bind:isSearchModeActive bind:searchQuery />
|
||||
</Sidebar.Header>
|
||||
<Button
|
||||
class="rounded-full md:hidden"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => sidebar.toggle()}
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
<span class="sr-only">Close sidebar</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Sidebar.Group class="mt-2 space-y-2 p-0 px-4">
|
||||
{#if (filteredConversations.length > 0 && isSearchModeActive) || !isSearchModeActive}
|
||||
<Sidebar.GroupLabel>
|
||||
{isSearchModeActive ? 'Search results' : 'Conversations'}
|
||||
</Sidebar.GroupLabel>
|
||||
{/if}
|
||||
<ChatSidebarActions
|
||||
bind:this={chatSidebarActions}
|
||||
{handleMobileSidebarItemClick}
|
||||
bind:isSearchModeActive
|
||||
bind:searchQuery
|
||||
/>
|
||||
</Sidebar.Header>
|
||||
|
||||
<Sidebar.GroupContent>
|
||||
<Sidebar.Menu>
|
||||
{#each conversationTree as { conversation, depth } (conversation.id)}
|
||||
<Sidebar.MenuItem class="mb-1 p-0">
|
||||
<ChatSidebarConversationItem
|
||||
conversation={{
|
||||
id: conversation.id,
|
||||
name: conversation.name,
|
||||
lastModified: conversation.lastModified,
|
||||
currNode: conversation.currNode,
|
||||
forkedFromConversationId: conversation.forkedFromConversationId
|
||||
}}
|
||||
{depth}
|
||||
{handleMobileSidebarItemClick}
|
||||
isActive={currentChatId === conversation.id}
|
||||
onSelect={selectConversation}
|
||||
onEdit={handleEditConversation}
|
||||
onDelete={handleDeleteConversation}
|
||||
onStop={handleStopGeneration}
|
||||
/>
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
<Sidebar.Group class="mt-2 h-[calc(100vh-21rem)] space-y-2 p-0 px-3">
|
||||
{#if (filteredConversations.length > 0 && isSearchModeActive) || !isSearchModeActive}
|
||||
<Sidebar.GroupLabel>
|
||||
{isSearchModeActive ? 'Search results' : 'Conversations'}
|
||||
</Sidebar.GroupLabel>
|
||||
{/if}
|
||||
|
||||
{#if conversationTree.length === 0}
|
||||
<div class="px-2 py-4 text-center">
|
||||
<p class="mb-4 p-4 text-sm text-muted-foreground">
|
||||
{searchQuery.length > 0
|
||||
? 'No results found'
|
||||
: isSearchModeActive
|
||||
? 'Start typing to see results'
|
||||
: 'No conversations yet'}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.GroupContent>
|
||||
</Sidebar.Group>
|
||||
</ScrollArea>
|
||||
<Sidebar.GroupContent>
|
||||
<Sidebar.Menu>
|
||||
{#each conversationTree as { conversation, depth } (conversation.id)}
|
||||
<Sidebar.MenuItem class="mb-1 p-0">
|
||||
<ChatSidebarConversationItem
|
||||
conversation={{
|
||||
id: conversation.id,
|
||||
name: conversation.name,
|
||||
lastModified: conversation.lastModified,
|
||||
currNode: conversation.currNode,
|
||||
forkedFromConversationId: conversation.forkedFromConversationId
|
||||
}}
|
||||
{depth}
|
||||
{handleMobileSidebarItemClick}
|
||||
isActive={currentChatId === conversation.id}
|
||||
onSelect={selectConversation}
|
||||
onEdit={handleEditConversation}
|
||||
onDelete={handleDeleteConversation}
|
||||
onStop={handleStopGeneration}
|
||||
/>
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
|
||||
{#if conversationTree.length === 0}
|
||||
<div class="px-2 py-4 text-center">
|
||||
<p class="mb-4 p-4 text-sm text-muted-foreground">
|
||||
{searchQuery.length > 0
|
||||
? 'No results found'
|
||||
: isSearchModeActive
|
||||
? 'Start typing to see results'
|
||||
: 'No conversations yet'}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.GroupContent>
|
||||
</Sidebar.Group>
|
||||
|
||||
<Sidebar.Footer class="sticky bottom-0 z-10 mt-2 bg-sidebar/50 p-3 backdrop-blur-lg">
|
||||
<ChatSidebarFooter />
|
||||
</Sidebar.Footer>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<DialogConfirmation
|
||||
bind:open={showDeleteDialog}
|
||||
|
|
|
|||
|
|
@ -1,61 +1,50 @@
|
|||
<script lang="ts">
|
||||
import { Search, SquarePen, X } from '@lucide/svelte';
|
||||
import { Search, SquarePen } from '@lucide/svelte';
|
||||
import { KeyboardShortcutInfo } from '$lib/components/app';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { McpLogo } from '$lib/components/app';
|
||||
import { SETTINGS_SECTION_TITLES } from '$lib/constants';
|
||||
import { getChatSettingsDialogContext } from '$lib/contexts';
|
||||
import { SearchInput } from '$lib/components/app';
|
||||
|
||||
interface Props {
|
||||
handleMobileSidebarItemClick: () => void;
|
||||
isSearchModeActive: boolean;
|
||||
searchQuery: string;
|
||||
isCancelAlwaysVisible?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
handleMobileSidebarItemClick,
|
||||
isSearchModeActive = $bindable(),
|
||||
searchQuery = $bindable()
|
||||
searchQuery = $bindable(),
|
||||
isCancelAlwaysVisible = false
|
||||
}: Props = $props();
|
||||
|
||||
let searchInput: HTMLInputElement | null = $state(null);
|
||||
|
||||
const chatSettingsDialog = getChatSettingsDialogContext();
|
||||
let searchInputRef = $state<HTMLInputElement | null>(null);
|
||||
|
||||
function handleSearchModeDeactivate() {
|
||||
isSearchModeActive = false;
|
||||
searchQuery = '';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isSearchModeActive) {
|
||||
searchInput?.focus();
|
||||
}
|
||||
});
|
||||
export function activateSearch() {
|
||||
isSearchModeActive = true;
|
||||
// Focus after Svelte renders the input
|
||||
queueMicrotask(() => searchInputRef?.focus());
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="my-1 space-y-1">
|
||||
{#if isSearchModeActive}
|
||||
<div class="relative">
|
||||
<Search class="absolute top-2.5 left-2 h-4 w-4 text-muted-foreground" />
|
||||
|
||||
<Input
|
||||
bind:ref={searchInput}
|
||||
bind:value={searchQuery}
|
||||
onkeydown={(e) => e.key === 'Escape' && handleSearchModeDeactivate()}
|
||||
placeholder="Search conversations..."
|
||||
class="pl-8"
|
||||
/>
|
||||
|
||||
<X
|
||||
class="cursor-pointertext-muted-foreground absolute top-2.5 right-2 h-4 w-4"
|
||||
onclick={handleSearchModeDeactivate}
|
||||
/>
|
||||
</div>
|
||||
<SearchInput
|
||||
bind:value={searchQuery}
|
||||
bind:ref={searchInputRef}
|
||||
onClose={handleSearchModeDeactivate}
|
||||
onKeyDown={(e) => e.key === 'Escape' && handleSearchModeDeactivate()}
|
||||
placeholder="Search conversations..."
|
||||
{isCancelAlwaysVisible}
|
||||
/>
|
||||
{:else}
|
||||
<Button
|
||||
class="w-full justify-between backdrop-blur-none! hover:[&>kbd]:opacity-100"
|
||||
class="w-full justify-between px-2 backdrop-blur-none! hover:[&>kbd]:opacity-100"
|
||||
href="?new_chat=true#/"
|
||||
onclick={handleMobileSidebarItemClick}
|
||||
variant="ghost"
|
||||
|
|
@ -70,10 +59,8 @@
|
|||
</Button>
|
||||
|
||||
<Button
|
||||
class="w-full justify-between backdrop-blur-none! hover:[&>kbd]:opacity-100"
|
||||
onclick={() => {
|
||||
isSearchModeActive = true;
|
||||
}}
|
||||
class="w-full justify-between px-2 backdrop-blur-none! hover:[&>kbd]:opacity-100"
|
||||
onclick={activateSearch}
|
||||
variant="ghost"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
@ -84,19 +71,5 @@
|
|||
|
||||
<KeyboardShortcutInfo keys={['cmd', 'k']} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
class="w-full justify-between backdrop-blur-none! hover:[&>kbd]:opacity-100"
|
||||
onclick={() => {
|
||||
chatSettingsDialog.open(SETTINGS_SECTION_TITLES.MCP);
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<McpLogo class="h-4 w-4" />
|
||||
|
||||
MCP Servers
|
||||
</div>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
<script lang="ts">
|
||||
import { Database, Settings } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { McpLogo } from '$lib/components/app';
|
||||
// import { getChatSettingsDialogContext, getMcpServersDialogContext } from '$lib/contexts';
|
||||
import { page } from '$app/state';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar';
|
||||
|
||||
// const chatSettingsDialog = getChatSettingsDialogContext();
|
||||
// const mcpServersDialog = getMcpServersDialogContext();
|
||||
|
||||
const sidebar = Sidebar.useSidebar();
|
||||
|
||||
let isMcpActive = $derived(page.route.id === '/settings/mcp');
|
||||
let isSettingsActive = $derived(page.route.id === '/settings/chat');
|
||||
let isImportExportActive = $derived(page.route.id === '/settings/import-export');
|
||||
|
||||
function handleMobileSidebarItemClick() {
|
||||
if (sidebar.isMobile) {
|
||||
sidebar.toggle();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-1 pt-0">
|
||||
<Button
|
||||
class="w-full justify-between px-2 backdrop-blur-none! hover:[&>kbd]:opacity-100 {isMcpActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: ''}"
|
||||
href="#/settings/mcp"
|
||||
onclick={handleMobileSidebarItemClick}
|
||||
variant="ghost"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<McpLogo class="h-4 w-4" />
|
||||
|
||||
MCP Servers
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
class="w-full justify-between px-2 backdrop-blur-none! hover:[&>kbd]:opacity-100 {isImportExportActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: ''}"
|
||||
href="#/settings/import-export"
|
||||
onclick={handleMobileSidebarItemClick}
|
||||
variant="ghost"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Database class="h-4 w-4" />
|
||||
|
||||
Import / Export
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
class="w-full justify-between px-2 backdrop-blur-none! hover:[&>kbd]:opacity-100 {isSettingsActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: ''}"
|
||||
href="#/settings/chat"
|
||||
onclick={handleMobileSidebarItemClick}
|
||||
variant="ghost"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Settings class="h-4 w-4" />
|
||||
|
||||
Settings
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -683,7 +683,7 @@ export { default as ChatScreenProcessingInfo } from './ChatScreen/ChatScreenProc
|
|||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as ChatSettings } from './ChatSettings/ChatSettings.svelte';
|
||||
export { default as ChatSettings } from '../settings/SettingsChat.svelte';
|
||||
|
||||
/**
|
||||
* Footer with save/cancel buttons for settings panel. Positioned at bottom
|
||||
|
|
@ -704,7 +704,7 @@ export { default as ChatSettingsFields } from './ChatSettings/ChatSettingsFields
|
|||
* to export all conversations as JSON file and import from JSON file.
|
||||
* Handles file download/upload and data validation.
|
||||
*/
|
||||
export { default as ChatSettingsImportExportTab } from './ChatSettings/ChatSettingsImportExportTab.svelte';
|
||||
export { default as ChatSettingsImportExportTab } from '../settings/SettingsImportExport.svelte';
|
||||
|
||||
/**
|
||||
* Badge indicating parameter source for sampling settings. Shows one of:
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@
|
|||
import type { SettingsSectionTitle } from '$lib/constants';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
open?: boolean;
|
||||
initialSection?: SettingsSectionTitle;
|
||||
}
|
||||
|
||||
let { onOpenChange, open = false, initialSection }: Props = $props();
|
||||
let { class: className = '', onOpenChange, open = false, initialSection }: Props = $props();
|
||||
|
||||
let chatSettingsRef: ChatSettings | undefined = $state();
|
||||
|
||||
|
|
@ -31,7 +32,7 @@
|
|||
<Dialog.Root {open} onOpenChange={handleClose}>
|
||||
<Dialog.Content
|
||||
class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] max-w-4xl! flex-col gap-0 rounded-none
|
||||
p-0 md:h-[64vh] md:max-h-[64vh] md:min-h-0 md:rounded-lg"
|
||||
p-0 md:h-[64vh] md:max-h-[64vh] md:min-h-0 md:rounded-lg {className}"
|
||||
>
|
||||
<ChatSettings bind:this={chatSettingsRef} onSave={handleSave} {initialSection} />
|
||||
</Dialog.Content>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { ChatSettingsImportExportTab } from '$lib/components/app/chat';
|
||||
|
||||
interface Props {
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
let { onOpenChange, open = false }: Props = $props();
|
||||
|
||||
function handleClose() {
|
||||
onOpenChange?.(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root {open} onOpenChange={handleClose}>
|
||||
<Dialog.Content
|
||||
class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] max-w-4xl! flex-col gap-0 rounded-none
|
||||
p-0 md:h-[80vh] md:max-h-[80vh] md:min-h-0 md:rounded-lg"
|
||||
>
|
||||
<div class="grid gap-2 border-b border-border/30 p-4 md:p-6">
|
||||
<Dialog.Title class="text-lg font-semibold">Import/Export Conversations</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-muted-foreground">
|
||||
Export your conversations to a JSON file or import previously exported conversations.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 py-6">
|
||||
<ChatSettingsImportExportTab />
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
open?: boolean;
|
||||
}
|
||||
|
||||
let { onOpenChange, open = $bindable(false) }: Props = $props();
|
||||
let { onOpenChange, open = false }: Props = $props();
|
||||
|
||||
function handleClose() {
|
||||
onOpenChange?.(false);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,50 @@
|
|||
*/
|
||||
export { default as DialogChatSettings } from './DialogChatSettings.svelte';
|
||||
|
||||
/**
|
||||
* **DialogMcpServersSettings** - MCP servers management dialog
|
||||
*
|
||||
* Modal dialog for managing MCP servers with dedicated context.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Uses context-based state management
|
||||
* - Provides `open()` method via context
|
||||
* - Contains McpServersSettings component
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <!-- In parent component -->
|
||||
* <DialogMcpServersSettings />
|
||||
*
|
||||
* <!-- Trigger via context -->
|
||||
* {#const mcpDialog = getMcpServersDialogContext()}
|
||||
* <Button onclick={() => mcpDialog.open()}>Manage MCP Servers</Button>
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogMcpServersSettings } from './DialogMcpServersSettings.svelte';
|
||||
|
||||
/**
|
||||
* **DialogChatSettingsImportExport** - Import/Export conversations dialog
|
||||
*
|
||||
* Modal dialog for importing and exporting conversations with dedicated context.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Uses context-based state management
|
||||
* - Provides `open()` method via context
|
||||
* - Contains ChatSettingsImportExportTab component
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <!-- In parent component -->
|
||||
* <DialogChatSettingsImportExport />
|
||||
*
|
||||
* <!-- Trigger via context -->
|
||||
* {#const importExportDialog = getImportExportDialogContext()}
|
||||
* <Button onclick={() => importExportDialog.open()}>Import/Export</Button>
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogChatSettingsImportExport } from './DialogChatSettingsImportExport.svelte';
|
||||
|
||||
/**
|
||||
*
|
||||
* CONFIRMATION DIALOGS
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
class?: string;
|
||||
id?: string;
|
||||
ref?: HTMLInputElement | null;
|
||||
isCancelAlwaysVisible?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -21,10 +22,11 @@
|
|||
onKeyDown,
|
||||
class: className,
|
||||
id,
|
||||
ref = $bindable(null)
|
||||
ref = $bindable(null),
|
||||
isCancelAlwaysVisible = false
|
||||
}: Props = $props();
|
||||
|
||||
let showClearButton = $derived(!!value || !!onClose);
|
||||
let showClearButton = $derived(isCancelAlwaysVisible || !!value || !!onClose);
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
|
|
@ -63,7 +65,7 @@
|
|||
{#if showClearButton}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-1/2 right-3 -translate-y-1/2 transform text-muted-foreground transition-colors hover:text-foreground"
|
||||
class="absolute top-1/2 right-3 -translate-y-1/2 transform cursor-pointer text-muted-foreground transition-colors hover:text-foreground"
|
||||
onclick={handleClear}
|
||||
aria-label={value ? 'Clear search' : 'Close'}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export * from './dialogs';
|
|||
export * from './forms';
|
||||
export * from './mcp';
|
||||
export * from './misc';
|
||||
export * from './settings';
|
||||
export * from './models';
|
||||
export * from './navigation';
|
||||
export * from './server';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
|
|
@ -7,9 +8,10 @@
|
|||
|
||||
interface Props {
|
||||
class?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
let { class: className = '' }: Props = $props();
|
||||
let { class: className = '', onClick }: Props = $props();
|
||||
|
||||
let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
|
||||
let enabledMcpServersForChat = $derived(
|
||||
|
|
@ -28,30 +30,41 @@
|
|||
let mcpFavicons = $derived(
|
||||
healthyEnabledMcpServers
|
||||
.slice(0, MAX_DISPLAYED_MCP_AVATARS)
|
||||
.map((s) => ({ id: s.id, url: mcpStore.getServerFavicon(s.id) }))
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
name: mcpStore.getServerDisplayName(s.id),
|
||||
url: mcpStore.getServerFavicon(s.id)
|
||||
}))
|
||||
.filter((f) => f.url !== null)
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if hasEnabledMcpServers && mcpFavicons.length > 0}
|
||||
<div class={cn('inline-flex items-center gap-1.5', className)}>
|
||||
<button class={cn('inline-flex items-center gap-1.5', className)} onclick={onClick}>
|
||||
<div class="flex -space-x-1">
|
||||
{#each mcpFavicons as favicon (favicon.id)}
|
||||
<div class="box-shadow-lg overflow-hidden rounded-full bg-muted ring-1 ring-muted">
|
||||
<img
|
||||
src={favicon.url}
|
||||
alt=""
|
||||
class="h-4 w-4"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<div class="box-shadow-lg overflow-hidden rounded-full bg-muted ring-1 ring-muted">
|
||||
<img
|
||||
src={favicon.url}
|
||||
alt=""
|
||||
class="h-4 w-4"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>{favicon.name}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if extraServersCount > 0}
|
||||
<span class="text-xs text-muted-foreground">+{extraServersCount}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@
|
|||
</div>
|
||||
|
||||
{#if capabilities || transportType}
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
{#if transportType}
|
||||
{@const TransportIcon = MCP_TRANSPORT_ICONS[transportType]}
|
||||
<Badge variant="outline" class="h-5 gap-1 px-1.5 text-[10px]">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { Settings } from '@lucide/svelte';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { DropdownMenuSearchable, McpActiveServersAvatars } from '$lib/components/app';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
|
|
@ -66,7 +67,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
{#if hasMcpServers && hasEnabledMcpServers && mcpFavicons.length > 0}
|
||||
{#if hasMcpServers}
|
||||
<DropdownMenu.Root
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
|
|
@ -84,11 +85,13 @@
|
|||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex cursor-pointer items-center rounded-sm py-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="inline-flex cursor-pointer items-center gap-1.5 rounded-sm py-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
{disabled}
|
||||
aria-label="MCP Servers"
|
||||
>
|
||||
<McpActiveServersAvatars class={className} />
|
||||
{#if hasEnabledMcpServers && mcpFavicons.length > 0}
|
||||
<McpActiveServersAvatars class={className} />
|
||||
{/if}
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
|
|
@ -134,12 +137,19 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={isEnabledForChat}
|
||||
disabled={hasError}
|
||||
onclick={(e: MouseEvent) => e.stopPropagation()}
|
||||
onCheckedChange={() => toggleServerForChat(server.id)}
|
||||
/>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Switch
|
||||
checked={isEnabledForChat}
|
||||
disabled={hasError}
|
||||
onclick={(e: MouseEvent) => e.stopPropagation()}
|
||||
onCheckedChange={() => toggleServerForChat(server.id)}
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>{isEnabledForChat ? 'Disable' : 'Enable'} {getServerLabel(server)}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Plus } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { uuid } from '$lib/utils';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { McpServerCard, McpServerCardSkeleton, McpServerForm } from '$lib/components/app/mcp';
|
||||
import { MCP_SERVER_ID_PREFIX } from '$lib/constants';
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
|
||||
let servers = $derived(mcpStore.getServersSorted());
|
||||
|
||||
let initialLoadComplete = $state(false);
|
||||
|
||||
$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;
|
||||
}
|
||||
});
|
||||
|
||||
let isAddingServer = $state(false);
|
||||
let newServerUrl = $state('');
|
||||
let newServerHeaders = $state('');
|
||||
let newServerUrlError = $derived.by(() => {
|
||||
if (!newServerUrl.trim()) return 'URL is required';
|
||||
try {
|
||||
new URL(newServerUrl);
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return 'Invalid URL format';
|
||||
}
|
||||
});
|
||||
|
||||
function showAddServerForm() {
|
||||
isAddingServer = true;
|
||||
newServerUrl = '';
|
||||
newServerHeaders = '';
|
||||
}
|
||||
|
||||
function cancelAddServer() {
|
||||
isAddingServer = false;
|
||||
newServerUrl = '';
|
||||
newServerHeaders = '';
|
||||
}
|
||||
|
||||
function saveNewServer() {
|
||||
if (newServerUrlError) return;
|
||||
|
||||
const newServerId = uuid() ?? `${MCP_SERVER_ID_PREFIX}-${Date.now()}`;
|
||||
|
||||
mcpStore.addServer({
|
||||
id: newServerId,
|
||||
enabled: true,
|
||||
url: newServerUrl.trim(),
|
||||
headers: newServerHeaders.trim() || undefined
|
||||
});
|
||||
|
||||
conversationsStore.setMcpServerOverride(newServerId, true);
|
||||
|
||||
isAddingServer = false;
|
||||
newServerUrl = '';
|
||||
newServerHeaders = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-5 md:space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h4 class="text-base font-semibold">Manage Servers</h4>
|
||||
</div>
|
||||
|
||||
{#if !isAddingServer}
|
||||
<Button variant="outline" size="sm" class="shrink-0" onclick={showAddServerForm}>
|
||||
<Plus class="h-4 w-4" />
|
||||
|
||||
Add New Server
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isAddingServer}
|
||||
<Card.Root class="bg-muted/30 p-4">
|
||||
<div class="space-y-4">
|
||||
<p class="font-medium">Add New Server</p>
|
||||
|
||||
<McpServerForm
|
||||
url={newServerUrl}
|
||||
headers={newServerHeaders}
|
||||
onUrlChange={(v) => (newServerUrl = v)}
|
||||
onHeadersChange={(v) => (newServerHeaders = v)}
|
||||
urlError={newServerUrl ? newServerUrlError : null}
|
||||
id="new-server"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onclick={cancelAddServer}>Cancel</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onclick={saveNewServer}
|
||||
disabled={!!newServerUrlError}
|
||||
aria-label="Save"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
{#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="space-y-3">
|
||||
{#each servers as server (server.id)}
|
||||
{#if !initialLoadComplete}
|
||||
<McpServerCardSkeleton />
|
||||
{:else}
|
||||
<McpServerCard
|
||||
{server}
|
||||
faviconUrl={mcpStore.getServerFavicon(server.id)}
|
||||
enabled={conversationsStore.isMcpServerEnabledForChat(server.id)}
|
||||
onToggle={async () => await conversationsStore.toggleMcpServerForChat(server.id)}
|
||||
onUpdate={(updates) => mcpStore.updateServer(server.id, updates)}
|
||||
onDelete={() => mcpStore.removeServer(server.id)}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
<script lang="ts">
|
||||
import { Settings } from '@lucide/svelte';
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { McpActiveServersAvatars, SearchInput } from '$lib/components/app';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
import type { MCPServerSettingsEntry } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
onSettingsClick?: () => void;
|
||||
}
|
||||
|
||||
let { class: className = '', disabled = false, onSettingsClick }: Props = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
|
||||
let hasMcpServers = $derived(mcpServers.length > 0);
|
||||
let enabledMcpServersForChat = $derived(
|
||||
mcpServers.filter((s) => conversationsStore.isMcpServerEnabledForChat(s.id) && s.url.trim())
|
||||
);
|
||||
let healthyEnabledMcpServers = $derived(
|
||||
enabledMcpServersForChat.filter((s) => {
|
||||
const healthState = mcpStore.getHealthCheckState(s.id);
|
||||
return healthState.status !== HealthCheckStatus.ERROR;
|
||||
})
|
||||
);
|
||||
let hasEnabledMcpServers = $derived(enabledMcpServersForChat.length > 0);
|
||||
let mcpFavicons = $derived(
|
||||
healthyEnabledMcpServers
|
||||
.slice(0, 3)
|
||||
.map((s) => ({ id: s.id, url: mcpStore.getServerFavicon(s.id) }))
|
||||
.filter((f) => f.url !== null)
|
||||
);
|
||||
let filteredMcpServers = $derived.by(() => {
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
if (query) {
|
||||
return mcpServers.filter((s) => {
|
||||
const name = getServerLabel(s).toLowerCase();
|
||||
const url = s.url.toLowerCase();
|
||||
return name.includes(query) || url.includes(query);
|
||||
});
|
||||
}
|
||||
return mcpServers;
|
||||
});
|
||||
|
||||
let sheetOpen = $state(false);
|
||||
|
||||
function getServerLabel(server: MCPServerSettingsEntry): string {
|
||||
return mcpStore.getServerLabel(server);
|
||||
}
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (open) {
|
||||
sheetOpen = true;
|
||||
searchQuery = '';
|
||||
mcpStore.runHealthChecksForServers(mcpServers);
|
||||
} else {
|
||||
sheetOpen = false;
|
||||
searchQuery = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleSheetOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
handleOpenChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
export function open() {
|
||||
handleOpenChange(true);
|
||||
}
|
||||
|
||||
function isServerEnabledForChat(serverId: string): boolean {
|
||||
return conversationsStore.isMcpServerEnabledForChat(serverId);
|
||||
}
|
||||
|
||||
async function toggleServerForChat(serverId: string) {
|
||||
await conversationsStore.toggleMcpServerForChat(serverId);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if hasMcpServers}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex cursor-pointer items-center gap-1.5 rounded-sm py-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
{disabled}
|
||||
aria-label="MCP Servers"
|
||||
onclick={() => handleOpenChange(true)}
|
||||
>
|
||||
{#if hasEnabledMcpServers && mcpFavicons.length > 0}
|
||||
<McpActiveServersAvatars class={className} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<Sheet.Root bind:open={sheetOpen} onOpenChange={handleSheetOpenChange}>
|
||||
<Sheet.Content side="bottom" class="max-h-[85vh] gap-1">
|
||||
<Sheet.Header>
|
||||
<Sheet.Title>MCP Servers</Sheet.Title>
|
||||
|
||||
<Sheet.Description class="sr-only">
|
||||
Toggle MCP servers for the current conversation
|
||||
</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
|
||||
<div class="flex flex-col gap-1 pb-4">
|
||||
<div class="mb-3 px-4">
|
||||
<SearchInput placeholder="Search servers..." bind:value={searchQuery} />
|
||||
</div>
|
||||
|
||||
<div class="max-h-[60vh] overflow-y-auto px-2">
|
||||
{#if filteredMcpServers.length === 0}
|
||||
<p class="px-3 py-3 text-center text-sm text-muted-foreground">No servers found.</p>
|
||||
{/if}
|
||||
|
||||
{#each filteredMcpServers as server (server.id)}
|
||||
{@const healthState = mcpStore.getHealthCheckState(server.id)}
|
||||
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
|
||||
{@const isEnabledForChat = isServerEnabledForChat(server.id)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-2 rounded-md px-3 py-2.5 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => !hasError && toggleServerForChat(server.id)}
|
||||
disabled={hasError}
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
{#if mcpStore.getServerFavicon(server.id)}
|
||||
<img
|
||||
src={mcpStore.getServerFavicon(server.id)}
|
||||
alt=""
|
||||
class="h-4 w-4 shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<span class="truncate text-sm">{getServerLabel(server)}</span>
|
||||
|
||||
{#if hasError}
|
||||
<span
|
||||
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
|
||||
>
|
||||
Error
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={isEnabledForChat}
|
||||
disabled={hasError}
|
||||
onclick={(e: MouseEvent) => e.stopPropagation()}
|
||||
onCheckedChange={() => toggleServerForChat(server.id)}
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 border-t px-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded-md px-3 py-2.5 text-sm transition-colors hover:bg-accent"
|
||||
onclick={() => {
|
||||
handleOpenChange(false);
|
||||
onSettingsClick?.();
|
||||
}}
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
<span>Manage MCP Servers</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
{/if}
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
* <McpServersSettings />
|
||||
* ```
|
||||
*/
|
||||
export { default as McpServersSettings } from './McpServersSettings.svelte';
|
||||
export { default as McpServersSettings } from '../settings/SettingsMcpServers.svelte';
|
||||
|
||||
/**
|
||||
* **McpActiveServersAvatars** - Active MCP servers indicator
|
||||
|
|
@ -96,6 +96,21 @@ export { default as McpActiveServersAvatars } from './McpActiveServersAvatars.sv
|
|||
*/
|
||||
export { default as McpServersSelector } from './McpServersSelector.svelte';
|
||||
|
||||
/**
|
||||
* **McpServersSheet** - Mobile MCP server toggle sheet
|
||||
*
|
||||
* Bottom sheet variant of McpServersSelector for mobile devices.
|
||||
* Uses Sheet UI instead of dropdown for better touch interaction.
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <McpServersSheet
|
||||
* onSettingsClick={() => showMcpSettings = true}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as McpServersSheet } from './McpServersSheet.svelte';
|
||||
|
||||
/**
|
||||
* **McpCapabilitiesBadges** - Server capabilities display
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { Search, X } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
|
|
@ -111,21 +110,7 @@
|
|||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="relative">
|
||||
<Search class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
|
||||
<Input bind:value={searchQuery} placeholder="Search conversations..." class="pr-9 pl-9" />
|
||||
|
||||
{#if searchQuery}
|
||||
<button
|
||||
class="absolute top-1/2 right-3 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onclick={() => (searchQuery = '')}
|
||||
type="button"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<SearchInput bind:value={searchQuery} placeholder="Search conversations..." />
|
||||
|
||||
<div class="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@
|
|||
|
||||
interface Props {
|
||||
modelId: string;
|
||||
showOrgName?: boolean;
|
||||
hideOrgName?: boolean;
|
||||
showRaw?: boolean;
|
||||
hideQuantization?: boolean;
|
||||
aliases?: string[];
|
||||
tags?: string[];
|
||||
class?: string;
|
||||
|
|
@ -14,8 +15,9 @@
|
|||
|
||||
let {
|
||||
modelId,
|
||||
showOrgName = false,
|
||||
hideOrgName = false,
|
||||
showRaw = undefined,
|
||||
hideQuantization = false,
|
||||
aliases,
|
||||
tags,
|
||||
class: className = '',
|
||||
|
|
@ -41,7 +43,7 @@
|
|||
{:else}
|
||||
<span class="flex min-w-0 flex-wrap items-center gap-1 {className}" {...rest}>
|
||||
<span class="min-w-0 truncate font-medium">
|
||||
{#if showOrgName && parsed.orgName && !(aliases && aliases.length > 0)}{parsed.orgName}/{/if}{displayName}
|
||||
{#if !hideOrgName && parsed.orgName && !(aliases && aliases.length > 0)}{parsed.orgName}/{/if}{displayName}
|
||||
</span>
|
||||
|
||||
{#if parsed.params}
|
||||
|
|
@ -50,7 +52,7 @@
|
|||
</span>
|
||||
{/if}
|
||||
|
||||
{#if parsed.quantization}
|
||||
{#if parsed.quantization && !hideQuantization}
|
||||
<span class={badgeClass}>
|
||||
{parsed.quantization}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -256,11 +256,11 @@
|
|||
'inline-flex items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
style="max-width: min(calc(100cqw - 9rem), 20rem)"
|
||||
style="max-width: min(calc(100cqw - 10rem), 20rem)"
|
||||
>
|
||||
<Package class="h-3.5 w-3.5" />
|
||||
|
||||
<ModelId modelId={currentModel} class="min-w-0" showOrgName />
|
||||
<ModelId modelId={currentModel} class="min-w-0" hideQuantization />
|
||||
</span>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No models available.</p>
|
||||
|
|
@ -282,7 +282,7 @@
|
|||
: 'text-muted-foreground',
|
||||
isOpen ? 'text-foreground' : ''
|
||||
)}
|
||||
style="max-width: min(calc(100cqw - 9rem), 20rem)"
|
||||
style="max-width: min(calc(100cqw - 9rem), 25rem)"
|
||||
disabled={disabled || updating}
|
||||
>
|
||||
<Package class="h-3.5 w-3.5" />
|
||||
|
|
@ -295,7 +295,7 @@
|
|||
<ModelId
|
||||
modelId={selectedOption.model}
|
||||
class="min-w-0 overflow-hidden"
|
||||
showOrgName
|
||||
hideOrgName={false}
|
||||
{...props}
|
||||
/>
|
||||
{/snippet}
|
||||
|
|
@ -338,7 +338,7 @@
|
|||
aria-disabled="true"
|
||||
disabled
|
||||
>
|
||||
<ModelId modelId={currentModel} class="flex-1" showOrgName />
|
||||
<ModelId modelId={currentModel} class="flex-1" hideQuantization />
|
||||
|
||||
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
||||
</button>
|
||||
|
|
@ -348,7 +348,7 @@
|
|||
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
|
||||
{/if}
|
||||
|
||||
{#snippet modelOption(item: ModelItem, showOrgName: boolean)}
|
||||
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{@const { option, flatIndex } = item}
|
||||
{@const isSelected = currentModel === option.model || activeId === option.id}
|
||||
{@const isHighlighted = flatIndex === highlightedIndex}
|
||||
|
|
@ -359,7 +359,7 @@
|
|||
{isSelected}
|
||||
{isHighlighted}
|
||||
{isFav}
|
||||
{showOrgName}
|
||||
{hideOrgName}
|
||||
onSelect={handleSelect}
|
||||
onInfoClick={handleInfoClick}
|
||||
onMouseEnter={() => (highlightedIndex = flatIndex)}
|
||||
|
|
@ -412,7 +412,7 @@
|
|||
<ModelId
|
||||
modelId={selectedOption.model}
|
||||
class="min-w-0 overflow-hidden"
|
||||
showOrgName
|
||||
hideOrgName={false}
|
||||
{...props}
|
||||
/>
|
||||
{/snippet}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
let render = $derived(renderOption ?? defaultOption);
|
||||
</script>
|
||||
|
||||
{#snippet defaultOption(item: ModelItem, showOrgName: boolean)}
|
||||
{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || activeId === option.id}
|
||||
{@const isFav = modelsStore.favoriteModelIds.has(option.model)}
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
{isSelected}
|
||||
isHighlighted={false}
|
||||
{isFav}
|
||||
{showOrgName}
|
||||
{hideOrgName}
|
||||
{onSelect}
|
||||
{onInfoClick}
|
||||
onMouseEnter={() => {}}
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
{#if groups.loaded.length > 0}
|
||||
<p class={sectionHeaderClass}>Loaded models</p>
|
||||
{#each groups.loaded as item (`loaded-${item.option.id}`)}
|
||||
{@render render(item, true)}
|
||||
{@render render(item, false)}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
|
|
@ -66,7 +66,7 @@
|
|||
<p class={orgHeaderClass}>{group.orgName}</p>
|
||||
{/if}
|
||||
{#each group.items as item (item.option.id)}
|
||||
{@render render(item, false)}
|
||||
{@render render(item, true)}
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
isSelected: boolean;
|
||||
isHighlighted: boolean;
|
||||
isFav: boolean;
|
||||
showOrgName?: boolean;
|
||||
hideOrgName?: boolean;
|
||||
onSelect: (modelId: string) => void;
|
||||
onMouseEnter: () => void;
|
||||
onKeyDown: (e: KeyboardEvent) => void;
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
isSelected,
|
||||
isHighlighted,
|
||||
isFav,
|
||||
showOrgName = false,
|
||||
hideOrgName = false,
|
||||
onSelect,
|
||||
onMouseEnter,
|
||||
onKeyDown,
|
||||
|
|
@ -71,7 +71,7 @@
|
|||
>
|
||||
<ModelId
|
||||
modelId={option.model}
|
||||
{showOrgName}
|
||||
{hideOrgName}
|
||||
aliases={option.aliases}
|
||||
tags={option.tags}
|
||||
class="flex-1"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import {
|
||||
DialogModelInformation,
|
||||
ModelId,
|
||||
ModelsSelectorList,
|
||||
SearchInput,
|
||||
TruncatedText
|
||||
|
|
@ -233,7 +234,16 @@
|
|||
>
|
||||
<Package class="h-3.5 w-3.5" />
|
||||
|
||||
<TruncatedText text={selectedOption?.model || 'Select model'} class="min-w-0 font-medium" />
|
||||
{#if !selectedOption}
|
||||
<span class="min-w-0 font-medium">Select model</span>
|
||||
{:else}
|
||||
<ModelId
|
||||
class="text-xs"
|
||||
modelId={selectedOption?.model || ''}
|
||||
hideQuantization
|
||||
hideOrgName
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if updating || isLoadingModel}
|
||||
<Loader2 class="h-3 w-3.5 animate-spin" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { ActionIcon } from '$lib/components/app/actions';
|
||||
import { McpLogo } from '$lib/components/app/mcp';
|
||||
import { Database, Settings, Search, SquarePen } from '@lucide/svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { circIn } from 'svelte/easing';
|
||||
import { onMount, type Component } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
sidebarOpen: boolean;
|
||||
onSearchClick: () => void;
|
||||
}
|
||||
|
||||
let { sidebarOpen, onSearchClick }: Props = $props();
|
||||
|
||||
const TRANSITION_DURATION = 250;
|
||||
const TRANSITION_DELAY_MULTIPLIER = 150;
|
||||
const TRANSITION_EASING = circIn;
|
||||
|
||||
let isMcpActive = $derived(page.route.id === '/settings/mcp');
|
||||
let isImportExportActive = $derived(page.route.id === '/settings/import-export');
|
||||
let isSettingsActive = $derived(page.route.id === '/settings/chat');
|
||||
|
||||
interface IconItem {
|
||||
icon: Component;
|
||||
tooltip: string;
|
||||
onclick: () => void;
|
||||
activeClass?: string;
|
||||
group: 'top' | 'bottom';
|
||||
}
|
||||
|
||||
let icons = $derived<IconItem[]>([
|
||||
{
|
||||
icon: SquarePen,
|
||||
tooltip: 'New Chat',
|
||||
onclick: () => goto('?new_chat=true#/'),
|
||||
group: 'top'
|
||||
},
|
||||
{
|
||||
icon: Search,
|
||||
tooltip: 'Search',
|
||||
onclick: onSearchClick,
|
||||
group: 'top'
|
||||
},
|
||||
{
|
||||
icon: McpLogo,
|
||||
tooltip: 'MCP Servers',
|
||||
onclick: () => goto('#/settings/mcp'),
|
||||
activeClass: isMcpActive ? 'bg-accent text-accent-foreground' : '',
|
||||
group: 'bottom'
|
||||
},
|
||||
{
|
||||
icon: Database,
|
||||
tooltip: 'Import / Export',
|
||||
onclick: () => goto('#/settings/import-export'),
|
||||
activeClass: isImportExportActive ? 'bg-accent text-accent-foreground' : '',
|
||||
group: 'bottom'
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
tooltip: 'Settings',
|
||||
onclick: () => goto('#/settings/chat'),
|
||||
activeClass: isSettingsActive ? 'bg-accent text-accent-foreground' : '',
|
||||
group: 'bottom'
|
||||
}
|
||||
]);
|
||||
|
||||
let topIcons = $derived(icons.filter((i) => i.group === 'top'));
|
||||
let bottomIcons = $derived(icons.filter((i) => i.group === 'bottom'));
|
||||
|
||||
let mounted = $state(false);
|
||||
onMount(() => (mounted = true));
|
||||
let showIcons = $derived(mounted && !sidebarOpen);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="hidden shrink-0 transition-[width] duration-200 ease-linear md:block {sidebarOpen
|
||||
? 'w-0'
|
||||
: 'w-[calc(var(--sidebar-width-icon)+1.5rem)]'}"
|
||||
></div>
|
||||
|
||||
<aside
|
||||
class="fixed top-0 bottom-0 left-0 z-10 hidden w-[calc(var(--sidebar-width-icon)+1.5rem)] flex-col items-center justify-between py-3 transition-opacity duration-200 ease-linear md:flex {sidebarOpen
|
||||
? 'pointer-events-none opacity-0'
|
||||
: 'opacity-100'}"
|
||||
>
|
||||
<div class="mt-12 flex flex-col items-center gap-1">
|
||||
{#each topIcons as item, i (item.tooltip)}
|
||||
{#if showIcons}
|
||||
<div
|
||||
in:fade={{
|
||||
duration: TRANSITION_DURATION,
|
||||
delay: TRANSITION_DELAY_MULTIPLIER + i * TRANSITION_DELAY_MULTIPLIER,
|
||||
easing: TRANSITION_EASING
|
||||
}}
|
||||
>
|
||||
<ActionIcon
|
||||
icon={item.icon}
|
||||
tooltip={item.tooltip}
|
||||
tooltipSide="right"
|
||||
size="lg"
|
||||
iconSize="h-4 w-4"
|
||||
class="h-9 w-9 rounded-full hover:bg-accent! {item.activeClass ?? ''}"
|
||||
onclick={item.onclick}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
{#each bottomIcons as item, i (item.tooltip)}
|
||||
{#if showIcons}
|
||||
<div
|
||||
in:fade={{
|
||||
duration: TRANSITION_DURATION,
|
||||
delay:
|
||||
TRANSITION_DELAY_MULTIPLIER + (topIcons.length + i) * TRANSITION_DELAY_MULTIPLIER,
|
||||
easing: TRANSITION_EASING
|
||||
}}
|
||||
>
|
||||
<ActionIcon
|
||||
icon={item.icon}
|
||||
tooltip={item.tooltip}
|
||||
size="lg"
|
||||
iconSize="h-4 w-4"
|
||||
class="h-9 w-9 rounded-full hover:bg-accent! {item.activeClass ?? ''}"
|
||||
onclick={item.onclick}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</aside>
|
||||
|
|
@ -63,3 +63,11 @@ export { default as DropdownMenuSearchable } from './DropdownMenuSearchable.svel
|
|||
* ```
|
||||
*/
|
||||
export { default as DropdownMenuActions } from './DropdownMenuActions.svelte';
|
||||
|
||||
/**
|
||||
* **DesktopIconStrip** - Fixed icon strip for desktop sidebar
|
||||
*
|
||||
* Vertical icon strip shown on desktop when the sidebar is collapsed.
|
||||
* Contains navigation shortcuts for new chat, search, MCP, import/export, and settings.
|
||||
*/
|
||||
export { default as DesktopIconStrip } from './DesktopIconStrip.svelte';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,543 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
Settings,
|
||||
Funnel,
|
||||
AlertTriangle,
|
||||
Code,
|
||||
Monitor,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ListRestart,
|
||||
Sliders
|
||||
} from '@lucide/svelte';
|
||||
import { ChatSettingsFooter, ChatSettingsFields } from '$lib/components/app';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import {
|
||||
SETTINGS_SECTION_TITLES,
|
||||
type SettingsSectionTitle,
|
||||
NUMERIC_FIELDS,
|
||||
POSITIVE_INTEGER_FIELDS,
|
||||
SETTINGS_COLOR_MODES_CONFIG,
|
||||
SETTINGS_KEYS
|
||||
} from '$lib/constants';
|
||||
import { setMode } from 'mode-watcher';
|
||||
import { ColorMode } from '$lib/enums/ui';
|
||||
import { SettingsFieldType } from '$lib/enums/settings';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
onSave?: () => void;
|
||||
initialSection?: SettingsSectionTitle;
|
||||
}
|
||||
|
||||
let { class: className, onSave, initialSection }: Props = $props();
|
||||
|
||||
const settingSections: Array<{
|
||||
fields: SettingsFieldConfig[];
|
||||
icon: Component;
|
||||
title: SettingsSectionTitle;
|
||||
}> = [
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.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.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
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.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,
|
||||
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,
|
||||
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,
|
||||
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.DEVELOPER,
|
||||
icon: Code,
|
||||
fields: [
|
||||
{
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
// TODO: Experimental features section will be implemented after initial release
|
||||
// This includes Python interpreter (Pyodide integration) and other experimental features
|
||||
// {
|
||||
// title: 'Experimental',
|
||||
// icon: Beaker,
|
||||
// fields: [
|
||||
// {
|
||||
// key: 'pyInterpreterEnabled',
|
||||
// label: 'Enable Python interpreter',
|
||||
// type: 'checkbox'
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
];
|
||||
|
||||
let activeSection = $derived<SettingsSectionTitle>(
|
||||
initialSection ?? SETTINGS_SECTION_TITLES.GENERAL
|
||||
);
|
||||
let currentSection = $derived(
|
||||
settingSections.find((section) => section.title === activeSection) || settingSections[0]
|
||||
);
|
||||
let localConfig: SettingsConfigType = $state({ ...config() });
|
||||
|
||||
let canScrollLeft = $state(false);
|
||||
let canScrollRight = $state(false);
|
||||
let scrollContainer: HTMLDivElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
if (initialSection) {
|
||||
activeSection = initialSection;
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert numeric strings to numbers for numeric fields
|
||||
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);
|
||||
onSave?.();
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function reset() {
|
||||
localConfig = { ...config() };
|
||||
|
||||
setTimeout(updateScrollButtons, 100);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (scrollContainer) {
|
||||
updateScrollButtons();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col overflow-y-auto {className} w-full" in:fade={{ duration: 150 }}>
|
||||
<div class="flex flex-1 flex-col gap-4 md:flex-row">
|
||||
<!-- Desktop Sidebar -->
|
||||
<div class="sticky top-0 hidden w-64 flex-col self-start bg-background pt-8 pb-4 md:flex">
|
||||
<div class="flex items-center gap-2 pb-12">
|
||||
<Settings class="h-6 w-6" />
|
||||
<h1 class="text-2xl font-semibold">Settings</h1>
|
||||
</div>
|
||||
<nav class="space-y-1">
|
||||
{#each settingSections as section (section.title)}
|
||||
<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 {activeSection ===
|
||||
section.title
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
onclick={() => (activeSection = section.title)}
|
||||
>
|
||||
<section.icon class="h-4 w-4" />
|
||||
|
||||
<span class="ml-2">{section.title}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Header with Horizontal Scrollable Menu -->
|
||||
<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">
|
||||
<!-- Horizontal Scrollable Category Menu with Navigation -->
|
||||
<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 {canScrollLeft
|
||||
? 'opacity-100'
|
||||
: 'pointer-events-none opacity-0'}"
|
||||
onclick={scrollLeft}
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="scrollbar-hide overflow-x-auto py-2"
|
||||
bind:this={scrollContainer}
|
||||
onscroll={updateScrollButtons}
|
||||
>
|
||||
<div class="flex min-w-max gap-2">
|
||||
{#each settingSections as section (section.title)}
|
||||
<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 {activeSection ===
|
||||
section.title
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
onclick={(e: MouseEvent) => {
|
||||
activeSection = section.title;
|
||||
scrollToCenter(e.currentTarget as HTMLElement);
|
||||
}}
|
||||
>
|
||||
<section.icon class="h-4 w-4 flex-shrink-0" />
|
||||
<span>{section.title}</span>
|
||||
</button>
|
||||
{/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 {canScrollRight
|
||||
? 'opacity-100'
|
||||
: 'pointer-events-none opacity-0'}"
|
||||
onclick={scrollRight}
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
|
||||
{#if currentSection.fields}
|
||||
<div class="space-y-6">
|
||||
<ChatSettingsFields
|
||||
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>
|
||||
|
||||
<ChatSettingsFooter onReset={handleReset} onSave={handleSave} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
import { Download, Upload, Trash2, Database } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { DialogConversationSelection, DialogConfirmation } from '$lib/components/app';
|
||||
import { createMessageCountMap } from '$lib/utils';
|
||||
import { ISO_DATE_TIME_SEPARATOR } from '$lib/constants';
|
||||
import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
type SectionOpts = {
|
||||
wrapperClass?: string;
|
||||
titleClass?: string;
|
||||
buttonVariant?: 'outline' | 'destructive';
|
||||
buttonClass?: string;
|
||||
summary?: { show: boolean; verb: string; items: DatabaseConversation[] };
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
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,
|
||||
`${new Date().toISOString().split(ISO_DATE_TIME_SEPARATOR)[0]}_conversations.json`
|
||||
);
|
||||
|
||||
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 = 'file';
|
||||
input.accept = '.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>
|
||||
|
||||
{#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>
|
||||
|
||||
<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}
|
||||
|
||||
{#if items.length > 10}
|
||||
<li class="italic">... and {items.length - 10} more</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet section(
|
||||
title: string,
|
||||
description: string,
|
||||
Icon: 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>
|
||||
|
||||
<p class="mb-4 text-sm text-muted-foreground">{description}</p>
|
||||
|
||||
<Button class={buttonClass} {onclick} variant={buttonVariant}>
|
||||
<Icon 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="flex items-center gap-2 pb-4">
|
||||
<Database class="h-5 w-5 md:h-6 md:w-6" />
|
||||
|
||||
<h1 class="text-xl font-semibold md:text-2xl">Import / Export</h1>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<DialogConversationSelection
|
||||
conversations={availableConversations}
|
||||
{messageCountMap}
|
||||
mode="export"
|
||||
bind:open={showExportDialog}
|
||||
onCancel={() => (showExportDialog = false)}
|
||||
onConfirm={handleExportConfirm}
|
||||
/>
|
||||
|
||||
<DialogConversationSelection
|
||||
conversations={availableConversations}
|
||||
{messageCountMap}
|
||||
mode="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}
|
||||
/>
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
<script lang="ts">
|
||||
import { Plus } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { uuid } from '$lib/utils';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { McpServerCard, McpServerCardSkeleton, McpServerForm } from '$lib/components/app/mcp';
|
||||
import { MCP_SERVER_ID_PREFIX } from '$lib/constants';
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
import { fade } from 'svelte/transition';
|
||||
import McpLogo from '../mcp/McpLogo.svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className }: Props = $props();
|
||||
|
||||
let servers = $derived(mcpStore.getServersSorted());
|
||||
|
||||
let initialLoadComplete = $state(false);
|
||||
|
||||
$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;
|
||||
}
|
||||
});
|
||||
|
||||
let isAddingServer = $state(false);
|
||||
let newServerUrl = $state('');
|
||||
let newServerHeaders = $state('');
|
||||
let newServerUrlError = $derived.by(() => {
|
||||
if (!newServerUrl.trim()) return 'URL is required';
|
||||
try {
|
||||
new URL(newServerUrl);
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return 'Invalid URL format';
|
||||
}
|
||||
});
|
||||
|
||||
function showAddServerForm() {
|
||||
isAddingServer = true;
|
||||
newServerUrl = '';
|
||||
newServerHeaders = '';
|
||||
}
|
||||
|
||||
function cancelAddServer() {
|
||||
isAddingServer = false;
|
||||
newServerUrl = '';
|
||||
newServerHeaders = '';
|
||||
}
|
||||
|
||||
function saveNewServer() {
|
||||
if (newServerUrlError) return;
|
||||
|
||||
const newServerId = uuid() ?? `${MCP_SERVER_ID_PREFIX}-${Date.now()}`;
|
||||
|
||||
mcpStore.addServer({
|
||||
id: newServerId,
|
||||
enabled: true,
|
||||
url: newServerUrl.trim(),
|
||||
headers: newServerHeaders.trim() || undefined
|
||||
});
|
||||
|
||||
conversationsStore.setMcpServerOverride(newServerId, true);
|
||||
|
||||
isAddingServer = false;
|
||||
newServerUrl = '';
|
||||
newServerHeaders = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div in:fade={{ duration: 150 }} class="max-h-full overflow-auto">
|
||||
<div class="flex items-center gap-2 p-4 md:absolute md:top-8 md:left-8 md:p-0">
|
||||
<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 justify-end gap-4 px-8 py-4">
|
||||
{#if !isAddingServer}
|
||||
<Button variant="outline" size="sm" class="shrink-0" onclick={showAddServerForm}>
|
||||
<Plus class="h-4 w-4" />
|
||||
|
||||
Add New Server
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 md:space-y-4 {className}">
|
||||
{#if isAddingServer}
|
||||
<Card.Root class="bg-muted/30 p-4">
|
||||
<div class="space-y-4">
|
||||
<p class="font-medium">Add New Server</p>
|
||||
|
||||
<McpServerForm
|
||||
url={newServerUrl}
|
||||
headers={newServerHeaders}
|
||||
onUrlChange={(v) => (newServerUrl = v)}
|
||||
onHeadersChange={(v) => (newServerHeaders = v)}
|
||||
urlError={newServerUrl ? newServerUrlError : null}
|
||||
id="new-server"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onclick={cancelAddServer}>Cancel</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onclick={saveNewServer}
|
||||
disabled={!!newServerUrlError}
|
||||
aria-label="Save"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
{#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}
|
||||
faviconUrl={mcpStore.getServerFavicon(server.id)}
|
||||
enabled={conversationsStore.isMcpServerEnabledForChat(server.id)}
|
||||
onToggle={async () => await conversationsStore.toggleMcpServerForChat(server.id)}
|
||||
onUpdate={(updates) => mcpStore.updateServer(server.id, updates)}
|
||||
onDelete={() => mcpStore.removeServer(server.id)}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { default as SettingsChat } from './SettingsChat.svelte';
|
||||
export { default as SettingsImportExport } from './SettingsImportExport.svelte';
|
||||
export { default as SettingsMcpServers } from './SettingsMcpServers.svelte';
|
||||
|
|
@ -9,9 +9,9 @@
|
|||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
|
||||
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white!',
|
||||
outline:
|
||||
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
|
||||
'shadow-xs hover:text-accent-foreground hover:bg-muted-foreground/10 backdrop-blur-sm dark:border-input border',
|
||||
secondary:
|
||||
'dark:bg-secondary dark:text-secondary-foreground bg-background shadow-sm text-foreground hover:bg-muted-foreground/20',
|
||||
ghost: 'hover:text-accent-foreground hover:bg-muted-foreground/10 backdrop-blur-sm',
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
bind:ref
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
class={cn(
|
||||
'z-50 min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
'z-50 max-h-(--bits-dropdown-menu-content-available-height) min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/20',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export const SIDEBAR_COOKIE_NAME = 'sidebar:state';
|
||||
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
export const SIDEBAR_WIDTH = '18rem';
|
||||
export const SIDEBAR_MIN_WIDTH = '18rem';
|
||||
export const SIDEBAR_MAX_WIDTH = '32rem';
|
||||
export const SIDEBAR_WIDTH_MOBILE = '18rem';
|
||||
export const SIDEBAR_WIDTH_ICON = '3rem';
|
||||
export const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import { SIDEBAR_KEYBOARD_SHORTCUT } from './constants.js';
|
||||
import { SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_MIN_WIDTH } from './constants.js';
|
||||
|
||||
type Getter<T> = () => T;
|
||||
|
||||
|
|
@ -24,6 +24,8 @@ class SidebarState {
|
|||
readonly props: SidebarStateProps;
|
||||
open = $derived.by(() => this.props.open());
|
||||
openMobile = $state(false);
|
||||
sidebarWidth = $state(SIDEBAR_MIN_WIDTH);
|
||||
isResizing = $state(false);
|
||||
setOpen: SidebarStateProps['setOpen'];
|
||||
#isMobile: IsMobile;
|
||||
state = $derived.by(() => (this.open ? 'expanded' : 'collapsed'));
|
||||
|
|
@ -53,7 +55,7 @@ class SidebarState {
|
|||
};
|
||||
|
||||
toggle = () => {
|
||||
return this.#isMobile.current ? (this.openMobile = !this.openMobile) : this.setOpen(!this.open);
|
||||
this.setOpen(!this.open);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
bind:this={ref}
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
class={cn('flex flex-col gap-2 p-2', className)}
|
||||
class={cn('flex flex-col gap-2 p-3', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { tv, type VariantProps } from 'tailwind-variants';
|
||||
|
||||
export const sidebarMenuButtonVariants = tv({
|
||||
base: 'peer/menu-button outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground group-has-data-[sidebar=menu-action]/menu-item:pr-8 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm transition-[width,height,padding] focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
base: 'peer/menu-button outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground group-has-data-[sidebar=menu-action]/menu-item:pr-8 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! flex w-full items-center gap-2 overflow-hidden rounded-md py-2 px-1 text-left text-sm transition-[width,height,padding] focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
import {
|
||||
SIDEBAR_COOKIE_MAX_AGE,
|
||||
SIDEBAR_COOKIE_NAME,
|
||||
SIDEBAR_WIDTH,
|
||||
SIDEBAR_MIN_WIDTH,
|
||||
SIDEBAR_MAX_WIDTH,
|
||||
SIDEBAR_WIDTH_ICON
|
||||
} from './constants.js';
|
||||
import { setSidebar } from './context.svelte.js';
|
||||
|
|
@ -38,7 +39,7 @@
|
|||
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style="--sidebar-width: {SIDEBAR_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON}; {style}"
|
||||
style="--sidebar-width: {sidebar.sidebarWidth}; --sidebar-min-width: {SIDEBAR_MIN_WIDTH}; --sidebar-max-width: {SIDEBAR_MAX_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON}; {style}"
|
||||
class={cn(
|
||||
'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar',
|
||||
className
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import PanelLeftIcon from '@lucide/svelte/icons/panel-left';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import { useSidebar } from './context.svelte.js';
|
||||
import { PanelLeftClose } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
|
|
@ -21,9 +22,11 @@
|
|||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
class="rounded-full backdrop-blur-lg {className} md:left-{sidebar.open
|
||||
? 'unset'
|
||||
: '2'} -top-2 -left-2 md:top-0"
|
||||
class="rounded-full backdrop-blur-lg {className} {sidebar.open
|
||||
? 'top-1.5'
|
||||
: 'top-0'} md:left-[calc(var(--sidebar-width)-3.25rem)] {sidebar.isResizing
|
||||
? '!duration-0'
|
||||
: ''}"
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
onclick?.(e);
|
||||
|
|
@ -31,6 +34,10 @@
|
|||
}}
|
||||
{...restProps}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
{#if sidebar.open}
|
||||
<PanelLeftClose />
|
||||
{:else}
|
||||
<PanelLeftIcon />
|
||||
{/if}
|
||||
<span class="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
<script lang="ts">
|
||||
import * as Sheet from '$lib/components/ui/sheet/index.js';
|
||||
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { SIDEBAR_WIDTH_MOBILE } from './constants.js';
|
||||
import { SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH } from './constants.js';
|
||||
import { useSidebar } from './context.svelte.js';
|
||||
|
||||
let {
|
||||
|
|
@ -20,6 +19,40 @@
|
|||
} = $props();
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
function remToPx(rem: string): number {
|
||||
const val = parseFloat(rem);
|
||||
const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
return val * fontSize;
|
||||
}
|
||||
|
||||
function handleResizePointerDown(e: PointerEvent) {
|
||||
if (sidebar.isMobile) return;
|
||||
e.preventDefault();
|
||||
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
target.setPointerCapture(e.pointerId);
|
||||
|
||||
const minPx = remToPx(SIDEBAR_MIN_WIDTH);
|
||||
const maxPx = remToPx(SIDEBAR_MAX_WIDTH);
|
||||
|
||||
sidebar.isResizing = true;
|
||||
|
||||
function onPointerMove(ev: PointerEvent) {
|
||||
const newWidth = side === 'left' ? ev.clientX : window.innerWidth - ev.clientX;
|
||||
const clamped = Math.min(maxPx, Math.max(minPx, newWidth));
|
||||
sidebar.sidebarWidth = `${clamped}px`;
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
sidebar.isResizing = false;
|
||||
target.removeEventListener('pointermove', onPointerMove);
|
||||
target.removeEventListener('pointerup', onPointerUp);
|
||||
}
|
||||
|
||||
target.addEventListener('pointermove', onPointerMove);
|
||||
target.addEventListener('pointerup', onPointerUp);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if collapsible === 'none'}
|
||||
|
|
@ -33,29 +66,10 @@
|
|||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{:else if sidebar.isMobile}
|
||||
<Sheet.Root bind:open={() => sidebar.openMobile, (v) => sidebar.setOpenMobile(v)} {...restProps}>
|
||||
<Sheet.Content
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
class="z-99999 w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground sm:z-99 [&>button]:hidden"
|
||||
style="--sidebar-width: {SIDEBAR_WIDTH_MOBILE};"
|
||||
{side}
|
||||
>
|
||||
<Sheet.Header class="sr-only">
|
||||
<Sheet.Title>Sidebar</Sheet.Title>
|
||||
<Sheet.Description>Displays the mobile sidebar.</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
<div class="flex h-full w-full flex-col">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
{:else}
|
||||
<div
|
||||
bind:this={ref}
|
||||
class="group peer hidden text-sidebar-foreground md:block"
|
||||
class="group peer block text-sidebar-foreground"
|
||||
data-state={sidebar.state}
|
||||
data-collapsible={sidebar.state === 'collapsed' ? collapsible : ''}
|
||||
data-variant={variant}
|
||||
|
|
@ -66,36 +80,75 @@
|
|||
<div
|
||||
data-slot="sidebar-gap"
|
||||
class={cn(
|
||||
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
|
||||
'group-data-[collapsible=offcanvas]:w-0',
|
||||
'relative bg-transparent transition-[width] duration-200 ease-linear',
|
||||
sidebar.isResizing && '!duration-0',
|
||||
'w-0',
|
||||
variant === 'floating'
|
||||
? 'md:w-[calc(var(--sidebar-width)+0.75rem)]'
|
||||
: 'md:w-(--sidebar-width)',
|
||||
'md:group-data-[collapsible=offcanvas]:w-0',
|
||||
'group-data-[side=right]:rotate-180',
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)'
|
||||
)}
|
||||
></div>
|
||||
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
class={cn(
|
||||
'fixed inset-y-0 z-999 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:z-0 md:flex',
|
||||
side === 'left'
|
||||
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
||||
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === 'floating' || variant === 'inset'
|
||||
'fixed inset-y-0 z-[900] flex w-[calc(100dvw-1.5rem)] duration-200 ease-linear md:z-0 md:w-(--sidebar-width)',
|
||||
sidebar.isResizing && '!duration-0',
|
||||
variant === 'floating'
|
||||
? [
|
||||
'transition-[left,right,width,opacity]',
|
||||
side === 'left'
|
||||
? 'left-3 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-0.775)] group-data-[collapsible=offcanvas]:opacity-0'
|
||||
: 'right-3 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-0.775)] group-data-[collapsible=offcanvas]:opacity-0',
|
||||
'my-3 overflow-hidden rounded-3xl border border-sidebar-border shadow-md'
|
||||
]
|
||||
: [
|
||||
'h-svh transition-[left,right,width]',
|
||||
side === 'left'
|
||||
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
||||
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]'
|
||||
],
|
||||
// Adjust the padding for inset variant.
|
||||
variant === 'inset'
|
||||
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
|
||||
: variant === 'floating'
|
||||
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
|
||||
className
|
||||
)}
|
||||
style={variant === 'floating' ? 'height: calc(100dvh - 1.5rem);' : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
class="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm"
|
||||
class="flex h-full w-full flex-col bg-sidebar"
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
<!-- Resize handle -->
|
||||
{#if side === 'left'}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
data-slot="sidebar-resize-handle"
|
||||
class="absolute inset-y-0 right-0 z-50 hidden w-1.5 cursor-ew-resize touch-none select-none hover:bg-sidebar-border/50 active:bg-sidebar-border md:block"
|
||||
class:bg-sidebar-border={sidebar.isResizing}
|
||||
onpointerdown={handleResizePointerDown}
|
||||
></div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
data-slot="sidebar-resize-handle"
|
||||
class="absolute inset-y-0 left-0 z-50 hidden w-1.5 cursor-ew-resize touch-none select-none hover:bg-sidebar-border/50 active:bg-sidebar-border md:block"
|
||||
class:bg-sidebar-border={sidebar.isResizing}
|
||||
onpointerdown={handleResizePointerDown}
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -4,4 +4,9 @@
|
|||
let { ref = $bindable(null), ...restProps }: TooltipPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<TooltipPrimitive.Trigger bind:ref data-slot="tooltip-trigger" {...restProps} />
|
||||
<TooltipPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="tooltip-trigger"
|
||||
class="cursor-pointer"
|
||||
{...restProps}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -4,5 +4,10 @@ export const API_MODELS = {
|
|||
UNLOAD: '/models/unload'
|
||||
};
|
||||
|
||||
export const API_TOOLS = {
|
||||
LIST: '/tools',
|
||||
EXECUTE: '/tools'
|
||||
};
|
||||
|
||||
/** CORS proxy endpoint path */
|
||||
export const CORS_PROXY_ENDPOINT = '/cors-proxy';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
export const CONTEXT_KEY_MESSAGE_EDIT = 'chat-message-edit';
|
||||
export const CONTEXT_KEY_CHAT_ACTIONS = 'chat-actions';
|
||||
export const CONTEXT_KEY_CHAT_SETTINGS_DIALOG = 'chat-settings-dialog';
|
||||
export const CONTEXT_KEY_MCP_SERVERS_DIALOG = 'mcp-servers-dialog';
|
||||
export const CONTEXT_KEY_IMPORT_EXPORT_DIALOG = 'import-export-dialog';
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ export const CONFIG_LOCALSTORAGE_KEY = 'LlamaCppWebui.config';
|
|||
export const USER_OVERRIDES_LOCALSTORAGE_KEY = 'LlamaCppWebui.userOverrides';
|
||||
export const FAVORITE_MODELS_LOCALSTORAGE_KEY = 'LlamaCppWebui.favoriteModels';
|
||||
export const MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY = 'LlamaCppWebui.mcpDefaultEnabled';
|
||||
export const DISABLED_TOOLS_LOCALSTORAGE_KEY = 'LlamaCppWebui.disabledTools';
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean |
|
|||
disableAutoScroll: false,
|
||||
renderUserContentAsMarkdown: false,
|
||||
alwaysShowSidebarOnDesktop: false,
|
||||
autoShowSidebarOnNewChat: true,
|
||||
autoMicOnEmpty: false,
|
||||
fullHeightCodeBlocks: false,
|
||||
showRawModelNames: false,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ export const SETTINGS_KEYS = {
|
|||
RENDER_USER_CONTENT_AS_MARKDOWN: 'renderUserContentAsMarkdown',
|
||||
DISABLE_AUTO_SCROLL: 'disableAutoScroll',
|
||||
ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop',
|
||||
AUTO_SHOW_SIDEBAR_ON_NEW_CHAT: 'autoShowSidebarOnNewChat',
|
||||
FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
|
||||
SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
|
||||
// Sampling
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export const SETTINGS_SECTION_TITLES = {
|
|||
SAMPLING: 'Sampling',
|
||||
PENALTIES: 'Penalties',
|
||||
IMPORT_EXPORT: 'Import/Export',
|
||||
AGENTIC: 'Agentic',
|
||||
MCP: 'MCP',
|
||||
DEVELOPER: 'Developer'
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export const FORK_TREE_DEPTH_PADDING = 8;
|
||||
export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
|
||||
export const APP_NAME = 'llama.cpp';
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { CONTEXT_KEY_CHAT_SETTINGS_DIALOG } from '$lib/constants';
|
|||
|
||||
export interface ChatSettingsDialogContext {
|
||||
open: (initialSection?: SettingsSectionTitle) => void;
|
||||
isActive: () => boolean;
|
||||
}
|
||||
|
||||
const CHAT_SETTINGS_DIALOG_KEY = Symbol.for(CONTEXT_KEY_CHAT_SETTINGS_DIALOG);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import { getContext, setContext } from 'svelte';
|
||||
import { CONTEXT_KEY_IMPORT_EXPORT_DIALOG } from '$lib/constants';
|
||||
|
||||
export interface ImportExportDialogContext {
|
||||
open: () => void;
|
||||
}
|
||||
|
||||
const IMPORT_EXPORT_DIALOG_KEY = Symbol.for(CONTEXT_KEY_IMPORT_EXPORT_DIALOG);
|
||||
|
||||
export function setImportExportDialogContext(
|
||||
ctx: ImportExportDialogContext
|
||||
): ImportExportDialogContext {
|
||||
return setContext(IMPORT_EXPORT_DIALOG_KEY, ctx);
|
||||
}
|
||||
|
||||
export function getImportExportDialogContext(): ImportExportDialogContext {
|
||||
return getContext(IMPORT_EXPORT_DIALOG_KEY);
|
||||
}
|
||||
|
|
@ -17,3 +17,15 @@ export {
|
|||
setChatSettingsDialogContext,
|
||||
type ChatSettingsDialogContext
|
||||
} from './chat-settings-dialog.context';
|
||||
|
||||
export {
|
||||
getMcpServersDialogContext,
|
||||
setMcpServersDialogContext,
|
||||
type McpServersDialogContext
|
||||
} from './mcp-servers-dialog.context';
|
||||
|
||||
export {
|
||||
getImportExportDialogContext,
|
||||
setImportExportDialogContext,
|
||||
type ImportExportDialogContext
|
||||
} from './import-export-dialog.context';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
import { getContext, setContext } from 'svelte';
|
||||
import { CONTEXT_KEY_MCP_SERVERS_DIALOG } from '$lib/constants';
|
||||
|
||||
export interface McpServersDialogContext {
|
||||
open: () => void;
|
||||
isActive: () => boolean;
|
||||
}
|
||||
|
||||
const MCP_SERVERS_DIALOG_KEY = Symbol.for(CONTEXT_KEY_MCP_SERVERS_DIALOG);
|
||||
|
||||
export function setMcpServersDialogContext(ctx: McpServersDialogContext): McpServersDialogContext {
|
||||
return setContext(MCP_SERVERS_DIALOG_KEY, ctx);
|
||||
}
|
||||
|
||||
export function getMcpServersDialogContext(): McpServersDialogContext {
|
||||
return getContext(MCP_SERVERS_DIALOG_KEY);
|
||||
}
|
||||
|
|
@ -50,3 +50,5 @@ export { ParameterSource, SyncableParameterType, SettingsFieldType } from './set
|
|||
export { ColorMode, McpPromptVariant, UrlProtocol } from './ui';
|
||||
|
||||
export { KeyboardKey } from './keyboard';
|
||||
|
||||
export { ToolSource, ToolResponseField } from './tools';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
export enum ToolSource {
|
||||
BUILTIN = 'builtin',
|
||||
MCP = 'mcp',
|
||||
CUSTOM = 'custom'
|
||||
}
|
||||
|
||||
export enum ToolResponseField {
|
||||
PLAIN_TEXT = 'plain_text_response',
|
||||
ERROR = 'error'
|
||||
}
|
||||
|
|
@ -399,9 +399,10 @@ export class ChatService {
|
|||
|
||||
try {
|
||||
const parsed: ApiChatCompletionStreamChunk = JSON.parse(data);
|
||||
const content = parsed.choices[0]?.delta?.content;
|
||||
const reasoningContent = parsed.choices[0]?.delta?.reasoning_content;
|
||||
const toolCalls = parsed.choices[0]?.delta?.tool_calls;
|
||||
const choice = parsed.choices?.[0];
|
||||
const content = choice?.delta?.content;
|
||||
const reasoningContent = choice?.delta?.reasoning_content;
|
||||
const toolCalls = choice?.delta?.tool_calls;
|
||||
const timings = parsed.timings;
|
||||
const promptProgress = parsed.prompt_progress;
|
||||
|
||||
|
|
|
|||
|
|
@ -191,12 +191,6 @@ export const SYNCABLE_PARAMETERS: SyncableParameter[] = [
|
|||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'autoShowSidebarOnNewChat',
|
||||
serverKey: 'autoShowSidebarOnNewChat',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'showRawModelNames',
|
||||
serverKey: 'showRawModelNames',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { apiFetch } from '$lib/utils';
|
||||
import { API_TOOLS } from '$lib/constants';
|
||||
import { ToolResponseField } from '$lib/enums';
|
||||
import type { ToolExecutionResult, ServerBuiltinToolInfo } from '$lib/types';
|
||||
|
||||
export class ToolsService {
|
||||
/**
|
||||
* Fetch the list of built-in tools from the server.
|
||||
*
|
||||
* @returns Array of tool definitions in OpenAI-compatible format
|
||||
*/
|
||||
static async list(): Promise<ServerBuiltinToolInfo[]> {
|
||||
return apiFetch<ServerBuiltinToolInfo[]>(API_TOOLS.LIST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a built-in tool on the server.
|
||||
*/
|
||||
static async executeTool(
|
||||
toolName: string,
|
||||
params: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
): Promise<ToolExecutionResult> {
|
||||
const result = await apiFetch<Record<string, unknown>>(API_TOOLS.EXECUTE, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tool: toolName, params }),
|
||||
signal
|
||||
});
|
||||
|
||||
if (ToolResponseField.ERROR in result) {
|
||||
return { content: String(result[ToolResponseField.ERROR]), isError: true };
|
||||
}
|
||||
|
||||
if (ToolResponseField.PLAIN_TEXT in result) {
|
||||
return { content: String(result[ToolResponseField.PLAIN_TEXT]), isError: false };
|
||||
}
|
||||
|
||||
return { content: JSON.stringify(result), isError: false };
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,9 @@ import { ChatService } from '$lib/services';
|
|||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { ToolSource } from '$lib/enums';
|
||||
import { ToolsService } from '$lib/services/tools.service';
|
||||
import { isAbortError } from '$lib/utils';
|
||||
import {
|
||||
DEFAULT_AGENTIC_CONFIG,
|
||||
|
|
@ -186,28 +189,48 @@ class AgenticStore {
|
|||
const maxTurns = Number(settings.agenticMaxTurns) || DEFAULT_AGENTIC_CONFIG.maxTurns;
|
||||
const maxToolPreviewLines =
|
||||
Number(settings.agenticMaxToolPreviewLines) || DEFAULT_AGENTIC_CONFIG.maxToolPreviewLines;
|
||||
const hasTools =
|
||||
mcpStore.hasEnabledServers(perChatOverrides) ||
|
||||
toolsStore.builtinTools.length > 0 ||
|
||||
toolsStore.customTools.length > 0;
|
||||
return {
|
||||
enabled: mcpStore.hasEnabledServers(perChatOverrides) && DEFAULT_AGENTIC_CONFIG.enabled,
|
||||
enabled: hasTools && DEFAULT_AGENTIC_CONFIG.enabled,
|
||||
maxTurns,
|
||||
maxToolPreviewLines
|
||||
};
|
||||
}
|
||||
|
||||
private parseToolArguments(args: string | Record<string, unknown>): Record<string, unknown> {
|
||||
if (typeof args === 'object') return args;
|
||||
const trimmed = args.trim();
|
||||
if (trimmed === '') return {};
|
||||
return JSON.parse(trimmed) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
|
||||
const { conversationId, messages, options = {}, callbacks, signal, perChatOverrides } = params;
|
||||
|
||||
const agenticConfig = this.getConfig(config(), perChatOverrides);
|
||||
if (!agenticConfig.enabled) return { handled: false };
|
||||
|
||||
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
|
||||
if (!initialized) {
|
||||
console.log('[AgenticStore] MCP not initialized, falling back to standard chat');
|
||||
return { handled: false };
|
||||
const hasMcpServers = mcpStore.hasEnabledServers(perChatOverrides);
|
||||
if (hasMcpServers) {
|
||||
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
|
||||
|
||||
if (!initialized) {
|
||||
console.log('[AgenticStore] MCP not initialized');
|
||||
}
|
||||
}
|
||||
|
||||
const tools = mcpStore.getToolDefinitionsForLLM();
|
||||
// Ensure built-in tools are fetched
|
||||
if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) {
|
||||
await toolsStore.fetchBuiltinTools();
|
||||
}
|
||||
|
||||
const tools = toolsStore.getEnabledToolsForLLM();
|
||||
if (tools.length === 0) {
|
||||
console.log('[AgenticStore] No tools available, falling back to standard chat');
|
||||
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
|
|
@ -235,7 +258,8 @@ class AgenticStore {
|
|||
totalToolCalls: 0,
|
||||
lastError: null
|
||||
});
|
||||
mcpStore.acquireConnection();
|
||||
|
||||
if (hasMcpServers) mcpStore.acquireConnection();
|
||||
|
||||
try {
|
||||
await this.executeAgenticLoop({
|
||||
|
|
@ -255,11 +279,14 @@ class AgenticStore {
|
|||
return { handled: true, error: normalizedError };
|
||||
} finally {
|
||||
this.updateSession(conversationId, { isRunning: false });
|
||||
await mcpStore
|
||||
.releaseConnection()
|
||||
.catch((err: unknown) =>
|
||||
console.warn('[AgenticStore] Failed to release MCP connection:', err)
|
||||
);
|
||||
|
||||
if (hasMcpServers) {
|
||||
await mcpStore
|
||||
.releaseConnection()
|
||||
.catch((err: unknown) =>
|
||||
console.warn('[AgenticStore] Failed to release MCP connection:', err)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -485,17 +512,29 @@ class AgenticStore {
|
|||
}
|
||||
|
||||
const toolStartTime = performance.now();
|
||||
const mcpCall: MCPToolCall = {
|
||||
id: toolCall.id,
|
||||
function: { name: toolCall.function.name, arguments: toolCall.function.arguments }
|
||||
};
|
||||
const toolName = toolCall.function.name;
|
||||
const toolSource = toolsStore.getToolSource(toolName);
|
||||
|
||||
let result: string;
|
||||
let toolSuccess = true;
|
||||
|
||||
try {
|
||||
const executionResult = await mcpStore.executeTool(mcpCall, signal);
|
||||
result = executionResult.content;
|
||||
if (toolSource === ToolSource.BUILTIN) {
|
||||
const args = this.parseToolArguments(toolCall.function.arguments);
|
||||
const executionResult = await ToolsService.executeTool(toolName, args, signal);
|
||||
|
||||
result = executionResult.content;
|
||||
|
||||
if (executionResult.isError) toolSuccess = false;
|
||||
} else {
|
||||
const mcpCall: MCPToolCall = {
|
||||
id: toolCall.id,
|
||||
function: { name: toolName, arguments: toolCall.function.arguments }
|
||||
};
|
||||
const executionResult = await mcpStore.executeTool(mcpCall, signal);
|
||||
|
||||
result = executionResult.content;
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
|
||||
|
|
|
|||
|
|
@ -765,7 +765,6 @@ class ChatStore {
|
|||
if (agenticResult.handled) return;
|
||||
}
|
||||
|
||||
// Non-agentic path: direct streaming into the single assistant message
|
||||
await ChatService.sendMessage(
|
||||
allMessages,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -319,6 +319,9 @@ class SettingsStore {
|
|||
const propsDefaults = this.getServerDefaults();
|
||||
if (Object.keys(propsDefaults).length === 0) return;
|
||||
|
||||
const webuiSettings = serverStore.webuiSettings;
|
||||
const webuiSettingsKeys = new Set(webuiSettings ? Object.keys(webuiSettings) : []);
|
||||
|
||||
for (const [key, propsValue] of Object.entries(propsDefaults)) {
|
||||
const currentValue = getConfigValue(this.config, key);
|
||||
|
||||
|
|
@ -328,12 +331,18 @@ class SettingsStore {
|
|||
// if user value matches server, it's not a real override
|
||||
if (normalizedCurrent === normalizedDefault) {
|
||||
this.userOverrides.delete(key);
|
||||
|
||||
if (
|
||||
!webuiSettingsKeys.has(key) &&
|
||||
getConfigValue(SETTING_CONFIG_DEFAULT, key) === undefined
|
||||
) {
|
||||
setConfigValue(this.config, key, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// webui settings need actual values in config (no placeholder mechanism),
|
||||
// so write them for non-overridden keys
|
||||
const webuiSettings = serverStore.webuiSettings;
|
||||
if (webuiSettings) {
|
||||
for (const [key, value] of Object.entries(webuiSettings)) {
|
||||
if (!this.userOverrides.has(key) && value !== undefined) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,364 @@
|
|||
import type { OpenAIToolDefinition } from '$lib/types';
|
||||
import { ToolsService } from '$lib/services/tools.service';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { HealthCheckStatus, ToolSource } from '$lib/enums';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { DISABLED_TOOLS_LOCALSTORAGE_KEY } from '$lib/constants';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
export interface ToolEntry {
|
||||
source: ToolSource;
|
||||
/** For MCP tools, the server ID; otherwise undefined */
|
||||
serverName?: string;
|
||||
definition: OpenAIToolDefinition;
|
||||
}
|
||||
|
||||
export interface ToolGroup {
|
||||
source: ToolSource;
|
||||
label: string;
|
||||
/** For MCP groups, the server ID */
|
||||
serverId?: string;
|
||||
tools: OpenAIToolDefinition[];
|
||||
}
|
||||
|
||||
class ToolsStore {
|
||||
private _builtinTools = $state<OpenAIToolDefinition[]>([]);
|
||||
private _loading = $state(false);
|
||||
private _error = $state<string | null>(null);
|
||||
private _disabledTools = $state(new SvelteSet<string>());
|
||||
|
||||
constructor() {
|
||||
try {
|
||||
const stored = localStorage.getItem(DISABLED_TOOLS_LOCALSTORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const name of parsed) {
|
||||
if (typeof name === 'string') this._disabledTools.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
throw new Error('Failed to load disabled tools from localStorage');
|
||||
}
|
||||
}
|
||||
|
||||
private persistDisabledTools(): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
DISABLED_TOOLS_LOCALSTORAGE_KEY,
|
||||
JSON.stringify([...this._disabledTools])
|
||||
);
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
get builtinTools(): OpenAIToolDefinition[] {
|
||||
return this._builtinTools;
|
||||
}
|
||||
|
||||
get mcpTools(): OpenAIToolDefinition[] {
|
||||
return mcpStore.getToolDefinitionsForLLM();
|
||||
}
|
||||
|
||||
get customTools(): OpenAIToolDefinition[] {
|
||||
const raw = config().custom;
|
||||
if (!raw || typeof raw !== 'string') return [];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
return parsed.filter(
|
||||
(t: unknown): t is OpenAIToolDefinition =>
|
||||
typeof t === 'object' &&
|
||||
t !== null &&
|
||||
'type' in t &&
|
||||
(t as OpenAIToolDefinition).type === 'function' &&
|
||||
'function' in t &&
|
||||
typeof (t as OpenAIToolDefinition).function?.name === 'string'
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Flat list of all tool entries with source metadata */
|
||||
get allTools(): ToolEntry[] {
|
||||
const entries: ToolEntry[] = [];
|
||||
|
||||
for (const def of this._builtinTools) {
|
||||
entries.push({ source: ToolSource.BUILTIN, definition: def });
|
||||
}
|
||||
|
||||
// Use live connections when available (full schema), fall back to health check data
|
||||
const connections = mcpStore.getConnections();
|
||||
if (connections.size > 0) {
|
||||
for (const [serverId, connection] of connections) {
|
||||
const serverName = mcpStore.getServerDisplayName(serverId);
|
||||
for (const tool of connection.tools) {
|
||||
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
};
|
||||
entries.push({
|
||||
source: ToolSource.MCP,
|
||||
serverName,
|
||||
definition: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: rawSchema
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const { serverName, tools } of this.getMcpToolsFromHealthChecks()) {
|
||||
for (const tool of tools) {
|
||||
entries.push({
|
||||
source: ToolSource.MCP,
|
||||
serverName,
|
||||
definition: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: { type: 'object', properties: {}, required: [] }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const def of this.customTools) {
|
||||
entries.push({ source: ToolSource.CUSTOM, definition: def });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Tools grouped by category for tree display */
|
||||
get toolGroups(): ToolGroup[] {
|
||||
const groups: ToolGroup[] = [];
|
||||
|
||||
if (this._builtinTools.length > 0) {
|
||||
groups.push({
|
||||
source: ToolSource.BUILTIN,
|
||||
label: 'Built-in',
|
||||
tools: this._builtinTools
|
||||
});
|
||||
}
|
||||
|
||||
// Use live connections when available, fall back to health check data
|
||||
const connections = mcpStore.getConnections();
|
||||
if (connections.size > 0) {
|
||||
for (const [serverId, connection] of connections) {
|
||||
if (connection.tools.length === 0) continue;
|
||||
const label = mcpStore.getServerDisplayName(serverId);
|
||||
const tools: OpenAIToolDefinition[] = connection.tools.map((tool) => {
|
||||
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
};
|
||||
return {
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: rawSchema
|
||||
}
|
||||
};
|
||||
});
|
||||
groups.push({ source: ToolSource.MCP, label, serverId, tools });
|
||||
}
|
||||
} else {
|
||||
for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) {
|
||||
if (tools.length === 0) continue;
|
||||
const defs: OpenAIToolDefinition[] = tools.map((tool) => ({
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: { type: 'object', properties: {}, required: [] }
|
||||
}
|
||||
}));
|
||||
groups.push({ source: ToolSource.MCP, label: serverName, serverId, tools: defs });
|
||||
}
|
||||
}
|
||||
|
||||
const custom = this.customTools;
|
||||
if (custom.length > 0) {
|
||||
groups.push({
|
||||
source: ToolSource.CUSTOM,
|
||||
label: 'JSON Schema',
|
||||
tools: custom
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Only enabled tool definitions (for sending to the API) */
|
||||
get enabledToolDefinitions(): OpenAIToolDefinition[] {
|
||||
return this.allTools
|
||||
.filter((t) => !this._disabledTools.has(t.definition.function.name))
|
||||
.map((t) => t.definition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns enabled tool definitions for sending to the LLM.
|
||||
* MCP tools use properly normalized schemas from mcpStore.
|
||||
* Filters out tools disabled via the UI checkboxes.
|
||||
*/
|
||||
getEnabledToolsForLLM(): OpenAIToolDefinition[] {
|
||||
const disabled = this._disabledTools;
|
||||
const result: OpenAIToolDefinition[] = [];
|
||||
|
||||
for (const tool of this._builtinTools) {
|
||||
if (!disabled.has(tool.function.name)) {
|
||||
result.push(tool);
|
||||
}
|
||||
}
|
||||
|
||||
// MCP tools with properly normalized schemas
|
||||
for (const tool of mcpStore.getToolDefinitionsForLLM()) {
|
||||
if (!disabled.has(tool.function.name)) {
|
||||
result.push(tool);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tool of this.customTools) {
|
||||
if (!disabled.has(tool.function.name)) {
|
||||
result.push(tool);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
get allToolDefinitions(): OpenAIToolDefinition[] {
|
||||
return this.allTools.map((t) => t.definition);
|
||||
}
|
||||
|
||||
get loading(): boolean {
|
||||
return this._loading;
|
||||
}
|
||||
|
||||
get error(): string | null {
|
||||
return this._error;
|
||||
}
|
||||
|
||||
get disabledTools(): SvelteSet<string> {
|
||||
return this._disabledTools;
|
||||
}
|
||||
|
||||
isToolEnabled(toolName: string): boolean {
|
||||
return !this._disabledTools.has(toolName);
|
||||
}
|
||||
|
||||
toggleTool(toolName: string): void {
|
||||
if (this._disabledTools.has(toolName)) {
|
||||
this._disabledTools.delete(toolName);
|
||||
} else {
|
||||
this._disabledTools.add(toolName);
|
||||
}
|
||||
this.persistDisabledTools();
|
||||
}
|
||||
|
||||
setToolEnabled(toolName: string, enabled: boolean): void {
|
||||
if (enabled) {
|
||||
this._disabledTools.delete(toolName);
|
||||
} else {
|
||||
this._disabledTools.add(toolName);
|
||||
}
|
||||
}
|
||||
|
||||
toggleGroup(group: ToolGroup): void {
|
||||
const allEnabled = group.tools.every((t) => this.isToolEnabled(t.function.name));
|
||||
for (const tool of group.tools) {
|
||||
this.setToolEnabled(tool.function.name, !allEnabled);
|
||||
}
|
||||
this.persistDisabledTools();
|
||||
}
|
||||
|
||||
isGroupFullyEnabled(group: ToolGroup): boolean {
|
||||
return group.tools.length > 0 && group.tools.every((t) => this.isToolEnabled(t.function.name));
|
||||
}
|
||||
|
||||
isGroupPartiallyEnabled(group: ToolGroup): boolean {
|
||||
const enabledCount = group.tools.filter((t) => this.isToolEnabled(t.function.name)).length;
|
||||
return enabledCount > 0 && enabledCount < group.tools.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MCP tools from health check data (reactive).
|
||||
* Used when live connections aren't established yet.
|
||||
*/
|
||||
private getMcpToolsFromHealthChecks(): {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
tools: { name: string; description?: string }[];
|
||||
}[] {
|
||||
const result: ReturnType<ToolsStore['getMcpToolsFromHealthChecks']> = [];
|
||||
for (const server of mcpStore.getServersSorted().filter((s) => s.enabled)) {
|
||||
const health = mcpStore.getHealthCheckState(server.id);
|
||||
if (health.status === HealthCheckStatus.SUCCESS && health.tools.length > 0) {
|
||||
result.push({
|
||||
serverId: server.id,
|
||||
serverName: mcpStore.getServerLabel(server),
|
||||
tools: health.tools
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Determine the source of a tool by its name. */
|
||||
getToolSource(toolName: string): ToolSource | null {
|
||||
if (this._builtinTools.some((t) => t.function.name === toolName)) {
|
||||
return ToolSource.BUILTIN;
|
||||
}
|
||||
for (const entry of this.allTools) {
|
||||
if (entry.definition.function.name === toolName) {
|
||||
return entry.source;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Check if there are any enabled tools available (builtin, MCP, or custom). */
|
||||
get hasEnabledTools(): boolean {
|
||||
return this.getEnabledToolsForLLM().length > 0;
|
||||
}
|
||||
|
||||
async fetchBuiltinTools(): Promise<void> {
|
||||
if (this._loading) return;
|
||||
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
|
||||
try {
|
||||
const toolInfos = await ToolsService.list();
|
||||
this._builtinTools = toolInfos.map((info) => info.definition);
|
||||
} catch (err) {
|
||||
this._error = err instanceof Error ? err.message : String(err);
|
||||
console.error('[ToolsStore] Failed to fetch built-in tools:', err);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const toolsStore = new ToolsStore();
|
||||
|
||||
export const allTools = () => toolsStore.allTools;
|
||||
export const allToolDefinitions = () => toolsStore.allToolDefinitions;
|
||||
export const enabledToolDefinitions = () => toolsStore.enabledToolDefinitions;
|
||||
export const toolGroups = () => toolsStore.toolGroups;
|
||||
|
|
@ -116,6 +116,7 @@ export type {
|
|||
ServerStatus,
|
||||
ToolCallParams,
|
||||
ToolExecutionResult,
|
||||
ServerBuiltinToolInfo,
|
||||
Tool,
|
||||
Prompt,
|
||||
GetPromptResult,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { MCPConnectionPhase, MCPLogLevel, HealthCheckStatus } from '$lib/enums/mcp';
|
||||
import type { ToolSource } from '$lib/enums/tools';
|
||||
import type {
|
||||
Client,
|
||||
ClientCapabilities as SDKClientCapabilities,
|
||||
|
|
@ -256,6 +257,16 @@ export interface ToolExecutionResult {
|
|||
isError: boolean;
|
||||
}
|
||||
|
||||
export interface ServerBuiltinToolInfo {
|
||||
display_name: string;
|
||||
tool: string;
|
||||
type: ToolSource.BUILTIN;
|
||||
permissions: {
|
||||
write: boolean;
|
||||
};
|
||||
definition: OpenAIToolDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress tracking state for a specific operation
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { ChatScreen } from '$lib/components/app';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let showCenteredEmpty = $derived(!page.params.id);
|
||||
</script>
|
||||
|
||||
<ChatScreen {showCenteredEmpty} />
|
||||
|
||||
{@render children?.()}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { ChatScreen, DialogModelNotAvailable } from '$lib/components/app';
|
||||
import { DialogModelNotAvailable } from '$lib/components/app';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { conversationsStore, isConversationsInitialized } from '$lib/stores/conversations.svelte';
|
||||
import { modelsStore, modelOptions } from '$lib/stores/models.svelte';
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import { APP_NAME } from '$lib/constants';
|
||||
|
||||
let qParam = $derived(page.url.searchParams.get('q'));
|
||||
let modelParam = $derived(page.url.searchParams.get('model'));
|
||||
|
|
@ -87,11 +88,9 @@
|
|||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>llama.cpp - AI Chat Interface</title>
|
||||
<title>{APP_NAME}</title>
|
||||
</svelte:head>
|
||||
|
||||
<ChatScreen showCenteredEmpty />
|
||||
|
||||
<DialogModelNotAvailable
|
||||
bind:open={showModelNotAvailable}
|
||||
modelName={requestedModelName}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
import { goto, replaceState } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import { ChatScreen, DialogModelNotAvailable } from '$lib/components/app';
|
||||
import { DialogModelNotAvailable } from '$lib/components/app';
|
||||
import { chatStore, isLoading } from '$lib/stores/chat.svelte';
|
||||
import {
|
||||
conversationsStore,
|
||||
|
|
@ -169,8 +169,6 @@
|
|||
<title>{activeConversation()?.name || 'Chat'} - llama.cpp</title>
|
||||
</svelte:head>
|
||||
|
||||
<ChatScreen />
|
||||
|
||||
<DialogModelNotAvailable
|
||||
bind:open={showModelNotAvailable}
|
||||
modelName={requestedModelName}
|
||||
|
|
@ -4,13 +4,16 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { page } from '$app/state';
|
||||
import { untrack } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import {
|
||||
ChatSidebar,
|
||||
ChatSettings,
|
||||
DesktopIconStrip,
|
||||
DialogConversationTitleUpdate,
|
||||
DialogChatSettings
|
||||
DialogChatSettingsImportExport
|
||||
} from '$lib/components/app';
|
||||
import { isLoading } from '$lib/stores/chat.svelte';
|
||||
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
|
||||
|
|
@ -21,22 +24,17 @@
|
|||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { TOOLTIP_DELAY_DURATION } from '$lib/constants';
|
||||
import type { SettingsSectionTitle } from '$lib/constants';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
|
||||
import { setChatSettingsDialogContext } from '$lib/contexts';
|
||||
import { setImportExportDialogContext } from '$lib/contexts';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let isChatRoute = $derived(page.route.id === '/chat/[id]');
|
||||
let isHomeRoute = $derived(page.route.id === '/');
|
||||
let isNewChatMode = $derived(page.url.searchParams.get('new_chat') === 'true');
|
||||
let showSidebarByDefault = $derived(activeMessages().length > 0 || isLoading());
|
||||
let alwaysShowSidebarOnDesktop = $derived(config().alwaysShowSidebarOnDesktop);
|
||||
let autoShowSidebarOnNewChat = $derived(config().autoShowSidebarOnNewChat);
|
||||
let isMobile = new IsMobile();
|
||||
let isDesktop = $derived(!isMobile.current);
|
||||
let sidebarOpen = $state(false);
|
||||
let mounted = $state(false);
|
||||
let innerHeight = $state<number | undefined>();
|
||||
let chatSidebar:
|
||||
| { activateSearchMode?: () => void; editActiveConversation?: () => void }
|
||||
|
|
@ -48,16 +46,29 @@
|
|||
let titleUpdateNewTitle = $state('');
|
||||
let titleUpdateResolve: ((value: boolean) => void) | null = null;
|
||||
|
||||
let chatSettingsDialogOpen = $state(false);
|
||||
let chatSettingsDialogInitialSection = $state<SettingsSectionTitle | undefined>(undefined);
|
||||
let activePanel = $state<'chat' | 'settings' | 'mcp'>('chat');
|
||||
let isSettingsRoute = $derived(!!page.route.id?.startsWith('/settings'));
|
||||
let chatSettingsRef: ChatSettings | undefined = $state();
|
||||
let importExportDialogOpen = $state(false);
|
||||
|
||||
setChatSettingsDialogContext({
|
||||
open: (initialSection?: SettingsSectionTitle) => {
|
||||
chatSettingsDialogInitialSection = initialSection;
|
||||
chatSettingsDialogOpen = true;
|
||||
setImportExportDialogContext({
|
||||
open: () => {
|
||||
importExportDialogOpen = true;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (activePanel === 'settings' && chatSettingsRef) {
|
||||
chatSettingsRef.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// Return to chat when navigating to a new route
|
||||
$effect(() => {
|
||||
void page.url;
|
||||
activePanel = 'chat';
|
||||
});
|
||||
|
||||
// Global keyboard shortcuts
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const isCtrlOrCmd = event.ctrlKey || event.metaKey;
|
||||
|
|
@ -100,28 +111,16 @@
|
|||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (alwaysShowSidebarOnDesktop && isDesktop) {
|
||||
sidebarOpen = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHomeRoute && !isNewChatMode) {
|
||||
// Auto-collapse sidebar when navigating to home route (but not in new chat mode)
|
||||
sidebarOpen = false;
|
||||
} else if (isHomeRoute && isNewChatMode) {
|
||||
// Keep sidebar open in new chat mode
|
||||
sidebarOpen = true;
|
||||
} else if (isChatRoute) {
|
||||
// On chat routes, only auto-show sidebar if setting is enabled
|
||||
if (autoShowSidebarOnNewChat) {
|
||||
sidebarOpen = true;
|
||||
}
|
||||
// If setting is disabled, don't change sidebar state - let user control it manually
|
||||
} else {
|
||||
// Other routes follow default behavior
|
||||
sidebarOpen = showSidebarByDefault;
|
||||
}
|
||||
// Don't auto-open or auto-close sidebar during navigation - user controls it manually
|
||||
});
|
||||
|
||||
// Initialize server properties on app load (run once)
|
||||
|
|
@ -185,7 +184,7 @@
|
|||
const apiKey = config().apiKey;
|
||||
|
||||
if (
|
||||
(page.route.id === '/' || page.route.id === '/chat/[id]') &&
|
||||
(page.route.id === '/(chat)' || page.route.id === '/(chat)/chat/[id]') &&
|
||||
page.status !== 401 &&
|
||||
page.status !== 403
|
||||
) {
|
||||
|
|
@ -229,10 +228,9 @@
|
|||
|
||||
<Toaster richColors />
|
||||
|
||||
<DialogChatSettings
|
||||
open={chatSettingsDialogOpen}
|
||||
onOpenChange={(open) => (chatSettingsDialogOpen = open)}
|
||||
initialSection={chatSettingsDialogInitialSection}
|
||||
<DialogChatSettingsImportExport
|
||||
open={importExportDialogOpen}
|
||||
onOpenChange={(open) => (importExportDialogOpen = open)}
|
||||
/>
|
||||
|
||||
<DialogConversationTitleUpdate
|
||||
|
|
@ -245,20 +243,36 @@
|
|||
|
||||
<Sidebar.Provider bind:open={sidebarOpen}>
|
||||
<div class="flex h-screen w-full" style:height="{innerHeight}px">
|
||||
<Sidebar.Root class="h-full">
|
||||
<Sidebar.Root variant="floating" class="h-full">
|
||||
<ChatSidebar bind:this={chatSidebar} />
|
||||
</Sidebar.Root>
|
||||
|
||||
{#if !(alwaysShowSidebarOnDesktop && isDesktop)}
|
||||
<Sidebar.Trigger
|
||||
class="transition-left absolute left-0 z-[900] duration-200 ease-linear {sidebarOpen
|
||||
? 'md:left-[var(--sidebar-width)]'
|
||||
: 'md:left-0!'}"
|
||||
style="translate: 1rem 1rem;"
|
||||
{#if !(alwaysShowSidebarOnDesktop && isDesktop) && !(isSettingsRoute && !isDesktop)}
|
||||
{#if mounted}
|
||||
<div in:fade={{ duration: 200 }}>
|
||||
<Sidebar.Trigger
|
||||
class="transition-left absolute left-0 z-[900] duration-200 ease-linear {sidebarOpen
|
||||
? 'left-[calc(var(--sidebar-width)+0.75rem)] max-md:hidden'
|
||||
: 'left-0!'}"
|
||||
style="translate: 1rem 1rem;"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if isDesktop && !alwaysShowSidebarOnDesktop}
|
||||
<DesktopIconStrip
|
||||
{sidebarOpen}
|
||||
onSearchClick={() => {
|
||||
if (chatSidebar?.activateSearchMode) {
|
||||
chatSidebar.activateSearchMode();
|
||||
}
|
||||
sidebarOpen = true;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Sidebar.Inset class="flex flex-1 flex-col overflow-hidden">
|
||||
<Sidebar.Inset class="flex flex-1 flex-col overflow-auto">
|
||||
{@render children?.()}
|
||||
</Sidebar.Inset>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
<script lang="ts">
|
||||
import { X } from '@lucide/svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { page } from '$app/state';
|
||||
import { ActionIcon } from '$lib/components/app';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let previousRouteId = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const currentId = page.route.id;
|
||||
return () => {
|
||||
previousRouteId = currentId;
|
||||
};
|
||||
});
|
||||
|
||||
function handleClose() {
|
||||
const prevIsSettings = previousRouteId?.startsWith('/settings');
|
||||
if (browser && window.history.length > 1 && !prevIsSettings) {
|
||||
history.back();
|
||||
} else {
|
||||
goto('#/');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative h-full">
|
||||
<div class="fixed top-4.5 right-4 z-50 md:hidden">
|
||||
<ActionIcon icon={X} tooltip="Close" onclick={handleClose} />
|
||||
</div>
|
||||
|
||||
<div class="min-h-full">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { SettingsChat } from '$lib/components/app/settings';
|
||||
import type { SettingsSectionTitle } from '$lib/constants';
|
||||
|
||||
let sectionParam = $derived(page.url.searchParams.get('section') as SettingsSectionTitle | null);
|
||||
</script>
|
||||
|
||||
<SettingsChat
|
||||
onSave={() => goto('#/')}
|
||||
initialSection={sectionParam ?? undefined}
|
||||
class="mx-auto max-h-[100dvh] md:pl-8"
|
||||
/>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { SettingsImportExport } from '$lib/components/app/settings';
|
||||
</script>
|
||||
|
||||
<div class="mx-auto w-full p-4 md:p-8">
|
||||
<SettingsImportExport />
|
||||
</div>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { SettingsMcpServers } from '$lib/components/app/settings';
|
||||
</script>
|
||||
|
||||
<SettingsMcpServers class="mx-auto w-full p-4 md:p-8" />
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import Page from '../../../src/routes/+page.svelte';
|
||||
import Page from '../../../src/routes/(chat)/+page.svelte';
|
||||
|
||||
let sidebarOpen = $state(false);
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ export default defineConfig({
|
|||
'/v1': 'http://localhost:8080',
|
||||
'/props': 'http://localhost:8080',
|
||||
'/models': 'http://localhost:8080',
|
||||
'/tools': 'http://localhost:8080',
|
||||
'/cors-proxy': 'http://localhost:8080'
|
||||
},
|
||||
headers: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue