feat: Builtin + MCP + JSON Schema Tools WIP
This commit is contained in:
parent
684ed10a04
commit
155af69edf
Binary file not shown.
|
|
@ -1,7 +1,19 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { Plus, MessageSquare, Settings, Zap, FolderOpen } from '@lucide/svelte';
|
||||
import {
|
||||
Plus,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
Zap,
|
||||
FolderOpen,
|
||||
PencilRuler,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2
|
||||
} from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
|
|
@ -9,9 +21,10 @@
|
|||
import { McpLogo, DropdownMenuSearchable } from '$lib/components/app';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { toolsStore, ToolSource } from '$lib/stores/tools.svelte';
|
||||
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
import type { MCPServerSettingsEntry } from '$lib/types';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
|
|
@ -51,21 +64,67 @@
|
|||
|
||||
let dropdownOpen = $state(false);
|
||||
|
||||
let expandedGroups = new SvelteSet<string>();
|
||||
let groups = $derived(
|
||||
toolsStore.toolGroups.filter(
|
||||
(g) =>
|
||||
g.source !== ToolSource.MCP ||
|
||||
!g.serverId ||
|
||||
conversationsStore.isMcpServerEnabledForChat(g.serverId)
|
||||
)
|
||||
);
|
||||
let totalToolCount = $derived(groups.reduce((n, g) => n + g.tools.length, 0));
|
||||
let enabledToolCount = $derived(
|
||||
groups.reduce(
|
||||
(n, g) => n + g.tools.filter((t) => toolsStore.isToolEnabled(t.function.name)).length,
|
||||
0
|
||||
)
|
||||
);
|
||||
let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
|
||||
let hasMcpServers = $derived(mcpServers.length > 0);
|
||||
let mcpSearchQuery = $state('');
|
||||
let filteredMcpServers = $derived.by(() => {
|
||||
const query = mcpSearchQuery.toLowerCase().trim();
|
||||
if (!query) return mcpServers;
|
||||
|
||||
return mcpServers.filter((s) => {
|
||||
const name = getServerLabel(s).toLowerCase();
|
||||
const name = mcpStore.getServerLabel(s).toLowerCase();
|
||||
const url = s.url.toLowerCase();
|
||||
return name.includes(query) || url.includes(query);
|
||||
});
|
||||
});
|
||||
|
||||
function getServerLabel(server: MCPServerSettingsEntry): string {
|
||||
return mcpStore.getServerLabel(server);
|
||||
const fileUploadTooltipText = 'Add files, system prompt or MCP Servers';
|
||||
|
||||
function getGroupCheckedState(group: (typeof groups)[number]): {
|
||||
checked: boolean;
|
||||
indeterminate: boolean;
|
||||
} {
|
||||
return {
|
||||
checked: toolsStore.isGroupFullyEnabled(group),
|
||||
indeterminate: toolsStore.isGroupPartiallyEnabled(group)
|
||||
};
|
||||
}
|
||||
|
||||
function getFavicon(group: { source: ToolSource; label: string }): string | null {
|
||||
if (group.source !== ToolSource.MCP) return null;
|
||||
|
||||
for (const server of mcpStore.getServersSorted()) {
|
||||
if (mcpStore.getServerLabel(server) === group.label) {
|
||||
return mcpStore.getServerFavicon(server.id);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleToolsSubMenuOpen(open: boolean) {
|
||||
if (open) {
|
||||
if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) {
|
||||
toolsStore.fetchBuiltinTools();
|
||||
}
|
||||
mcpStore.runHealthChecksForServers(mcpStore.getServersSorted().filter((s) => s.enabled));
|
||||
}
|
||||
}
|
||||
|
||||
function isServerEnabledForChat(serverId: string): boolean {
|
||||
|
|
@ -97,8 +156,6 @@
|
|||
dropdownOpen = false;
|
||||
onMcpResourcesClick?.();
|
||||
}
|
||||
|
||||
const fileUploadTooltipText = 'Add files, system prompt or MCP Servers';
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1 {className}">
|
||||
|
|
@ -234,6 +291,112 @@
|
|||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Sub onOpenChange={handleToolsSubMenuOpen}>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
<PencilRuler class="h-4 w-4" />
|
||||
|
||||
<span>Tools</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent class="w-72 p-0">
|
||||
{#if totalToolCount === 0}
|
||||
<div class="px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
{#if toolsStore.loading}
|
||||
<Loader2 class="mx-auto mb-1 h-4 w-4 animate-spin" />
|
||||
Loading tools...
|
||||
{:else if toolsStore.error}
|
||||
Failed to load tools
|
||||
{:else}
|
||||
No tools available
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
{enabledToolCount}/{totalToolCount} tools enabled
|
||||
</div>
|
||||
|
||||
<div class="max-h-80 overflow-y-auto px-1 pb-2">
|
||||
{#each groups as group (group.label)}
|
||||
{@const isExpanded = expandedGroups.has(group.label)}
|
||||
{@const { checked, indeterminate } = getGroupCheckedState(group)}
|
||||
{@const favicon = getFavicon(group)}
|
||||
|
||||
<Collapsible.Root
|
||||
open={isExpanded}
|
||||
onOpenChange={() => {
|
||||
if (expandedGroups.has(group.label)) {
|
||||
expandedGroups.delete(group.label);
|
||||
} else {
|
||||
expandedGroups.add(group.label);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<Collapsible.Trigger
|
||||
class="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDown class="h-3.5 w-3.5 shrink-0" />
|
||||
{:else}
|
||||
<ChevronRight class="h-3.5 w-3.5 shrink-0" />
|
||||
{/if}
|
||||
|
||||
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
|
||||
{#if favicon}
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
class="h-4 w-4 shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<span class="truncate">{group.label}</span>
|
||||
</span>
|
||||
|
||||
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
{group.tools.length}
|
||||
</span>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Checkbox
|
||||
{checked}
|
||||
{indeterminate}
|
||||
onCheckedChange={() => toolsStore.toggleGroup(group)}
|
||||
class="mr-2 h-4 w-4 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Collapsible.Content>
|
||||
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
|
||||
{#each group.tools as tool (tool.function.name)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors hover:bg-muted/50"
|
||||
onclick={() => toolsStore.toggleTool(tool.function.name)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={toolsStore.isToolEnabled(tool.function.name)}
|
||||
onCheckedChange={() => toolsStore.toggleTool(tool.function.name)}
|
||||
class="h-4 w-4 shrink-0"
|
||||
/>
|
||||
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{tool.function.name}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
<DropdownMenu.Sub onOpenChange={handleMcpSubMenuOpen}>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
<McpLogo class="h-4 w-4" />
|
||||
|
|
@ -272,7 +435,7 @@
|
|||
/>
|
||||
{/if}
|
||||
|
||||
<span class="truncate text-sm">{getServerLabel(server)}</span>
|
||||
<span class="truncate text-sm">{mcpStore.getServerLabel(server)}</span>
|
||||
|
||||
{#if hasError}
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ export const API_MODELS = {
|
|||
};
|
||||
|
||||
export const API_TOOLS = {
|
||||
LIST: '/tools'
|
||||
LIST: '/tools',
|
||||
EXECUTE: '/tools'
|
||||
};
|
||||
|
||||
/** CORS proxy endpoint path */
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ export const CONFIG_LOCALSTORAGE_KEY = 'LlamaCppWebui.config';
|
|||
export const USER_OVERRIDES_LOCALSTORAGE_KEY = 'LlamaCppWebui.userOverrides';
|
||||
export const FAVOURITE_MODELS_LOCALSTORAGE_KEY = 'LlamaCppWebui.favouriteModels';
|
||||
export const MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY = 'LlamaCppWebui.mcpDefaultEnabled';
|
||||
export const DISABLED_TOOLS_LOCALSTORAGE_KEY = 'LlamaCppWebui.disabledTools';
|
||||
|
|
|
|||
|
|
@ -424,9 +424,10 @@ export class ChatService {
|
|||
|
||||
try {
|
||||
const parsed: ApiChatCompletionStreamChunk = JSON.parse(data);
|
||||
const content = parsed.choices[0]?.delta?.content;
|
||||
const reasoningContent = parsed.choices[0]?.delta?.reasoning_content;
|
||||
const toolCalls = parsed.choices[0]?.delta?.tool_calls;
|
||||
const choice = parsed.choices?.[0];
|
||||
const content = choice?.delta?.content;
|
||||
const reasoningContent = choice?.delta?.reasoning_content;
|
||||
const toolCalls = choice?.delta?.tool_calls;
|
||||
const timings = parsed.timings;
|
||||
const promptProgress = parsed.prompt_progress;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { apiFetch } from '$lib/utils';
|
||||
import { API_TOOLS } from '$lib/constants';
|
||||
import type { OpenAIToolDefinition } from '$lib/types';
|
||||
import type { OpenAIToolDefinition, ToolExecutionResult } from '$lib/types';
|
||||
|
||||
export class ToolsService {
|
||||
/**
|
||||
|
|
@ -11,4 +11,22 @@ export class ToolsService {
|
|||
static async list(): Promise<OpenAIToolDefinition[]> {
|
||||
return apiFetch<OpenAIToolDefinition[]>(API_TOOLS.LIST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a built-in tool on the server.
|
||||
*/
|
||||
static async executeTool(
|
||||
toolName: string,
|
||||
params: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
): Promise<ToolExecutionResult> {
|
||||
const result = await apiFetch<Record<string, unknown>>(API_TOOLS.EXECUTE, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tool: toolName, params }),
|
||||
signal
|
||||
});
|
||||
const isError = 'error' in result;
|
||||
const content = isError ? String(result.error) : JSON.stringify(result);
|
||||
return { content, isError };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ 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, ToolSource } from '$lib/stores/tools.svelte';
|
||||
import { ToolsService } from '$lib/services/tools.service';
|
||||
import { isAbortError } from '$lib/utils';
|
||||
import {
|
||||
DEFAULT_AGENTIC_CONFIG,
|
||||
|
|
@ -184,13 +186,24 @@ class AgenticStore {
|
|||
const maxTurns = Number(settings.agenticMaxTurns) || DEFAULT_AGENTIC_CONFIG.maxTurns;
|
||||
const maxToolPreviewLines =
|
||||
Number(settings.agenticMaxToolPreviewLines) || DEFAULT_AGENTIC_CONFIG.maxToolPreviewLines;
|
||||
const hasTools =
|
||||
mcpStore.hasEnabledServers(perChatOverrides) ||
|
||||
toolsStore.builtinTools.length > 0 ||
|
||||
toolsStore.customTools.length > 0;
|
||||
return {
|
||||
enabled: mcpStore.hasEnabledServers(perChatOverrides) && DEFAULT_AGENTIC_CONFIG.enabled,
|
||||
enabled: hasTools && DEFAULT_AGENTIC_CONFIG.enabled,
|
||||
maxTurns,
|
||||
maxToolPreviewLines
|
||||
};
|
||||
}
|
||||
|
||||
private parseToolArguments(args: string | Record<string, unknown>): Record<string, unknown> {
|
||||
if (typeof args === 'object') return args;
|
||||
const trimmed = args.trim();
|
||||
if (trimmed === '') return {};
|
||||
return JSON.parse(trimmed) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
|
||||
const { conversationId, messages, options = {}, callbacks, signal, perChatOverrides } = params;
|
||||
const {
|
||||
|
|
@ -208,15 +221,24 @@ class AgenticStore {
|
|||
const agenticConfig = this.getConfig(config(), perChatOverrides);
|
||||
if (!agenticConfig.enabled) return { handled: false };
|
||||
|
||||
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
|
||||
if (!initialized) {
|
||||
console.log('[AgenticStore] MCP not initialized, falling back to standard chat');
|
||||
return { handled: false };
|
||||
const hasMcpServers = mcpStore.hasEnabledServers(perChatOverrides);
|
||||
if (hasMcpServers) {
|
||||
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
|
||||
|
||||
if (!initialized) {
|
||||
console.log('[AgenticStore] MCP not initialized');
|
||||
}
|
||||
}
|
||||
|
||||
const tools = mcpStore.getToolDefinitionsForLLM();
|
||||
// Ensure built-in tools are fetched
|
||||
if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) {
|
||||
await toolsStore.fetchBuiltinTools();
|
||||
}
|
||||
|
||||
const tools = toolsStore.getEnabledToolsForLLM();
|
||||
if (tools.length === 0) {
|
||||
console.log('[AgenticStore] No tools available, falling back to standard chat');
|
||||
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
|
|
@ -244,7 +266,8 @@ class AgenticStore {
|
|||
totalToolCalls: 0,
|
||||
lastError: null
|
||||
});
|
||||
mcpStore.acquireConnection();
|
||||
|
||||
if (hasMcpServers) mcpStore.acquireConnection();
|
||||
|
||||
try {
|
||||
await this.executeAgenticLoop({
|
||||
|
|
@ -274,11 +297,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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -517,17 +543,29 @@ class AgenticStore {
|
|||
}
|
||||
|
||||
const toolStartTime = performance.now();
|
||||
const mcpCall: MCPToolCall = {
|
||||
id: toolCall.id,
|
||||
function: { name: toolCall.function.name, arguments: toolCall.function.arguments }
|
||||
};
|
||||
const toolName = toolCall.function.name;
|
||||
const toolSource = toolsStore.getToolSource(toolName);
|
||||
|
||||
let result: string;
|
||||
let toolSuccess = true;
|
||||
|
||||
try {
|
||||
const executionResult = await mcpStore.executeTool(mcpCall, signal);
|
||||
result = executionResult.content;
|
||||
if (toolSource === ToolSource.BUILTIN) {
|
||||
const args = this.parseToolArguments(toolCall.function.arguments);
|
||||
const executionResult = await ToolsService.executeTool(toolName, args, signal);
|
||||
|
||||
result = executionResult.content;
|
||||
|
||||
if (executionResult.isError) toolSuccess = false;
|
||||
} else {
|
||||
const mcpCall: MCPToolCall = {
|
||||
id: toolCall.id,
|
||||
function: { name: toolName, arguments: toolCall.function.arguments }
|
||||
};
|
||||
const executionResult = await mcpStore.executeTool(mcpCall, signal);
|
||||
|
||||
result = executionResult.content;
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
onComplete?.(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { conversationsStore } from '$lib/stores/conversations.svelte';
|
|||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { agenticStore } from '$lib/stores/agentic.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { contextSize, isRouterMode } from '$lib/stores/server.svelte';
|
||||
import {
|
||||
selectedModelName,
|
||||
|
|
@ -731,10 +732,12 @@ class ChatStore {
|
|||
if (agenticResult.handled) return;
|
||||
}
|
||||
|
||||
const enabledTools = toolsStore.getEnabledToolsForLLM();
|
||||
const completionOptions = {
|
||||
...this.getApiOptions(),
|
||||
...(effectiveModel ? { model: effectiveModel } : {}),
|
||||
...streamCallbacks
|
||||
...streamCallbacks,
|
||||
...(enabledTools.length > 0 ? { tools: enabledTools } : {})
|
||||
};
|
||||
|
||||
await ChatService.sendMessage(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,369 @@
|
|||
import type { OpenAIToolDefinition } from '$lib/types';
|
||||
import { ToolsService } from '$lib/services/tools.service';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { DISABLED_TOOLS_LOCALSTORAGE_KEY } from '$lib/constants';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
export enum ToolSource {
|
||||
BUILTIN = 'builtin',
|
||||
MCP = 'mcp',
|
||||
CUSTOM = 'custom'
|
||||
}
|
||||
|
||||
export interface ToolEntry {
|
||||
source: ToolSource;
|
||||
/** For MCP tools, the server ID; otherwise undefined */
|
||||
serverName?: string;
|
||||
definition: OpenAIToolDefinition;
|
||||
}
|
||||
|
||||
export interface ToolGroup {
|
||||
source: ToolSource;
|
||||
label: string;
|
||||
/** For MCP groups, the server ID */
|
||||
serverId?: string;
|
||||
tools: OpenAIToolDefinition[];
|
||||
}
|
||||
|
||||
class ToolsStore {
|
||||
private _builtinTools = $state<OpenAIToolDefinition[]>([]);
|
||||
private _loading = $state(false);
|
||||
private _error = $state<string | null>(null);
|
||||
private _disabledTools = $state(new SvelteSet<string>());
|
||||
|
||||
constructor() {
|
||||
try {
|
||||
const stored = localStorage.getItem(DISABLED_TOOLS_LOCALSTORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const name of parsed) {
|
||||
if (typeof name === 'string') this._disabledTools.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
throw new Error('Failed to load disabled tools from localStorage');
|
||||
}
|
||||
}
|
||||
|
||||
private persistDisabledTools(): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
DISABLED_TOOLS_LOCALSTORAGE_KEY,
|
||||
JSON.stringify([...this._disabledTools])
|
||||
);
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
get builtinTools(): OpenAIToolDefinition[] {
|
||||
return this._builtinTools;
|
||||
}
|
||||
|
||||
get mcpTools(): OpenAIToolDefinition[] {
|
||||
return mcpStore.getToolDefinitionsForLLM();
|
||||
}
|
||||
|
||||
get customTools(): OpenAIToolDefinition[] {
|
||||
const raw = config().custom;
|
||||
if (!raw || typeof raw !== 'string') return [];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
return parsed.filter(
|
||||
(t: unknown): t is OpenAIToolDefinition =>
|
||||
typeof t === 'object' &&
|
||||
t !== null &&
|
||||
'type' in t &&
|
||||
(t as OpenAIToolDefinition).type === 'function' &&
|
||||
'function' in t &&
|
||||
typeof (t as OpenAIToolDefinition).function?.name === 'string'
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Flat list of all tool entries with source metadata */
|
||||
get allTools(): ToolEntry[] {
|
||||
const entries: ToolEntry[] = [];
|
||||
|
||||
for (const def of this._builtinTools) {
|
||||
entries.push({ source: ToolSource.BUILTIN, definition: def });
|
||||
}
|
||||
|
||||
// Use live connections when available (full schema), fall back to health check data
|
||||
const connections = mcpStore.getConnections();
|
||||
if (connections.size > 0) {
|
||||
for (const [serverId, connection] of connections) {
|
||||
const serverName = mcpStore.getServerDisplayName(serverId);
|
||||
for (const tool of connection.tools) {
|
||||
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
};
|
||||
entries.push({
|
||||
source: ToolSource.MCP,
|
||||
serverName,
|
||||
definition: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: rawSchema
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const { serverName, tools } of this.getMcpToolsFromHealthChecks()) {
|
||||
for (const tool of tools) {
|
||||
entries.push({
|
||||
source: ToolSource.MCP,
|
||||
serverName,
|
||||
definition: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: { type: 'object', properties: {}, required: [] }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const def of this.customTools) {
|
||||
entries.push({ source: ToolSource.CUSTOM, definition: def });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Tools grouped by category for tree display */
|
||||
get toolGroups(): ToolGroup[] {
|
||||
const groups: ToolGroup[] = [];
|
||||
|
||||
if (this._builtinTools.length > 0) {
|
||||
groups.push({
|
||||
source: ToolSource.BUILTIN,
|
||||
label: 'Built-in',
|
||||
tools: this._builtinTools
|
||||
});
|
||||
}
|
||||
|
||||
// Use live connections when available, fall back to health check data
|
||||
const connections = mcpStore.getConnections();
|
||||
if (connections.size > 0) {
|
||||
for (const [serverId, connection] of connections) {
|
||||
if (connection.tools.length === 0) continue;
|
||||
const label = mcpStore.getServerDisplayName(serverId);
|
||||
const tools: OpenAIToolDefinition[] = connection.tools.map((tool) => {
|
||||
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
};
|
||||
return {
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: rawSchema
|
||||
}
|
||||
};
|
||||
});
|
||||
groups.push({ source: ToolSource.MCP, label, serverId, tools });
|
||||
}
|
||||
} else {
|
||||
for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) {
|
||||
if (tools.length === 0) continue;
|
||||
const defs: OpenAIToolDefinition[] = tools.map((tool) => ({
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: { type: 'object', properties: {}, required: [] }
|
||||
}
|
||||
}));
|
||||
groups.push({ source: ToolSource.MCP, label: serverName, serverId, tools: defs });
|
||||
}
|
||||
}
|
||||
|
||||
const custom = this.customTools;
|
||||
if (custom.length > 0) {
|
||||
groups.push({
|
||||
source: ToolSource.CUSTOM,
|
||||
label: 'JSON Schema',
|
||||
tools: custom
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Only enabled tool definitions (for sending to the API) */
|
||||
get enabledToolDefinitions(): OpenAIToolDefinition[] {
|
||||
return this.allTools
|
||||
.filter((t) => !this._disabledTools.has(t.definition.function.name))
|
||||
.map((t) => t.definition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns enabled tool definitions for sending to the LLM.
|
||||
* MCP tools use properly normalized schemas from mcpStore.
|
||||
* Filters out tools disabled via the UI checkboxes.
|
||||
*/
|
||||
getEnabledToolsForLLM(): OpenAIToolDefinition[] {
|
||||
const disabled = this._disabledTools;
|
||||
const result: OpenAIToolDefinition[] = [];
|
||||
|
||||
for (const tool of this._builtinTools) {
|
||||
if (!disabled.has(tool.function.name)) {
|
||||
result.push(tool);
|
||||
}
|
||||
}
|
||||
|
||||
// MCP tools with properly normalized schemas
|
||||
for (const tool of mcpStore.getToolDefinitionsForLLM()) {
|
||||
if (!disabled.has(tool.function.name)) {
|
||||
result.push(tool);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tool of this.customTools) {
|
||||
if (!disabled.has(tool.function.name)) {
|
||||
result.push(tool);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
get allToolDefinitions(): OpenAIToolDefinition[] {
|
||||
return this.allTools.map((t) => t.definition);
|
||||
}
|
||||
|
||||
get loading(): boolean {
|
||||
return this._loading;
|
||||
}
|
||||
|
||||
get error(): string | null {
|
||||
return this._error;
|
||||
}
|
||||
|
||||
get disabledTools(): SvelteSet<string> {
|
||||
return this._disabledTools;
|
||||
}
|
||||
|
||||
isToolEnabled(toolName: string): boolean {
|
||||
return !this._disabledTools.has(toolName);
|
||||
}
|
||||
|
||||
toggleTool(toolName: string): void {
|
||||
if (this._disabledTools.has(toolName)) {
|
||||
this._disabledTools.delete(toolName);
|
||||
} else {
|
||||
this._disabledTools.add(toolName);
|
||||
}
|
||||
this.persistDisabledTools();
|
||||
}
|
||||
|
||||
setToolEnabled(toolName: string, enabled: boolean): void {
|
||||
if (enabled) {
|
||||
this._disabledTools.delete(toolName);
|
||||
} else {
|
||||
this._disabledTools.add(toolName);
|
||||
}
|
||||
}
|
||||
|
||||
toggleGroup(group: ToolGroup): void {
|
||||
const allEnabled = group.tools.every((t) => this.isToolEnabled(t.function.name));
|
||||
for (const tool of group.tools) {
|
||||
this.setToolEnabled(tool.function.name, !allEnabled);
|
||||
}
|
||||
this.persistDisabledTools();
|
||||
}
|
||||
|
||||
isGroupFullyEnabled(group: ToolGroup): boolean {
|
||||
return group.tools.length > 0 && group.tools.every((t) => this.isToolEnabled(t.function.name));
|
||||
}
|
||||
|
||||
isGroupPartiallyEnabled(group: ToolGroup): boolean {
|
||||
const enabledCount = group.tools.filter((t) => this.isToolEnabled(t.function.name)).length;
|
||||
return enabledCount > 0 && enabledCount < group.tools.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MCP tools from health check data (reactive).
|
||||
* Used when live connections aren't established yet.
|
||||
*/
|
||||
private getMcpToolsFromHealthChecks(): {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
tools: { name: string; description?: string }[];
|
||||
}[] {
|
||||
const result: ReturnType<ToolsStore['getMcpToolsFromHealthChecks']> = [];
|
||||
for (const server of mcpStore.getServersSorted().filter((s) => s.enabled)) {
|
||||
const health = mcpStore.getHealthCheckState(server.id);
|
||||
if (health.status === HealthCheckStatus.SUCCESS && health.tools.length > 0) {
|
||||
result.push({
|
||||
serverId: server.id,
|
||||
serverName: mcpStore.getServerLabel(server),
|
||||
tools: health.tools
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Determine the source of a tool by its name. */
|
||||
getToolSource(toolName: string): ToolSource | null {
|
||||
if (this._builtinTools.some((t) => t.function.name === toolName)) {
|
||||
return ToolSource.BUILTIN;
|
||||
}
|
||||
for (const entry of this.allTools) {
|
||||
if (entry.definition.function.name === toolName) {
|
||||
return entry.source;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Check if there are any enabled tools available (builtin, MCP, or custom). */
|
||||
get hasEnabledTools(): boolean {
|
||||
return this.getEnabledToolsForLLM().length > 0;
|
||||
}
|
||||
|
||||
async fetchBuiltinTools(): Promise<void> {
|
||||
if (this._loading) return;
|
||||
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
|
||||
try {
|
||||
this._builtinTools = await ToolsService.list();
|
||||
} catch (err) {
|
||||
this._error = err instanceof Error ? err.message : String(err);
|
||||
console.error('[ToolsStore] Failed to fetch built-in tools:', err);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const toolsStore = new ToolsStore();
|
||||
|
||||
export const allTools = () => toolsStore.allTools;
|
||||
export const allToolDefinitions = () => toolsStore.allToolDefinitions;
|
||||
export const enabledToolDefinitions = () => toolsStore.enabledToolDefinitions;
|
||||
export const toolGroups = () => toolsStore.toolGroups;
|
||||
Loading…
Reference in New Issue