webui: Server tools (#21237)

* wip: server_tools

* feat: Integrate with `/tools` endpoint

* feat: Builtin + MCP + JSON Schema Tools WIP

* refactor

* displayName -> display_name

* snake_case everywhere

* rm redundant field

* feat: Improvements

* chore: update webui build output

* refactor: Updates after server updates

* chore: update webui build output

* change arg to --tools all

* feat: UI improvements

* chore: update webui build output

* add readme mention

* llama-gen-docs

* chore: update webui build output

* chore: update webui build output

* chore: update webui build output

* feat: Reorganize settings sections

* feat: Separate dialogs for MCP Servers Settings and Import/Export

* feat: WIP

* feat: WIP

* feat: WIP

* feat: WIP

* feat: WIP

* feat: WIP

* WIP on allozaur/20677-webui-server-tools

* feat: UI improvements

* chore: Update package lock

* chore: Run `npm audit fix`

* feat: UI WIP

* feat: UI

* refactor: Desktop Icon Strip DRY

* feat: Cleaner rendering and transition for ChatScreen

* feat: UI improvements

* feat: UI improvement

* feat: Remove MCP Server "enable" switch from Tools submenu

* chore: Run `npm audit fix`

* feat: WIP

* feat: Logic improvements

* refactor: Cleanup

* refactor: DRY

* test: Fix Chat Sidebar UI Tests

* chore: Update package lock

* refactor: Cleanup

* feat: Chat Message Action Card with Continue and Permission flow implementations

* feat: Add agentic steering messages, draft messages and improve chat UX

* fix: Search results UI

* test: Fix unit test

* feat: UI/UX improvements

* refactor: Simplify `useToolsPanel` access in components

* feat: Implement Processing Info Context API

* feat: Implement 'Go back to chat' functionality for settings

* feat: Enhance MCP Server management in Chat Form Attachments

* style: Minor UI and branding adjustments

* chore: Update webui static build output

* chore: Formatting, linting & type checks

* feat: Draft messages logic

* feat: UI improvements

* feat: Steering Messages improvements

* refactor: Cleanup

* refactor: Cleanup

* feat: Improve UI

* refactor: Settings navigation hook

* refactor: DRY code

* refactor: DRY ChatMessageUser UI components

* refactor: Desktop Icon Strip DRY

* refactor: Tools & permissions

* fix: Navigation condition

* refactor: Cleanup

* refactor: Cleanup

* refactor: Cleanup

* fix: preserve reasoning_content in agentic flow

---------

Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
This commit is contained in:
Aleksander Grygier
2026-04-28 14:35:49 +03:00
committed by GitHub
parent 19821178be
commit f42e29fdf1
138 changed files with 11345 additions and 8326 deletions
@@ -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'));
@@ -92,11 +93,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}
+41 -77
View File
@@ -4,39 +4,34 @@
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,
DialogConversationTitleUpdate,
DialogChatSettings
DesktopIconStrip,
DialogConversationTitleUpdate
} 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';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { ModeWatcher } from 'mode-watcher';
import { Toaster } from 'svelte-sonner';
import { goto } from '$app/navigation';
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 { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
import { useSettingsNavigation } from '$lib/hooks/use-settings-navigation.svelte';
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,41 +43,12 @@
let titleUpdateNewTitle = $state('');
let titleUpdateResolve: ((value: boolean) => void) | null = null;
let chatSettingsDialogOpen = $state(false);
let chatSettingsDialogInitialSection = $state<SettingsSectionTitle | undefined>(undefined);
setChatSettingsDialogContext({
open: (initialSection?: SettingsSectionTitle) => {
chatSettingsDialogInitialSection = initialSection;
chatSettingsDialogOpen = true;
}
});
const panelNav = useSettingsNavigation();
// Global keyboard shortcuts
function handleKeydown(event: KeyboardEvent) {
const isCtrlOrCmd = event.ctrlKey || event.metaKey;
if (isCtrlOrCmd && event.key === KeyboardKey.K_LOWER) {
event.preventDefault();
if (chatSidebar?.activateSearchMode) {
chatSidebar.activateSearchMode();
sidebarOpen = true;
}
}
if (isCtrlOrCmd && event.shiftKey && event.key === KeyboardKey.O_UPPER) {
event.preventDefault();
goto('?new_chat=true#/');
}
if (event.shiftKey && isCtrlOrCmd && event.key === KeyboardKey.E_UPPER) {
event.preventDefault();
if (chatSidebar?.editActiveConversation) {
chatSidebar.editActiveConversation();
}
}
}
const { handleKeydown } = useKeyboardShortcuts({
editActiveConversation: () => chatSidebar?.editActiveConversation?.()
});
function handleTitleUpdateCancel() {
titleUpdateDialogOpen = false;
@@ -100,28 +66,15 @@
}
}
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;
}
});
// Initialize server properties on app load (run once)
@@ -185,7 +138,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,12 +182,6 @@
<Toaster richColors />
<DialogChatSettings
open={chatSettingsDialogOpen}
onOpenChange={(open) => (chatSettingsDialogOpen = open)}
initialSection={chatSettingsDialogInitialSection}
/>
<DialogConversationTitleUpdate
bind:open={titleUpdateDialogOpen}
currentTitle={titleUpdateCurrentTitle}
@@ -245,16 +192,33 @@
<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) && !(panelNav.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}
@@ -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,6 @@
<script lang="ts">
import { SettingsChat } from '$lib/components/app/settings';
import { page } from '$app/state';
</script>
<SettingsChat initialSection={(page.params as Record<string, string | undefined>).section} />
@@ -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 md:py-10">
<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 md:py-8" />