This commit is contained in:
Timothy Jaeryang Baek
2026-08-13 19:59:11 -06:00
parent b018feb741
commit 7d99b2716a
27 changed files with 1069 additions and 111 deletions
+40
View File
@@ -1369,6 +1369,46 @@ export const deleteChatMessageById = async (token: string, id: string, messageId
return res;
};
export const resolveChatMessageToolCall = async (
token: string,
id: string,
messageId: string,
callId: string,
action: 'approve' | 'reject' | 'answer',
options: { answers?: unknown; timed_out?: boolean } = {}
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/messages/${messageId}/resolve`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
call_id: callId,
action,
...options
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = getErrorDetail(err);
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteChatById = async (token: string, id: string) => {
let error = null;
@@ -9,6 +9,7 @@
import Spinner from '$lib/components/common/Spinner.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ExperimentalBadge from '$lib/components/common/ExperimentalBadge.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import DatabaseSettings from '$lib/components/icons/DatabaseSettings.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
@@ -797,11 +798,7 @@
<div class="mb-2 flex items-center justify-between">
<div class="flex items-center gap-2 leading-none text-gray-600 dark:text-gray-400">
<div>{$i18n.t('External Knowledge Sources')}</div>
<span
class="inline-flex items-center text-[0.625rem] font-normal uppercase leading-none text-gray-400 dark:text-gray-600"
>
{$i18n.t('Experimental')}
</span>
<ExperimentalBadge />
</div>
<Tooltip content={$i18n.t('Add Connection')}>
@@ -10,6 +10,7 @@
import Textarea from '$lib/components/common/Textarea.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import SettingsSelect from '$lib/components/common/SettingsSelect.svelte';
import ExperimentalBadge from '$lib/components/common/ExperimentalBadge.svelte';
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
import AdminSettingField from './AdminSettingField.svelte';
import AdminSettingRow from './AdminSettingRow.svelte';
@@ -48,7 +49,8 @@
CONTEXT_COMPACTION_TOKEN_THRESHOLD: 80000,
CONTEXT_COMPACTION_TOKEN_CAP: 80000,
CONTEXT_COMPACTION_RETENTION_PERCENTAGE: 40,
CONTEXT_COMPACTION_PROMPT_TEMPLATE: ''
CONTEXT_COMPACTION_PROMPT_TEMPLATE: '',
ENABLE_TOOL_PERMISSIONS: false
};
let showTaskParameters = false;
@@ -75,7 +77,8 @@
...current,
features: {
...current.features,
enable_context_compaction: chatConfig.ENABLE_CONTEXT_COMPACTION
enable_context_compaction: chatConfig.ENABLE_CONTEXT_COMPACTION,
enable_tool_permissions: chatConfig.ENABLE_TOOL_PERMISSIONS
}
}
: current
@@ -253,6 +256,18 @@
</AdminSettingSection>
<AdminSettingSection title={$i18n.t('Chat')}>
<AdminSettingRow
label={$i18n.t('Tool Permissions')}
description={$i18n.t('Show Full access and Ask for approval in the chat input menu.')}
let:labelId
>
<div slot="label" class="flex items-center gap-2">
<span>{$i18n.t('Tool Permissions')}</span>
<ExperimentalBadge />
</div>
<Switch bind:state={chatConfig.ENABLE_TOOL_PERMISSIONS} ariaLabelledbyId={labelId} />
</AdminSettingRow>
<AdminSettingRow
label={$i18n.t('Context Compaction')}
description={$i18n.t(
@@ -24,6 +24,7 @@
import ModelDeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import SettingsSelect from '$lib/components/common/SettingsSelect.svelte';
import ExperimentalBadge from '$lib/components/common/ExperimentalBadge.svelte';
let modelUploadInputElement: HTMLInputElement;
let showModelDeleteConfirm = false;
@@ -938,7 +939,7 @@
<div class="pt-1">
<div class="flex justify-between items-center text-xs">
<div class=" text-sm font-normal">{$i18n.t('Experimental')}</div>
<div class="text-sm font-normal"><ExperimentalBadge /></div>
<button
class=" text-xs font-normal text-gray-500"
type="button"
+21 -3
View File
@@ -414,6 +414,15 @@
let loadedChatIdProp = '';
let currentDraftKey = '';
$: toolApprovalMode = params?.tool_approval_mode === 'ask' ? 'ask' : 'full';
const handleToolApprovalModeChange = (mode) => {
params = {
...params,
tool_approval_mode: mode === 'ask' ? 'ask' : 'full'
};
};
const mergeChatVariableSchemas = (modelIds = [], availableModels = []) => {
const byKey: Record<string, any> = {};
const conflicts: any[] = [];
@@ -619,6 +628,9 @@
webSearchEnabled = input.webSearchEnabled;
imageGenerationEnabled = input.imageGenerationEnabled;
codeInterpreterEnabled = input.codeInterpreterEnabled;
if (input.toolApprovalMode) {
handleToolApprovalModeChange(input.toolApprovalMode);
}
}
} catch (e) {}
} else {
@@ -1396,6 +1408,9 @@
webSearchEnabled = input.webSearchEnabled;
imageGenerationEnabled = input.imageGenerationEnabled;
codeInterpreterEnabled = input.codeInterpreterEnabled;
if (input.toolApprovalMode) {
handleToolApprovalModeChange(input.toolApprovalMode);
}
}
} catch (e) {}
}
@@ -4168,7 +4183,8 @@
compactHandler={handleManualCompact}
statusHandler={handleStatusCommand}
forkHandler={handleForkChat}
toolServers={$toolServers}
{toolApprovalMode}
onToolApprovalModeChange={handleToolApprovalModeChange}
{generating}
{stopResponse}
{createMessagePair}
@@ -4271,7 +4287,8 @@
compactHandler={handleManualCompact}
statusHandler={handleStatusCommand}
forkHandler={handleForkChat}
toolServers={$toolServers}
{toolApprovalMode}
onToolApprovalModeChange={handleToolApprovalModeChange}
{generating}
{stopResponse}
{createMessagePair}
@@ -4328,8 +4345,9 @@
bind:atSelectedModel
bind:showCommands
bind:dragged
{toolApprovalMode}
onToolApprovalModeChange={handleToolApprovalModeChange}
{pendingOAuthTools}
toolServers={$toolServers}
{stopResponse}
{createMessagePair}
{onSelect}
+6 -1
View File
@@ -170,6 +170,8 @@
export let imageGenerationEnabled = false;
export let webSearchEnabled = false;
export let codeInterpreterEnabled = false;
export let toolApprovalMode = 'full';
export let onToolApprovalModeChange: Function = () => {};
export let pendingOAuthTools = [];
@@ -225,7 +227,8 @@
selectedFilterIds,
imageGenerationEnabled,
webSearchEnabled,
codeInterpreterEnabled
codeInterpreterEnabled,
toolApprovalMode
});
const inputVariableHandler = async (text: string): Promise<string> => {
@@ -2045,6 +2048,8 @@
bind:files
selectedModels={selectedModelIds}
{fileUploadCapableModels}
{toolApprovalMode}
{onToolApprovalModeChange}
{screenCaptureHandler}
{inputFilesHandler}
uploadFilesHandler={() => {
@@ -16,7 +16,6 @@
import Clip from '$lib/components/icons/Clip.svelte';
import ChatBubbleOval from '$lib/components/icons/ChatBubbleOval.svelte';
import Refresh from '$lib/components/icons/Refresh.svelte';
import Agile from '$lib/components/icons/Agile.svelte';
import ClockRotateRight from '$lib/components/icons/ClockRotateRight.svelte';
import Database from '$lib/components/icons/Database.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
@@ -45,11 +44,25 @@
export let onUpload: Function;
export let onClose: Function;
export let toolApprovalMode = 'full';
export let onToolApprovalModeChange: Function = () => {};
let show = false;
let tab = '';
let showAttachWebpageModal = false;
const toolApprovalModes = [
{
value: 'full',
label: 'Full access',
description: 'Run tools without asking for approval.'
},
{
value: 'ask',
label: 'Ask for approval',
description: 'Stop before each tool call until you allow or deny it.'
}
];
let fileUploadEnabled = true;
$: fileUploadEnabled =
@@ -58,6 +71,7 @@
let webUploadEnabled = true;
$: webUploadEnabled = $user?.role === 'admin' || ($user?.permissions?.chat?.web_upload ?? true);
$: toolPermissionsEnabled = $config?.features?.enable_tool_permissions ?? false;
$: if (!fileUploadEnabled && files.length > 0) {
files = [];
@@ -129,6 +143,47 @@
class="max-h-72 overflow-y-auto overflow-x-hidden scrollbar-thin"
in:fly={{ x: -20, duration: 150 }}
>
{#if toolPermissionsEnabled}
<button
class="flex gap-2 w-full items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl"
on:click={() => {
tab = 'tool_permissions';
}}
>
<svg
class="size-3.5 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 22C12 22 20 18 20 12V5L12 2L4 5V12C4 18 12 22 12 22Z" />
</svg>
<div class="flex items-center w-full justify-between min-w-0">
<div class="line-clamp-1">
{$i18n.t('Tool Permissions')}
</div>
<div class="flex items-center gap-2 min-w-0">
<div class="text-xs text-gray-500 truncate">
{$i18n.t(
toolApprovalModes.find((mode) => mode.value === toolApprovalMode)?.label ??
'Full access'
)}
</div>
<div class="text-gray-500">
<ChevronRight />
</div>
</div>
</div>
</button>
<div class="h-px mx-1 my-1 bg-gray-100 dark:bg-gray-800"></div>
{/if}
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
@@ -487,6 +542,55 @@
{/if}
{/if}
</div>
{:else if tab === 'tool_permissions'}
<div class="flex max-h-72 flex-col overflow-hidden" in:fly={{ x: 20, duration: 150 }}>
<button
class="flex w-full shrink-0 justify-between gap-2 items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal select-none cursor-pointer rounded-xl hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={() => {
tab = '';
}}
>
<ChevronLeft />
<div class="flex items-center w-full justify-between">
<div>
{$i18n.t('Tool Permissions')}
</div>
</div>
</button>
<div class="mt-1 space-y-1">
{#each toolApprovalModes as mode}
<Tooltip content={$i18n.t(mode.description)} className="w-full">
<button
class="flex gap-2 w-full items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl"
on:click={() => {
toolApprovalMode = mode.value;
onToolApprovalModeChange(mode.value);
tab = '';
}}
>
<div class="flex items-center w-full justify-between min-w-0">
<div class="line-clamp-1">{$i18n.t(mode.label)}</div>
{#if toolApprovalMode === mode.value}
<svg
class="size-3 shrink-0 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
{/if}
</div>
</button>
</Tooltip>
{/each}
</div>
</div>
{:else if tab === 'knowledge'}
<div class="flex max-h-72 flex-col overflow-hidden" in:fly={{ x: 20, duration: 150 }}>
<button
@@ -6,7 +6,7 @@
import StructuredOutputRenderer from './StructuredOutputRenderer.svelte';
import {
artifactCode,
chatId,
chatId as currentChatId,
mobile,
settings,
showArtifacts,
@@ -68,6 +68,7 @@
};
export let id;
export let chatId = '';
export let content;
/** @type {import('./structuredOutput').OutputItem[]} */
export let output = [];
@@ -153,7 +154,7 @@
hasClosingCodeFence(raw) &&
!autoOpenedArtifactIds.has(artifactId) &&
!$mobile &&
$chatId
$currentChatId
) {
autoOpenedArtifactIds.add(artifactId);
await tick();
@@ -283,6 +284,8 @@
{#if output?.length}
<StructuredOutputRenderer
{id}
{chatId}
{messageId}
{output}
{model}
{save}
@@ -304,6 +307,8 @@
<div class="markdown-prose">
<Markdown
{id}
{chatId}
{messageId}
content={formatMessageContent(content)}
{model}
{save}
@@ -326,7 +331,16 @@
{#if extracted.detailsContent}
<!-- Render structural blocks (tool calls, reasoning, etc.) through Markdown -->
<div class="markdown-prose">
<Markdown {id} content={extracted.detailsContent} {preview} {compactPreview} {done} />
<Markdown
{id}
{chatId}
{messageId}
content={extracted.detailsContent}
{save}
{preview}
{compactPreview}
{done}
/>
</div>
{/if}
{#if extracted.plainContent}
@@ -36,6 +36,8 @@
import MarkdownTokens from './Markdown/MarkdownTokens.svelte';
export let id = '';
export let chatId = '';
export let messageId = '';
export let content;
export let done = true;
export let model = null;
@@ -101,6 +103,8 @@
<MarkdownTokens
{tokens}
{id}
{chatId}
{messageId}
{done}
{save}
{preview}
@@ -7,9 +7,9 @@
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
import Sparkles from '$lib/components/icons/Sparkles.svelte';
import CheckCircle from '$lib/components/icons/CheckCircle.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import FullHeightIframe from '$lib/components/common/FullHeightIframe.svelte';
import { settings } from '$lib/stores';
@@ -23,6 +23,8 @@
type?: string;
name?: string;
done?: string;
id?: string;
status?: string;
duration?: string;
embeds?: string;
arguments?: string;
@@ -32,6 +34,9 @@
export let messageDone = true;
export let allowEmbeds = true;
export let compactPreview = false;
export let resolvable = false;
export let resolvingCallId = '';
export let onResolve: (callId: string, approved: boolean) => void = () => {};
let open = $settings?.expandDetails ?? false;
@@ -45,9 +50,13 @@
$: toolCallCount = tokens.filter((t) => t?.attributes?.type === 'tool_calls').length;
$: reasoningCount = tokens.filter((t) => t?.attributes?.type === 'reasoning').length;
$: hasPending =
!messageDone &&
tokens.some((t) => t?.attributes?.done !== undefined && t?.attributes?.done !== 'true');
$: pendingToolTokens = tokens.filter(
(t) => t?.attributes?.type === 'tool_calls' && t?.attributes?.status === 'pending'
);
$: hasPending = pendingToolTokens.length > 0;
$: hasRejected = tokens.some(
(t) => t?.attributes?.type === 'tool_calls' && t?.attributes?.status === 'rejected'
);
$: codeInterpreterCount = tokens.filter((t) => t?.attributes?.type === 'code_interpreter').length;
@@ -102,7 +111,6 @@
}
}
const prefix = hasPending ? $i18n.t('Exploring') : $i18n.t('Explored');
const detail = parts.join(', ');
return detail;
})();
@@ -110,54 +118,120 @@
$: prefixText = hasPending ? $i18n.t('Exploring') : $i18n.t('Explored');
</script>
<div {id} class="w-full">
<!-- svelte-ignore a11y-no-static-element-interactions -->
<button
class="w-fit py-0.5 text-left {compactPreview
? 'text-xs'
: 'text-[0.9375rem]'} text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition cursor-pointer"
aria-label={$i18n.t('Toggle details')}
aria-expanded={open}
on:click={() => {
open = !open;
}}
>
<div class="flex items-center gap-1.5">
<!-- Status icon -->
{#if hasPending}
<div>
<Spinner className="size-4" />
</div>
{:else if toolCallCount > 0}
<div class="text-emerald-500 dark:text-emerald-400">
<CheckCircle className="size-4" strokeWidth="2" />
</div>
{:else}
<div class="text-gray-400 dark:text-gray-500">
<Sparkles className="size-3.5" />
</div>
{/if}
<!-- Summary text -->
<div class="flex-1 line-clamp-1">
<span class="text-gray-600 dark:text-gray-300 {hasPending ? 'shimmer' : ''}"
>{prefixText}</span
>
{#if summaryText}
<span class="text-gray-400 dark:text-gray-500">{summaryText}</span>
{/if}
</div>
<!-- Chevron -->
<div class="flex shrink-0 self-center text-gray-400 dark:text-gray-500">
{#if open}
<ChevronUp strokeWidth="3.5" className="size-3" />
<div {id} class="w-full min-w-0">
<div class="flex w-full min-w-0 items-center gap-2">
<div
role="button"
tabindex="0"
class="flex-1 min-w-0 py-0.5 text-left {compactPreview
? 'text-xs'
: 'text-[0.9375rem]'} text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition cursor-pointer"
aria-label={$i18n.t('Toggle details')}
aria-expanded={open}
on:click={() => {
open = !open;
}}
on:keydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
open = !open;
}
}}
>
<div class="flex items-center gap-1.5 min-w-0">
<!-- Status icon -->
{#if hasPending}
<div>
<Spinner className="size-4" />
</div>
{:else if hasRejected}
<div class="text-red-400 dark:text-red-500">
<XMark className="size-4" strokeWidth="2.5" />
</div>
{:else if toolCallCount > 0}
<div class="text-emerald-500 dark:text-emerald-400">
<CheckCircle className="size-4" strokeWidth="2" />
</div>
{:else}
<ChevronDown strokeWidth="3.5" className="size-3" />
<div class="text-gray-400 dark:text-gray-500">
<Sparkles className="size-3.5" />
</div>
{/if}
<!-- Summary text -->
<div class="flex-1 line-clamp-1">
<span class="text-gray-600 dark:text-gray-300 {hasPending ? 'shimmer' : ''}"
>{prefixText}</span
>
{#if summaryText}
<span class="text-gray-400 dark:text-gray-500">{summaryText}</span>
{/if}
</div>
{#if resolvable && pendingToolTokens.length === 1}
{@const pendingCallId = pendingToolTokens[0]?.attributes?.id ?? ''}
<span class="flex gap-1 shrink-0">
<button
type="button"
class="text-[0.6875rem] px-2.5 py-0.5 rounded-md text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-white/8 hover:bg-gray-200 dark:hover:bg-white/12 transition-colors duration-100 disabled:opacity-50"
disabled={!pendingCallId || resolvingCallId === pendingCallId}
on:click|stopPropagation={() => onResolve(pendingCallId, true)}
>
{$i18n.t('Allow')}
</button>
<button
type="button"
class="text-[0.6875rem] px-2 py-0.5 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-100 disabled:opacity-50"
disabled={!pendingCallId || resolvingCallId === pendingCallId}
on:click|stopPropagation={() => onResolve(pendingCallId, false)}
>
{$i18n.t('Deny')}
</button>
</span>
{:else}
<!-- Chevron -->
<div class="flex shrink-0 self-center text-gray-400 dark:text-gray-500">
{#if open}
<ChevronUp strokeWidth="3.5" className="size-3" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3" />
{/if}
</div>
{/if}
</div>
</div>
</button>
</div>
{#if !open && resolvable && pendingToolTokens.length > 1}
<div class="mt-1 space-y-0.5">
{#each pendingToolTokens as token}
{@const pendingCallId = token?.attributes?.id ?? ''}
<div class="flex items-center gap-2 py-1 px-1">
<span class="text-xs text-gray-500 dark:text-gray-400 flex-1 min-w-0 line-clamp-1">
{token?.attributes?.name ?? $i18n.t('tool')}
</span>
<span class="flex gap-1 shrink-0">
<button
type="button"
class="text-[0.6875rem] px-2.5 py-0.5 rounded-md text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-white/8 hover:bg-gray-200 dark:hover:bg-white/12 transition-colors duration-100 disabled:opacity-50"
disabled={!pendingCallId || resolvingCallId === pendingCallId}
on:click={() => onResolve(pendingCallId, true)}
>
{$i18n.t('Allow')}
</button>
<button
type="button"
class="text-[0.6875rem] px-2 py-0.5 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-100 disabled:opacity-50"
disabled={!pendingCallId || resolvingCallId === pendingCallId}
on:click={() => onResolve(pendingCallId, false)}
>
{$i18n.t('Deny')}
</button>
</span>
</div>
{/each}
</div>
{/if}
{#if open}
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
@@ -8,9 +8,11 @@
import { marked, type Token } from 'marked';
import { copyToClipboard, unescapeHtml } from '$lib/utils';
import { resolveChatMessageToolCall } from '$lib/apis/chats';
import { WEBUI_BASE_URL } from '$lib/constants';
import { settings } from '$lib/stores';
import { toast } from 'svelte-sonner';
import CodeBlock from '$lib/components/chat/Messages/CodeBlock.svelte';
import MarkdownInlineTokens from '$lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte';
@@ -27,6 +29,8 @@
import ColonFenceBlock from './ColonFenceBlock.svelte';
export let id: string;
export let chatId = '';
export let messageId = '';
export let tokens: Token[];
export let top = true;
export let attributes = {};
@@ -98,7 +102,30 @@
.trim();
};
$: detailButtonClassName = `w-fit py-0.5 ${
let resolvingCallId = '';
const resolveToolCall = async (callId: string, approved: boolean) => {
if (!chatId || !messageId || !callId || resolvingCallId) {
return;
}
resolvingCallId = callId;
try {
await resolveChatMessageToolCall(
localStorage.token,
chatId,
messageId,
callId,
approved ? 'approve' : 'reject'
);
} catch (err) {
toast.error(String(err));
} finally {
resolvingCallId = '';
}
};
$: detailButtonClassName = `py-0.5 ${
compactPreview ? 'text-xs' : 'text-[0.9375rem]'
} text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition`;
@@ -279,8 +306,11 @@
<blockquote dir="auto">
<svelte:self
id={`${id}-${tokenIdx}`}
{chatId}
{messageId}
tokens={token.tokens}
{done}
{save}
{preview}
{compactPreview}
{editCodeBlock}
@@ -315,9 +345,12 @@
<svelte:self
id={`${id}-${tokenIdx}-${itemIdx}`}
{chatId}
{messageId}
tokens={item.tokens}
top={token.loose}
{done}
{save}
{preview}
{compactPreview}
{editCodeBlock}
@@ -352,9 +385,12 @@
<div>
<svelte:self
id={`${id}-${tokenIdx}-${itemIdx}`}
{chatId}
{messageId}
tokens={item.tokens}
top={token.loose}
{done}
{save}
{preview}
{compactPreview}
{editCodeBlock}
@@ -366,9 +402,12 @@
{:else}
<svelte:self
id={`${id}-${tokenIdx}-${itemIdx}`}
{chatId}
{messageId}
tokens={item.tokens}
top={token.loose}
{done}
{save}
{preview}
{compactPreview}
{editCodeBlock}
@@ -388,6 +427,9 @@
messageDone={done}
{compactPreview}
{allowEmbeds}
resolvable={!!chatId && !!messageId && save}
{resolvingCallId}
onResolve={resolveToolCall}
>
<div slot="content">
{#each token.items as detailToken, detailIdx}
@@ -399,6 +441,9 @@
attributes={detailToken.attributes}
resultContent={getDetailTextContent(detailToken)}
grouped={true}
resolvable={!!chatId && !!messageId && save}
resolving={resolvingCallId === detailToken.attributes?.id}
onResolve={(approved) => resolveToolCall(detailToken.attributes?.id ?? '', approved)}
open={$settings?.expandDetails ?? false}
className="w-full"
buttonClassName={detailButtonClassName}
@@ -416,9 +461,12 @@
<div class="mb-1.5" slot="content">
<svelte:self
id={`${id}-${tokenIdx}-${detailIdx}-d`}
{chatId}
{messageId}
tokens={marked.lexer(decode(detailToken.text))}
attributes={detailToken?.attributes}
{done}
{save}
{preview}
{compactPreview}
{editCodeBlock}
@@ -452,6 +500,9 @@
id={`${id}-${tokenIdx}-tc`}
attributes={token.attributes}
resultContent={getDetailTextContent(token)}
resolvable={!!chatId && !!messageId && save}
resolving={resolvingCallId === token.attributes?.id}
onResolve={(approved) => resolveToolCall(token.attributes?.id ?? '', approved)}
open={$settings?.expandDetails ?? false}
className="w-full space-y-2"
buttonClassName={detailButtonClassName}
@@ -469,9 +520,12 @@
<div class=" mb-1.5" slot="content">
<svelte:self
id={`${id}-${tokenIdx}-d`}
{chatId}
{messageId}
tokens={marked.lexer(decode(token.text))}
attributes={token?.attributes}
{done}
{save}
{preview}
{compactPreview}
{editCodeBlock}
@@ -815,6 +815,8 @@
<!-- unless message.error === true which is legacy error handling, where the error message is stored in message.content -->
<ContentRenderer
id={`${chatId}-${message.id}`}
{chatId}
messageId={message.id}
content={message.content}
output={message.output}
sources={message.sources}
@@ -1,7 +1,9 @@
<script lang="ts">
import Collapsible from '$lib/components/common/Collapsible.svelte';
import ToolCallDisplay from '$lib/components/common/ToolCallDisplay.svelte';
import { resolveChatMessageToolCall } from '$lib/apis/chats';
import { settings } from '$lib/stores';
import { toast } from 'svelte-sonner';
import Markdown from './Markdown.svelte';
import ConsecutiveDetailsGroup from './Markdown/ConsecutiveDetailsGroup.svelte';
@@ -13,6 +15,8 @@
} from './structuredOutput';
export let id = '';
export let chatId = '';
export let messageId = '';
export let output: OutputItem[] = [];
export let done = true;
export let model = null;
@@ -32,8 +36,30 @@
const getDetailTitle = (detailToken: OutputDetailToken): any => detailToken.summary;
const getDetailAttributes = (detailToken: OutputDetailToken): any => detailToken.attributes;
let resolvingCallId = '';
$: detailButtonClassName = `w-fit py-0.5 ${
const resolveToolCall = async (callId: string, approved: boolean) => {
if (!chatId || !messageId || !callId || resolvingCallId) {
return;
}
resolvingCallId = callId;
try {
await resolveChatMessageToolCall(
localStorage.token,
chatId,
messageId,
callId,
approved ? 'approve' : 'reject'
);
} catch (err) {
toast.error(String(err));
} finally {
resolvingCallId = '';
}
};
$: detailButtonClassName = `py-0.5 ${
compactPreview ? 'text-xs' : 'text-[0.9375rem]'
} text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition`;
@@ -46,6 +72,8 @@
<div class="markdown-prose">
<Markdown
id={`${id}-${displayItem.id}`}
{chatId}
{messageId}
content={formatMessageContent(displayItem.text)}
{model}
{save}
@@ -71,6 +99,9 @@
tokens={displayItem.tokens}
messageDone={done}
{compactPreview}
resolvable={!!chatId && !!messageId && save}
{resolvingCallId}
onResolve={resolveToolCall}
>
<div slot="content">
{#each displayItem.tokens as detailToken, detailIndex}
@@ -80,6 +111,9 @@
attributes={detailToken.attributes}
resultContent={detailToken.text}
grouped={true}
resolvable={!!chatId && !!messageId && save}
resolving={resolvingCallId === detailToken.attributes?.id}
onResolve={(approved) => resolveToolCall(detailToken.attributes?.id ?? '', approved)}
open={$settings?.expandDetails ?? false}
className="w-full"
buttonClassName={detailButtonClassName}
@@ -97,8 +131,11 @@
<div class="markdown-prose">
<Markdown
id={`${id}-${displayItem.id}-${detailIndex}-detail`}
{chatId}
{messageId}
content={detailToken.text}
{done}
{save}
{preview}
{compactPreview}
{editCodeBlock}
@@ -127,6 +164,9 @@
id={`${id}-${displayItem.id}-tool-call`}
attributes={detailToken.attributes}
resultContent={detailToken.text}
resolvable={!!chatId && !!messageId && save}
resolving={resolvingCallId === detailToken.attributes?.id}
onResolve={(approved) => resolveToolCall(detailToken.attributes?.id ?? '', approved)}
open={$settings?.expandDetails ?? false}
className="w-full space-y-2"
buttonClassName={detailButtonClassName}
@@ -144,8 +184,11 @@
<div class="markdown-prose">
<Markdown
id={`${id}-${displayItem.id}-detail`}
{chatId}
{messageId}
content={detailToken.text}
{done}
{save}
{preview}
{compactPreview}
{editCodeBlock}
@@ -38,6 +38,7 @@ export type OutputDetailToken = {
files?: string;
embeds?: string;
output?: string;
status?: string;
};
};
@@ -124,8 +125,9 @@ function getToolResultText(item?: OutputItem): string {
}
function buildToolCallToken(item: OutputItem, toolOutputByCallId: Record<string, OutputItem>) {
const callId = item.call_id ?? '';
const callId = item.call_id ?? item.id ?? '';
const resultItem = toolOutputByCallId[callId];
const status = String(item.status ?? '');
const isDone = isDoneStatus(item.status) || !!resultItem;
let name = item.name ?? '';
if (name === 'delegate_task') {
@@ -143,13 +145,14 @@ function buildToolCallToken(item: OutputItem, toolOutputByCallId: Record<string,
}
return {
summary: isDone ? 'Tool Executed' : 'Executing...',
summary: status === 'pending' ? 'Tool Approval Needed' : isDone ? 'Tool Executed' : 'Executing...',
text: getToolResultText(resultItem),
attributes: {
type: 'tool_calls',
id: callId,
name,
done: isDone ? 'true' : 'false',
status,
arguments: stringifyAttribute(item.arguments ?? ''),
files: stringifyAttribute(resultItem?.files),
embeds: stringifyAttribute(resultItem?.embeds)
+4 -2
View File
@@ -54,6 +54,8 @@
export let imageGenerationEnabled = false;
export let codeInterpreterEnabled = false;
export let webSearchEnabled = false;
export let toolApprovalMode = 'full';
export let onToolApprovalModeChange: Function = () => {};
export let onUpload: Function = (e) => {};
export let onUpdate: (data?: { file?: any }) => void = () => {};
@@ -65,7 +67,6 @@
export let onQueueEdit: (id: string) => void = () => {};
export let onQueueDelete: (id: string) => void = () => {};
export let toolServers = [];
export let dragged = false;
@@ -238,7 +239,8 @@
bind:showCommands
bind:dragged
{pendingOAuthTools}
{toolServers}
{toolApprovalMode}
{onToolApprovalModeChange}
{stopResponse}
{createMessagePair}
placeholder={$i18n.t('How can I help you today?')}
@@ -9,6 +9,7 @@
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import ExperimentalBadge from '$lib/components/common/ExperimentalBadge.svelte';
import MemoryModal from './Personalization/MemoryModal.svelte';
import { deleteMemoriesByUserId, deleteMemoryById, getMemories } from '$lib/apis/memories';
import { toast } from 'svelte-sonner';
@@ -121,19 +122,10 @@
)
.replace($i18n.t('Manage'), $i18n.t('Add Memory'))}
>
<Tooltip
slot="label"
content={$i18n.t(
'This is an experimental feature, it may not function as expected and is subject to change at any time.'
)}
>
<div class="flex items-center gap-2">
{$i18n.t('Memory')}
<span class="text-[0.625rem] uppercase text-gray-400 dark:text-gray-600"
>{$i18n.t('Experimental')}</span
>
</div>
</Tooltip>
<div slot="label" class="flex items-center gap-2">
{$i18n.t('Memory')}
<ExperimentalBadge />
</div>
<Switch
bind:state={enableMemory}
@@ -0,0 +1,22 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { Writable } from 'svelte/store';
import Tooltip from './Tooltip.svelte';
type I18nStore = Writable<{
t: (key: string, options?: Record<string, unknown>) => string;
}>;
const i18n: I18nStore = getContext('i18n');
$: label = $i18n.t('Experimental');
$: content = $i18n.t(
'This is an experimental feature, it may not function as expected and is subject to change at any time.'
);
</script>
<Tooltip {content} className="inline-flex">
<span class="text-[0.625rem] font-normal uppercase leading-none text-gray-400 dark:text-gray-600">
{label}
</span>
</Tooltip>
@@ -13,6 +13,7 @@
import Spinner from './Spinner.svelte';
import WrenchSolid from '../icons/WrenchSolid.svelte';
import CheckCircle from '../icons/CheckCircle.svelte';
import XMark from '../icons/XMark.svelte';
import Image from './Image.svelte';
import FullHeightIframe from './FullHeightIframe.svelte';
import { settings } from '$lib/stores';
@@ -27,18 +28,22 @@
files?: string;
embeds?: string;
done?: string;
status?: string;
} = {};
export let open = false;
export let grouped = false;
export let className = '';
export let resolvable = false;
export let resolving = false;
export let onResolve: (approved: boolean) => void = () => {};
const RESULT_PREVIEW_LIMIT = 10000;
let expandedResult = false;
$: if (!open) expandedResult = false;
export let buttonClassName =
'w-fit py-1 text-[0.9375rem] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition';
'py-1 text-[0.9375rem] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition';
const componentId = id || uuidv4();
@@ -91,7 +96,9 @@
$: args =
open || (Array.isArray(embeds) && embeds.length > 0) ? decode(attributes?.arguments ?? '') : '';
$: isDone = attributes?.done === 'true';
$: isExecuting = attributes?.done && attributes?.done !== 'true';
$: needsApproval = attributes?.status === 'pending' && resolvable;
$: isRejected = attributes?.status === 'rejected';
$: isExecuting = !needsApproval && attributes?.done && attributes?.done !== 'true';
$: parsedArgs = parseArguments(args);
$: parsedResult = parseJSONString(result);
@@ -133,14 +140,14 @@
{:else}
<!-- Tool call display -->
<div
class="{buttonClassName} cursor-pointer"
class="{buttonClassName} w-full min-w-0 cursor-pointer"
role="button"
tabindex="0"
on:click={toggleOpen}
on:keydown={toggleOpenOnKeydown}
>
<div
class="w-full max-w-full font-normal flex items-center gap-1.5 {isExecuting
class="w-full min-w-0 max-w-full font-normal flex items-center gap-1.5 {isExecuting
? 'shimmer'
: ''}"
>
@@ -149,6 +156,10 @@
<div>
<Spinner className="size-4" />
</div>
{:else if isRejected}
<div class="text-red-400 dark:text-red-500">
<XMark className="size-4" strokeWidth="2.5" />
</div>
{:else if isDone}
<div class="text-emerald-500 dark:text-emerald-400">
<CheckCircle className="size-4" strokeWidth="2" />
@@ -160,27 +171,52 @@
{/if}
<!-- Label -->
<div class="flex-1 line-clamp-1">
<div class="flex-1 min-w-0 line-clamp-1">
<!-- Short label (below md) -->
<span class="@md:hidden text-black dark:text-white">{attributes.name}</span>
<!-- Full label (md and above) -->
<span class="hidden @md:inline font-normal">
{#if isDone}
{$i18n.t('View Result from {{NAME}}', { NAME: attributes.name })}
{:else if needsApproval}
{$i18n.t('Allow {{NAME}}?', { NAME: attributes.name })}
{:else if isRejected}
{$i18n.t('Denied {{NAME}}', { NAME: attributes.name })}
{:else}
{$i18n.t('Executing {{NAME}}...', { NAME: attributes.name })}
{/if}
</span>
</div>
<!-- Chevron -->
<div class="flex shrink-0 self-center translate-y-[1px]">
{#if open}
<ChevronUp strokeWidth="3.5" className="size-3" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3" />
{/if}
</div>
{#if needsApproval}
<span class="flex gap-1 shrink-0">
<button
type="button"
class="text-[0.6875rem] px-2.5 py-0.5 rounded-md text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-white/8 hover:bg-gray-200 dark:hover:bg-white/12 transition-colors duration-100 disabled:opacity-50"
disabled={resolving}
on:click|stopPropagation={() => onResolve(true)}
>
{$i18n.t('Allow')}
</button>
<button
type="button"
class="text-[0.6875rem] px-2 py-0.5 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-100 disabled:opacity-50"
disabled={resolving}
on:click|stopPropagation={() => onResolve(false)}
>
{$i18n.t('Deny')}
</button>
</span>
{:else}
<!-- Chevron -->
<div class="flex shrink-0 self-center translate-y-[1px]">
{#if open}
<ChevronUp strokeWidth="3.5" className="size-3" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3" />
{/if}
</div>
{/if}
</div>
</div>
+1
View File
@@ -327,6 +327,7 @@ type Config = {
enable_admin_chat_access: boolean;
enable_admin_analytics: boolean;
enable_context_compaction?: boolean;
enable_tool_permissions?: boolean;
enable_community_sharing: boolean;
enable_memories: boolean;
enable_plugins?: boolean;