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
@@ -24,11 +24,15 @@ 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 { permissionsStore } from '$lib/stores/permissions.svelte';
import { ToolSource, ToolPermissionDecision } from '$lib/enums';
import { SvelteMap } from 'svelte/reactivity';
import { ToolsService } from '$lib/services/tools.service';
import { isAbortError } from '$lib/utils';
import {
DEFAULT_AGENTIC_CONFIG,
NEWLINE_SEPARATOR,
TURN_LIMIT_MESSAGE,
LLM_ERROR_BLOCK_START,
LLM_ERROR_BLOCK_END
} from '$lib/constants';
@@ -58,7 +62,8 @@ import type {
AgenticMessage,
AgenticToolCallList,
AgenticFlowCallbacks,
AgenticFlowOptions
AgenticFlowOptions,
SteeringMessage
} from '$lib/types/agentic';
import type {
ApiChatCompletionToolCall,
@@ -84,7 +89,8 @@ function createDefaultSession(): AgenticSession {
currentTurn: 0,
totalToolCalls: 0,
lastError: null,
streamingToolCall: null
streamingToolCall: null,
pendingPermissionRequest: null
};
}
@@ -98,6 +104,7 @@ function toAgenticMessages(messages: ApiChatMessageData[]): AgenticMessage[] {
return {
role: MessageRole.ASSISTANT,
content: message.content,
reasoning_content: message.reasoning_content,
tool_calls: message.tool_calls.map((call, index) => ({
id: call.id ?? `call_${index}`,
type: (call.type as ToolCallType.FUNCTION) ?? ToolCallType.FUNCTION,
@@ -105,6 +112,13 @@ function toAgenticMessages(messages: ApiChatMessageData[]): AgenticMessage[] {
}))
} satisfies AgenticMessage;
}
if (message.role === MessageRole.ASSISTANT) {
return {
role: MessageRole.ASSISTANT,
content: message.content,
reasoning_content: message.reasoning_content
} satisfies AgenticMessage;
}
if (message.role === MessageRole.TOOL && message.tool_call_id) {
return {
role: MessageRole.TOOL,
@@ -120,7 +134,22 @@ function toAgenticMessages(messages: ApiChatMessageData[]): AgenticMessage[] {
}
class AgenticStore {
private _sessions = $state<Map<string, AgenticSession>>(new Map());
private _sessions = new SvelteMap<string, AgenticSession>();
/** Dedicated reactive state for pending permission requests (ensures immediate UI updates) */
private _pendingPermissions = new SvelteMap<
string,
{ toolName: string; serverLabel: string } | null
>();
/** Non-reactive: stores resolve functions for pending permission Promises */
private _permissionResolvers = new Map<string, (decision: ToolPermissionDecision) => void>();
/** Dedicated reactive state for pending continue requests (turn limit reached) */
private _pendingContinueRequests = new SvelteMap<string, boolean>();
/** Non-reactive: stores resolve functions for pending continue Promises */
private _continueResolvers = new Map<string, (shouldContinue: boolean) => void>();
/** Reactive: queued steering messages to inject between turns */
private _steeringMessages = new SvelteMap<string, SteeringMessage>();
get isReady(): boolean {
return true;
@@ -178,36 +207,214 @@ class AgenticStore {
return this.getSession(conversationId).streamingToolCall;
}
pendingPermissionRequest(
conversationId: string
): { toolName: string; serverLabel: string } | null {
return this._pendingPermissions.get(conversationId) ?? null;
}
pendingContinueRequest(conversationId: string): boolean {
return this._pendingContinueRequests.get(conversationId) ?? false;
}
resolveContinue(conversationId: string, shouldContinue: boolean): void {
const resolver = this._continueResolvers.get(conversationId);
if (resolver) {
this._continueResolvers.delete(conversationId);
resolver(shouldContinue);
}
}
resolvePermission(conversationId: string, decision: ToolPermissionDecision): void {
const resolver = this._permissionResolvers.get(conversationId);
if (resolver) {
this._permissionResolvers.delete(conversationId);
resolver(decision);
}
}
clearError(conversationId: string): void {
this.updateSession(conversationId, { lastError: null });
}
hasPendingSteeringMessage(conversationId: string): boolean {
return this._steeringMessages.has(conversationId);
}
pendingSteeringMessageContent(conversationId: string): string | null {
return this._steeringMessages.get(conversationId)?.content ?? null;
}
pendingSteeringMessageExtras(conversationId: string): DatabaseMessageExtra[] | undefined {
return this._steeringMessages.get(conversationId)?.extras;
}
/**
* Queue a steering message. When the current agentic turn completes,
* the flow exits and the caller re-sends the message as a normal chat message.
*/
injectSteeringMessage(
conversationId: string,
content: string,
extras?: DatabaseMessageExtra[]
): void {
this._steeringMessages.set(conversationId, { content, extras });
}
/**
* Clear the pending steering message without consuming it.
*/
clearSteeringMessage(conversationId: string): void {
this._steeringMessages.delete(conversationId);
}
/**
* Consume and return the pending steering message for re-sending.
* Called by chatStore after the agentic flow exits.
*/
consumePendingSteeringMessage(conversationId: string): SteeringMessage | null {
const msg = this._steeringMessages.get(conversationId);
if (!msg) return null;
this._steeringMessages.delete(conversationId);
return msg;
}
getConfig(settings: SettingsConfigType, perChatOverrides?: McpServerOverride[]): AgenticConfig {
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>;
}
private async requestPermission(
conversationId: string,
toolName: string,
serverLabel: string,
signal?: AbortSignal
): Promise<ToolPermissionDecision> {
const permissionKey = toolsStore.getPermissionKey(toolName);
if (permissionKey && permissionsStore.hasTool(permissionKey)) {
return ToolPermissionDecision.ONCE;
}
this._pendingPermissions.set(conversationId, { toolName, serverLabel });
return new Promise<ToolPermissionDecision>((resolve) => {
if (signal?.aborted) {
this._pendingPermissions.set(conversationId, null);
resolve(ToolPermissionDecision.DENY);
return;
}
this._permissionResolvers.set(conversationId, (decision) => {
this._pendingPermissions.set(conversationId, null);
if (decision === ToolPermissionDecision.ALWAYS && permissionKey) {
permissionsStore.allowTool(permissionKey);
} else if (decision === ToolPermissionDecision.ALWAYS_SERVER) {
const serverToolKeys = toolsStore.allTools
.filter((t) =>
t.serverName
? t.serverName === serverLabel
: toolsStore.getToolServerLabel(t.definition.function.name) === serverLabel
)
.map((t) => toolsStore.getPermissionKey(t.definition.function.name)!)
.filter((k): k is string => k !== null);
permissionsStore.allowTools(serverToolKeys);
}
resolve(decision);
});
signal?.addEventListener(
'abort',
() => {
const resolver = this._permissionResolvers.get(conversationId);
if (resolver) {
this._permissionResolvers.delete(conversationId);
this._pendingPermissions.set(conversationId, null);
resolve(ToolPermissionDecision.DENY);
}
},
{ once: true }
);
});
}
private async requestContinue(conversationId: string, signal?: AbortSignal): Promise<boolean> {
this._pendingContinueRequests.set(conversationId, true);
return new Promise<boolean>((resolve) => {
if (signal?.aborted) {
this._pendingContinueRequests.set(conversationId, false);
resolve(false);
return;
}
this._continueResolvers.set(conversationId, (shouldContinue) => {
this._pendingContinueRequests.set(conversationId, false);
resolve(shouldContinue);
});
signal?.addEventListener(
'abort',
() => {
const resolver = this._continueResolvers.get(conversationId);
if (resolver) {
this._continueResolvers.delete(conversationId);
this._pendingContinueRequests.set(conversationId, false);
resolve(false);
}
},
{ once: true }
);
});
}
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
const { conversationId, messages, options = {}, callbacks, signal, perChatOverrides } = params;
// Clear any pending permissions/continue requests for this conversation when starting a new flow
this._pendingPermissions.set(conversationId, null);
this._permissionResolvers.delete(conversationId);
this._pendingContinueRequests.set(conversationId, false);
this._continueResolvers.delete(conversationId);
this._steeringMessages.delete(conversationId);
// Ensure built-in tools are fetched before checking if agentic is enabled
if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) {
await toolsStore.fetchBuiltinTools();
}
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();
const tools = toolsStore.getEnabledToolsForLLM();
if (tools.length === 0) {
console.log('[AgenticStore] No tools available, falling back to standard chat');
return { handled: false };
}
@@ -235,7 +442,8 @@ class AgenticStore {
totalToolCalls: 0,
lastError: null
});
mcpStore.acquireConnection();
if (hasMcpServers) mcpStore.acquireConnection();
try {
await this.executeAgenticLoop({
@@ -255,11 +463,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)
);
}
}
}
@@ -303,7 +514,24 @@ class AgenticStore {
const effectiveModel = options.model || modelsStore.models[0]?.model || '';
for (let turn = 0; turn < maxTurns; turn++) {
let turn = 0;
while (true) {
if (turn >= maxTurns) {
// Turn limit reached - ask user whether to continue
const shouldContinue = await this.requestContinue(conversationId, signal);
// Yield to allow Svelte to flush the UI update
await new Promise((r) => setTimeout(r, 0));
if (!shouldContinue || signal?.aborted) {
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
return;
}
// User chose to continue - extend the limit
turn = 0;
}
this.updateSession(conversationId, { currentTurn: turn + 1 });
agenticTimings.turns = turn + 1;
@@ -426,6 +654,20 @@ class AgenticStore {
throw normalizedError;
}
// === Steering check: if a user message was queued during this turn, exit the flow.
// The caller (chatStore) will consume the pending message and re-send it normally.
if (this._steeringMessages.has(conversationId)) {
console.log('[AgenticStore] Steering message detected after turn, exiting agentic flow');
await onAssistantTurnComplete?.(
turnContent,
turnReasoningContent || undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
turnToolCalls.length > 0 ? this.normalizeToolCalls(turnToolCalls) : undefined
);
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
return;
}
// No tool calls = final turn, save and complete
if (turnToolCalls.length === 0) {
agenticTimings.perTurn!.push(turnStats);
@@ -479,31 +721,88 @@ class AgenticStore {
});
// Execute each tool call and create result messages
for (const toolCall of normalizedCalls) {
for (let i = 0; i < normalizedCalls.length; i++) {
const toolCall = normalizedCalls[i];
if (signal?.aborted) {
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
return;
}
// Check for pending steering message - skip remaining tool calls
if (this._steeringMessages.has(conversationId)) {
console.log(
`[AgenticStore] Steering message detected, skipping ${normalizedCalls.length - i} remaining tool call(s)`
);
for (let j = i; j < normalizedCalls.length; j++) {
const remainingCall = normalizedCalls[j];
const interruptedContent = 'Tool execution was interrupted by a new user message.';
if (createToolResultMessage) {
await createToolResultMessage(remainingCall.id, interruptedContent);
}
sessionMessages.push({
role: MessageRole.TOOL,
tool_call_id: remainingCall.id,
content: interruptedContent
});
}
break;
}
const toolName = toolCall.function.name;
const serverLabel = toolsStore.getToolServerLabel(toolName);
// Ask for permission before executing the tool
const permission = await this.requestPermission(
conversationId,
toolName,
serverLabel,
signal
);
// Yield to allow Svelte to flush the UI update (hide permission dialog)
await new Promise((r) => setTimeout(r, 0));
if (signal?.aborted) {
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
return;
}
const toolStartTime = performance.now();
const mcpCall: MCPToolCall = {
id: toolCall.id,
function: { name: toolCall.function.name, arguments: toolCall.function.arguments }
};
const toolSource = toolsStore.getToolSource(toolName);
let result: string;
let toolSuccess = true;
try {
const executionResult = await mcpStore.executeTool(mcpCall, signal);
result = executionResult.content;
} catch (error) {
if (isAbortError(error)) {
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
return;
}
result = `Error: ${error instanceof Error ? error.message : String(error)}`;
if (permission === ToolPermissionDecision.DENY) {
result = 'Tool execution was denied by the user.';
toolSuccess = false;
} else {
try {
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));
return;
}
result = `Error: ${error instanceof Error ? error.message : String(error)}`;
toolSuccess = false;
}
}
const toolDurationMs = performance.now() - toolStartTime;
@@ -572,17 +871,18 @@ class AgenticStore {
const intermediateTimings = this.buildFinalTimings(capturedTimings, agenticTimings);
if (intermediateTimings) onTurnComplete?.(intermediateTimings);
}
}
// Turn limit reached
onChunk?.(TURN_LIMIT_MESSAGE);
await onAssistantTurnComplete?.(
TURN_LIMIT_MESSAGE,
undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
// If tools were interrupted by a steering message, exit now instead of starting another LLM turn
if (this._steeringMessages.has(conversationId)) {
console.log(
'[AgenticStore] Steering message detected after tool execution, exiting agentic flow'
);
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
return;
}
turn++;
}
}
private buildFinalTimings(
@@ -680,6 +980,46 @@ export function agenticStreamingToolCall(conversationId: string) {
return agenticStore.streamingToolCall(conversationId);
}
export function agenticPendingPermissionRequest(conversationId: string) {
return agenticStore.pendingPermissionRequest(conversationId);
}
export function agenticResolvePermission(conversationId: string, decision: ToolPermissionDecision) {
agenticStore.resolvePermission(conversationId, decision);
}
export function agenticPendingContinueRequest(conversationId: string) {
return agenticStore.pendingContinueRequest(conversationId);
}
export function agenticResolveContinue(conversationId: string, shouldContinue: boolean) {
agenticStore.resolveContinue(conversationId, shouldContinue);
}
export function agenticHasPendingSteeringMessage(conversationId: string) {
return agenticStore.hasPendingSteeringMessage(conversationId);
}
export function agenticInjectSteeringMessage(
conversationId: string,
content: string,
extras?: DatabaseMessageExtra[]
) {
agenticStore.injectSteeringMessage(conversationId, content, extras);
}
export function agenticPendingSteeringMessageContent(conversationId: string) {
return agenticStore.pendingSteeringMessageContent(conversationId);
}
export function agenticPendingSteeringMessageExtras(conversationId: string) {
return agenticStore.pendingSteeringMessageExtras(conversationId);
}
export function agenticClearSteeringMessage(conversationId: string) {
agenticStore.clearSteeringMessage(conversationId);
}
export function agenticIsAnyRunning() {
return agenticStore.isAnyRunning;
}
@@ -73,6 +73,12 @@ class ChatStore {
private _pendingDraftMessage = $state<string>('');
private _pendingDraftFiles = $state<ChatUploadedFile[]>([]);
/** Reactive: queued pending messages for non-agentic streaming */
private _pendingMessages = new SvelteMap<
string,
{ content: string; extras?: DatabaseMessageExtra[] }
>();
private setChatLoading(convId: string, loading: boolean): void {
this.touchConversationState(convId);
if (loading) {
@@ -176,6 +182,19 @@ class ChatStore {
}
}
/**
* Abort the current agentic flow signal without clearing loading state.
* Used by "Send immediately" to force the agentic loop to exit so that
* the pending steering message can be re-sent.
*/
abortCurrentFlow(convId: string): void {
const c = this.abortControllers.get(convId);
if (c) {
c.abort();
this.abortControllers.delete(convId);
}
}
private showErrorDialog(state: ErrorDialogState | null): void {
this.errorDialogState = state;
}
@@ -243,6 +262,35 @@ class ChatStore {
return this.chatStreamingStates.has(convId);
}
hasPendingMessage(convId: string): boolean {
return this._pendingMessages.has(convId);
}
pendingMessageContent(convId: string): string | null {
return this._pendingMessages.get(convId)?.content ?? null;
}
pendingMessageExtras(convId: string): DatabaseMessageExtra[] | undefined {
return this._pendingMessages.get(convId)?.extras;
}
injectPendingMessage(convId: string, content: string, extras?: DatabaseMessageExtra[]): void {
this._pendingMessages.set(convId, { content, extras });
}
clearPendingMessage(convId: string): void {
this._pendingMessages.delete(convId);
}
consumePendingMessage(
convId: string
): { content: string; extras?: DatabaseMessageExtra[] } | null {
const msg = this._pendingMessages.get(convId);
if (!msg) return null;
this._pendingMessages.delete(convId);
return msg;
}
private touchConversationState(convId: string): void {
this.conversationStateTimestamps.set(convId, { lastAccessed: Date.now() });
}
@@ -462,7 +510,18 @@ class ChatStore {
async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise<void> {
if (!content.trim() && (!extras || extras.length === 0)) return;
const activeConv = conversationsStore.activeConversation;
if (activeConv && this.isChatLoadingInternal(activeConv.id)) return;
// If agentic loop is running, inject as a steering message instead of starting a new flow
if (activeConv && agenticStore.isRunning(activeConv.id)) {
agenticStore.injectSteeringMessage(activeConv.id, content, extras);
return;
}
// If non-agentic streaming is active, queue as a pending message to send after completion
if (activeConv && this.isChatLoadingInternal(activeConv.id)) {
this.injectPendingMessage(activeConv.id, content, extras);
return;
}
// Cancel any in-flight pre-encode request
this.cancelPreEncode();
@@ -747,10 +806,16 @@ class ChatStore {
this.setStreamingActive(false);
if (isAbortError(error)) {
cleanupStreamingState();
// If aborted with a pending message (e.g. "Send immediately"), re-send it
const pending = this.consumePendingMessage(convId);
if (pending) {
this.sendMessage(pending.content, pending.extras);
}
return;
}
console.error('Streaming error:', error);
cleanupStreamingState();
this.clearPendingMessage(convId);
const idx = conversationsStore.findMessageIndex(assistantMessage.id);
if (idx !== -1) {
const failedMessage = conversationsStore.removeMessageAtIndex(idx);
@@ -770,8 +835,7 @@ class ChatStore {
const perChatOverrides = conversationsStore.activeConversation?.mcpServerOverrides;
const agenticConfig = agenticStore.getConfig(config(), perChatOverrides);
if (agenticConfig.enabled) {
{
const agenticResult = await agenticStore.runAgenticFlow({
conversationId: convId,
messages: allMessages,
@@ -780,10 +844,16 @@ class ChatStore {
signal: abortController.signal,
perChatOverrides
});
if (agenticResult.handled) return;
if (agenticResult.handled) {
// Check if there's a pending steering message to re-send
const pending = agenticStore.consumePendingSteeringMessage(convId);
if (pending) {
await this.sendMessage(pending.content, pending.extras);
}
return;
}
}
// Non-agentic path: direct streaming into the single assistant message
await ChatService.sendMessage(
allMessages,
{
@@ -823,6 +893,12 @@ class ChatStore {
cleanupStreamingState();
if (onComplete) await onComplete(content);
if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error);
// Check if there's a pending message queued during streaming
const pending = this.consumePendingMessage(convId);
if (pending) {
await this.sendMessage(pending.content, pending.extras);
}
},
onError: streamCallbacks.onError
},
@@ -843,6 +919,7 @@ class ChatStore {
this.setChatLoading(convId, false);
this.clearChatStreaming(convId);
this.setProcessingState(convId, null);
this.clearPendingMessage(convId);
}
private async savePartialResponseIfNeeded(convId?: string): Promise<void> {
const conversationId = convId || conversationsStore.activeConversation?.id;
@@ -1688,3 +1765,13 @@ export const isChatStreaming = () => chatStore.isStreaming();
export const isEditing = () => chatStore.isEditing();
export const isLoading = () => chatStore.isLoading;
export const pendingEditMessageId = () => chatStore.pendingEditMessageId;
export const chatHasPendingMessage = (convId: string) => chatStore.hasPendingMessage(convId);
export const chatPendingMessageContent = (convId: string) =>
chatStore.pendingMessageContent(convId);
export const chatPendingMessageExtras = (convId: string) => chatStore.pendingMessageExtras(convId);
export const chatClearPendingMessage = (convId: string) => chatStore.clearPendingMessage(convId);
export const chatInjectPendingMessage = (
convId: string,
content: string,
extras?: DatabaseMessageExtra[]
) => chatStore.injectPendingMessage(convId, content, extras);
@@ -30,7 +30,7 @@ import {
generateConversationTitle
} from '$lib/utils';
import type { McpServerOverride } from '$lib/types/database';
import { MessageRole } from '$lib/enums';
import { MessageRole, HtmlInputType, FileExtensionText } from '$lib/enums';
import {
ISO_DATE_TIME_SEPARATOR,
ISO_DATE_TIME_SEPARATOR_REPLACEMENT,
@@ -797,7 +797,15 @@ class ConversationsStore {
return;
}
const downloadFilename = filename ?? this.generateConversationFilename(conversation, msgs);
let downloadFilename: string;
if (filename) {
downloadFilename = filename;
} else if (Array.isArray(data) && data.length > 1) {
downloadFilename = `${new Date().toISOString().split(ISO_DATE_TIME_SEPARATOR)[0]}_conversations.json`;
} else {
downloadFilename = this.generateConversationFilename(conversation, msgs);
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
@@ -838,8 +846,8 @@ class ConversationsStore {
async importConversations(): Promise<DatabaseConversation[]> {
return new Promise((resolve, reject) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.type = HtmlInputType.FILE;
input.accept = FileExtensionText.JSON;
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement)?.files?.[0];
@@ -0,0 +1,31 @@
import { NEW_CHAT_DRAFT_KEY } from '$lib/constants';
interface DraftMessage {
message: string;
files: ChatUploadedFile[];
}
class DraftMessagesStore {
private drafts = new Map<string, DraftMessage>();
getDraftMessage(chatId: string | undefined): DraftMessage {
const key = chatId ?? NEW_CHAT_DRAFT_KEY;
return this.drafts.get(key) ?? { message: '', files: [] };
}
saveDraftMessage(chatId: string | undefined, message: string, files: ChatUploadedFile[]): void {
const key = chatId ?? NEW_CHAT_DRAFT_KEY;
if (message || files.length > 0) {
this.drafts.set(key, { message, files: [...files] });
} else {
this.drafts.delete(key);
}
}
clearDraftMessage(chatId: string | undefined): void {
const key = chatId ?? NEW_CHAT_DRAFT_KEY;
this.drafts.delete(key);
}
}
export const draftMessagesStore = new DraftMessagesStore();
@@ -0,0 +1,58 @@
import { ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY } from '$lib/constants';
import { SvelteSet } from 'svelte/reactivity';
class PermissionsStore {
private _tools = $state(new SvelteSet<string>());
constructor() {
try {
const stored = localStorage.getItem(ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY);
if (stored) {
for (const name of JSON.parse(stored) as string[]) {
if (typeof name === 'string') this._tools.add(name);
}
}
} catch (err) {
console.error(
`Failed to load permissions from localStorage ("${ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY}"):`,
err
);
}
}
get tools(): ReadonlySet<string> {
return this._tools;
}
hasTool(key: string): boolean {
return this._tools.has(key);
}
allowTool(key: string): void {
this._tools.add(key);
this._persist();
}
allowTools(keys: string[]): void {
for (const key of keys) this._tools.add(key);
this._persist();
}
revokeTool(key: string): void {
this._tools.delete(key);
this._persist();
}
private _persist(): void {
try {
localStorage.setItem(ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY, JSON.stringify([...this._tools]));
} catch (err) {
console.error(
`Failed to persist to localStorage ("${ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY}"):`,
err
);
}
}
}
export const permissionsStore = new PermissionsStore();
@@ -0,0 +1,10 @@
let _url = $state('#/');
export const settingsReferrer = {
get url() {
return _url;
},
set url(value: string) {
_url = value;
}
};
@@ -327,6 +327,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);
@@ -336,12 +339,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,422 @@
import type { OpenAIToolDefinition, ToolEntry, ToolGroup } from '$lib/types';
import { ToolsService } from '$lib/services/tools.service';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { HealthCheckStatus, JsonSchemaType, ToolCallType, ToolSource } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
import {
DISABLED_TOOLS_LOCALSTORAGE_KEY,
TOOL_GROUP_LABELS,
TOOL_SERVER_LABELS
} from '$lib/constants';
import { SvelteSet } from 'svelte/reactivity';
class ToolsStore {
private _builtinTools = $state<OpenAIToolDefinition[]>([]);
private _loading = $state(false);
private _error = $state<string | null>(null);
private _disabledTools = $state(new SvelteSet<string>());
private _toolsEndpointUnreachable = $state(false);
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 (err) {
console.error('[ToolsStore] Failed to load disabled tools from localStorage:', err);
}
// Initialize builtin tools on startup
this.fetchBuiltinTools();
}
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: JsonSchemaType.OBJECT,
properties: {},
required: []
};
entries.push({
source: ToolSource.MCP,
serverName,
serverId,
definition: {
type: ToolCallType.FUNCTION,
function: {
name: tool.name,
description: tool.description,
parameters: rawSchema
}
}
});
}
}
} else {
for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) {
for (const tool of tools) {
entries.push({
source: ToolSource.MCP,
serverName,
serverId,
definition: {
type: ToolCallType.FUNCTION,
function: {
name: tool.name,
description: tool.description,
parameters: { type: JsonSchemaType.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: TOOL_GROUP_LABELS[ToolSource.BUILTIN],
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: JsonSchemaType.OBJECT,
properties: {},
required: []
};
return {
type: ToolCallType.FUNCTION,
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: ToolCallType.FUNCTION,
function: {
name: tool.name,
description: tool.description,
parameters: { type: JsonSchemaType.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: TOOL_GROUP_LABELS[ToolSource.CUSTOM],
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 isToolsEndpointUnreachable(): boolean {
return this._toolsEndpointUnreachable;
}
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);
}
}
/**
* Enable all tools belonging to a specific MCP server.
* Called when a server is enabled for a conversation.
*/
enableAllToolsForServer(serverId: string): void {
const connection = mcpStore.getConnections().get(serverId);
if (!connection) return;
for (const tool of connection.tools) {
this._disabledTools.delete(tool.name);
}
this.persistDisabledTools();
}
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;
}
/** Get the display label for the server that owns a given tool. */
getToolServerLabel(toolName: string): string {
for (const entry of this.allTools) {
if (entry.definition.function.name === toolName) {
if (entry.serverName) {
return mcpStore.getServerDisplayName(entry.serverName);
}
if (entry.source === ToolSource.BUILTIN) {
return TOOL_SERVER_LABELS[ToolSource.BUILTIN];
}
if (entry.source === ToolSource.CUSTOM) {
return TOOL_SERVER_LABELS[ToolSource.CUSTOM];
}
}
}
return '';
}
/** Build a permission key with category prefix, e.g. "mcp-<serverId>:tool_name" */
getPermissionKey(toolName: string): string | null {
for (const entry of this.allTools) {
if (entry.definition.function.name === toolName) {
switch (entry.source) {
case ToolSource.BUILTIN:
return `builtin:${toolName}`;
case ToolSource.CUSTOM:
return `custom:${toolName}`;
case ToolSource.MCP:
if (entry.serverId) {
return `mcp-${entry.serverId}:${toolName}`;
}
return `mcp:${toolName}`;
default:
return null;
}
}
}
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;
this._toolsEndpointUnreachable = false;
try {
const toolInfos = await ToolsService.list();
this._builtinTools = toolInfos.map((info) => info.definition);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
this._error = errorMessage;
// 404 from /tools means the server was started without --tools
if (errorMessage.includes('404') || errorMessage.toLowerCase().includes('not found')) {
this._toolsEndpointUnreachable = true;
}
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;