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
co-authored by Xuan Son Nguyen
parent 19821178be
commit f42e29fdf1
138 changed files with 11345 additions and 8326 deletions
@@ -0,0 +1,21 @@
<script lang="ts">
import { cn } from '$lib/components/ui/utils';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
interface Props extends HTMLAttributes<HTMLDivElement> {
children: Snippet;
}
let { class: className, children, ...restProps }: Props = $props();
</script>
<div
class={cn(
'flex items-center [&>*:first-child]:rounded-r-none [&>*:last-child]:rounded-l-none [&>*:not(:first-child):not(:last-child)]:rounded-none',
className
)}
{...restProps}
>
{@render children()}
</div>
@@ -0,0 +1,8 @@
<script lang="ts">
import { cn } from '$lib/components/ui/utils';
import type { HTMLAttributes } from 'svelte/elements';
let { ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<div class={cn('shrink-0 self-stretch bg-border', 'w-px')} {...restProps}></div>
@@ -0,0 +1,2 @@
export { default as Root } from './button-group-root.svelte';
export { default as Separator } from './button-group-separator.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,9 +1,9 @@
<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';
import { remToPx } from '$lib/utils';
let {
ref = $bindable(null),
@@ -20,6 +20,34 @@
} = $props();
const sidebar = useSidebar();
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 +61,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 +75,76 @@
<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)',
'group-data-[collapsible=offcanvas]:pointer-events-none md:group-data-[collapsible=offcanvas]:pointer-events-auto',
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}
/>