mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-06-27 23:50:20 -05:00
webui: fix tool selector toggle/counter, key tools by stable identity (#24065)
* webui: fix tool selector toggle/counter, key tools by stable identity Key the disabled set, counts and toggles by a stable per-tool key instead of bare function name, deduped from one canonical list. Per-tool checkboxes become presentational (single row handler, no nested button), category checkboxes drop the tristate (n/total carries partial). One getEnabledToolsForLLM keeps normalized MCP schemas and dedupes by name. * ui: use SvelteSet and SvelteMap for local tool collections to satisfy svelte/prefer-svelte-reactivity
This commit is contained in:
parent
4d742877b2
commit
4586479852
@ -231,7 +231,7 @@
|
|||||||
<Collapsible.Content>
|
<Collapsible.Content>
|
||||||
<div class="flex flex-col gap-0.5 pl-4">
|
<div class="flex flex-col gap-0.5 pl-4">
|
||||||
{#each toolsPanel.activeGroups as group (group.label)}
|
{#each toolsPanel.activeGroups as group (group.label)}
|
||||||
{@const { checked, indeterminate } = toolsPanel.getGroupCheckedState(group)}
|
{@const checked = toolsPanel.isGroupChecked(group)}
|
||||||
{@const enabledCount = toolsPanel.getEnabledToolCount(group)}
|
{@const enabledCount = toolsPanel.getEnabledToolCount(group)}
|
||||||
{@const favicon = toolsPanel.getFavicon(group)}
|
{@const favicon = toolsPanel.getFavicon(group)}
|
||||||
|
|
||||||
@ -259,7 +259,6 @@
|
|||||||
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
{checked}
|
{checked}
|
||||||
{indeterminate}
|
|
||||||
class="h-4 w-4 shrink-0"
|
class="h-4 w-4 shrink-0"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
|
onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { PencilRuler, ChevronDown, ChevronRight, Loader2, Info } from '@lucide/svelte';
|
import { PencilRuler, ChevronDown, ChevronRight, Loader2, Info, Check } from '@lucide/svelte';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
@ -65,7 +65,7 @@
|
|||||||
<div class="max-h-80 overflow-y-auto p-2 pr-1">
|
<div class="max-h-80 overflow-y-auto p-2 pr-1">
|
||||||
{#each toolsPanel.activeGroups as group (group.label)}
|
{#each toolsPanel.activeGroups as group (group.label)}
|
||||||
{@const isExpanded = toolsPanel.expandedGroups.has(group.label)}
|
{@const isExpanded = toolsPanel.expandedGroups.has(group.label)}
|
||||||
{@const { checked, indeterminate } = toolsPanel.getGroupCheckedState(group)}
|
{@const checked = toolsPanel.isGroupChecked(group)}
|
||||||
{@const favicon = toolsPanel.getFavicon(group)}
|
{@const favicon = toolsPanel.getFavicon(group)}
|
||||||
|
|
||||||
<Collapsible.Root
|
<Collapsible.Root
|
||||||
@ -104,12 +104,14 @@
|
|||||||
|
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
<Tooltip.Trigger>
|
<Tooltip.Trigger>
|
||||||
<Checkbox
|
{#snippet child({ props })}
|
||||||
{checked}
|
<Checkbox
|
||||||
{indeterminate}
|
{...props}
|
||||||
onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
|
{checked}
|
||||||
class="mr-2 h-4 w-4 shrink-0"
|
onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
|
||||||
/>
|
class="mr-2 h-4 w-4 shrink-0"
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
|
|
||||||
<Tooltip.Content side="right">
|
<Tooltip.Content side="right">
|
||||||
@ -123,20 +125,25 @@
|
|||||||
|
|
||||||
<Collapsible.Content>
|
<Collapsible.Content>
|
||||||
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
|
<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)}
|
{#each group.tools as entry (entry.key)}
|
||||||
|
{@const enabled = toolsStore.isToolEnabled(entry.key)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted/50"
|
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted/50"
|
||||||
onclick={() => toolsStore.toggleTool(tool.function.name)}
|
onclick={() => toolsStore.toggleTool(entry.key)}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<span
|
||||||
checked={toolsStore.isToolEnabled(tool.function.name)}
|
data-slot="checkbox"
|
||||||
onCheckedChange={() => toolsStore.toggleTool(tool.function.name)}
|
data-state={enabled ? 'checked' : 'unchecked'}
|
||||||
class="h-4 w-4 shrink-0"
|
class="flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
|
||||||
/>
|
>
|
||||||
|
{#if enabled}
|
||||||
|
<Check class="size-3.5" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
<span class="min-w-0 flex-1 truncate font-mono text-[12px]">
|
<span class="min-w-0 flex-1 truncate font-mono text-[12px]">
|
||||||
{tool.function.name}
|
{entry.definition.function.name}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@ -62,13 +62,11 @@
|
|||||||
<span class="w-20 shrink-0 text-center">Always allow</span>
|
<span class="w-20 shrink-0 text-center">Always allow</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each group.tools as tool (tool.function.name)}
|
{#each group.tools as entry (entry.key)}
|
||||||
{@const toolName = tool.function.name}
|
{@const toolName = entry.definition.function.name}
|
||||||
{@const isEnabled = toolsStore.isToolEnabled(toolName)}
|
{@const isEnabled = toolsStore.isToolEnabled(entry.key)}
|
||||||
{@const permissionKey = toolsStore.getPermissionKey(toolName)}
|
{@const permissionKey = entry.key}
|
||||||
{@const isAlwaysAllowed = permissionKey
|
{@const isAlwaysAllowed = permissionsStore.hasTool(permissionKey)}
|
||||||
? permissionsStore.hasTool(permissionKey)
|
|
||||||
: false}
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50">
|
<div class="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50">
|
||||||
<TruncatedText text={toolName} class="flex-1" showTooltip={true} />
|
<TruncatedText text={toolName} class="flex-1" showTooltip={true} />
|
||||||
@ -76,7 +74,7 @@
|
|||||||
<div class="flex w-16 shrink-0 justify-center">
|
<div class="flex w-16 shrink-0 justify-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isEnabled}
|
checked={isEnabled}
|
||||||
onCheckedChange={() => toolsStore.toggleTool(toolName)}
|
onCheckedChange={() => toolsStore.toggleTool(entry.key)}
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -86,9 +84,9 @@
|
|||||||
checked={isAlwaysAllowed}
|
checked={isAlwaysAllowed}
|
||||||
onCheckedChange={() => {
|
onCheckedChange={() => {
|
||||||
if (isAlwaysAllowed) {
|
if (isAlwaysAllowed) {
|
||||||
permissionsStore.revokeTool(permissionKey!);
|
permissionsStore.revokeTool(permissionKey);
|
||||||
} else {
|
} else {
|
||||||
permissionsStore.allowTool(permissionKey!);
|
permissionsStore.allowTool(permissionKey);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
|
|||||||
@ -17,6 +17,9 @@ export const DB_APP_NAME_DEPRECATED = 'LlamacppWebui';
|
|||||||
export const ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.alwaysAllowedTools`;
|
export const ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.alwaysAllowedTools`;
|
||||||
export const CONFIG_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.config`;
|
export const CONFIG_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.config`;
|
||||||
export const DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledTools`;
|
export const DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledTools`;
|
||||||
|
|
||||||
|
/** Disabled tools keyed by stable selection identity, no migration from the name based key */
|
||||||
|
export const DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledToolKeys`;
|
||||||
export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
|
export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
|
||||||
export const MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.mcpDefaultEnabled`;
|
export const MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.mcpDefaultEnabled`;
|
||||||
export const THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.thinkingEnabledDefault`;
|
export const THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.thinkingEnabledDefault`;
|
||||||
|
|||||||
@ -12,9 +12,9 @@ export interface UseToolsPanelReturn {
|
|||||||
readonly activeGroups: ToolGroup[];
|
readonly activeGroups: ToolGroup[];
|
||||||
readonly totalToolCount: number;
|
readonly totalToolCount: number;
|
||||||
readonly noToolsInfoMessage: string | null;
|
readonly noToolsInfoMessage: string | null;
|
||||||
getGroupCheckedState(group: ToolGroup): { checked: boolean; indeterminate: boolean };
|
isGroupChecked(group: ToolGroup): boolean;
|
||||||
getEnabledToolCount(group: ToolGroup): number;
|
getEnabledToolCount(group: ToolGroup): number;
|
||||||
getFavicon(group: { source: ToolSource; label: string }): string | null;
|
getFavicon(group: ToolGroup): string | null;
|
||||||
isGroupDisabled(group: ToolGroup): boolean;
|
isGroupDisabled(group: ToolGroup): boolean;
|
||||||
toggleGroupExpanded(label: string): void;
|
toggleGroupExpanded(label: string): void;
|
||||||
/** Toggle all tools in a group by label (avoids stale group object references). */
|
/** Toggle all tools in a group by label (avoids stale group object references). */
|
||||||
@ -54,27 +54,18 @@ export function useToolsPanel(): UseToolsPanelReturn {
|
|||||||
return `To enable Built-In Tools you need to run llama-server with ${CLI_FLAGS.TOOLS} all or ${CLI_FLAGS.TOOLS} <name> flag. To see MCP Tools you need to add / enable MCP Server(s).`;
|
return `To enable Built-In Tools you need to run llama-server with ${CLI_FLAGS.TOOLS} all or ${CLI_FLAGS.TOOLS} <name> flag. To see MCP Tools you need to add / enable MCP Server(s).`;
|
||||||
});
|
});
|
||||||
|
|
||||||
function getGroupCheckedState(group: ToolGroup): { checked: boolean; indeterminate: boolean } {
|
function isGroupChecked(group: ToolGroup): boolean {
|
||||||
return {
|
return toolsStore.isGroupFullyEnabled(group);
|
||||||
checked: toolsStore.isGroupFullyEnabled(group),
|
|
||||||
indeterminate: toolsStore.isGroupPartiallyEnabled(group)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEnabledToolCount(group: ToolGroup): number {
|
function getEnabledToolCount(group: ToolGroup): number {
|
||||||
return group.tools.filter((tool) => toolsStore.isToolEnabled(tool.function.name)).length;
|
return group.tools.filter((tool) => toolsStore.isToolEnabled(tool.key)).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFavicon(group: { source: ToolSource; label: string }): string | null {
|
function getFavicon(group: ToolGroup): string | null {
|
||||||
if (group.source !== ToolSource.MCP) return null;
|
if (group.source !== ToolSource.MCP || !group.serverId) return null;
|
||||||
|
|
||||||
for (const server of mcpStore.getServersSorted()) {
|
return mcpStore.getServerFavicon(group.serverId);
|
||||||
if (mcpStore.getServerLabel(server) === group.label) {
|
|
||||||
return mcpStore.getServerFavicon(server.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isGroupDisabled(group: ToolGroup): boolean {
|
function isGroupDisabled(group: ToolGroup): boolean {
|
||||||
@ -121,7 +112,7 @@ export function useToolsPanel(): UseToolsPanelReturn {
|
|||||||
get noToolsInfoMessage() {
|
get noToolsInfoMessage() {
|
||||||
return noToolsInfoMessage;
|
return noToolsInfoMessage;
|
||||||
},
|
},
|
||||||
getGroupCheckedState,
|
isGroupChecked,
|
||||||
getEnabledToolCount,
|
getEnabledToolCount,
|
||||||
getFavicon,
|
getFavicon,
|
||||||
isGroupDisabled,
|
isGroupDisabled,
|
||||||
|
|||||||
@ -4,12 +4,39 @@ import { mcpStore } from '$lib/stores/mcp.svelte';
|
|||||||
import { HealthCheckStatus, JsonSchemaType, ToolCallType, ToolSource } from '$lib/enums';
|
import { HealthCheckStatus, JsonSchemaType, ToolCallType, ToolSource } from '$lib/enums';
|
||||||
import { config } from '$lib/stores/settings.svelte';
|
import { config } from '$lib/stores/settings.svelte';
|
||||||
import {
|
import {
|
||||||
DISABLED_TOOLS_LOCALSTORAGE_KEY,
|
DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
|
||||||
TOOL_GROUP_LABELS,
|
TOOL_GROUP_LABELS,
|
||||||
TOOL_SERVER_LABELS
|
TOOL_SERVER_LABELS
|
||||||
} from '$lib/constants';
|
} from '$lib/constants';
|
||||||
|
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
/** Stable selection identity for a tool, shared by the disabled set and the permission store */
|
||||||
|
function toolKey(source: ToolSource, name: string, serverId?: string): string {
|
||||||
|
switch (source) {
|
||||||
|
case ToolSource.MCP:
|
||||||
|
return serverId ? `mcp-${serverId}:${name}` : `mcp:${name}`;
|
||||||
|
case ToolSource.CUSTOM:
|
||||||
|
return `custom:${name}`;
|
||||||
|
default:
|
||||||
|
return `builtin:${name}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mcpDefinition(
|
||||||
|
name: string,
|
||||||
|
description: string | undefined,
|
||||||
|
schema?: Record<string, unknown>
|
||||||
|
): OpenAIToolDefinition {
|
||||||
|
return {
|
||||||
|
type: ToolCallType.FUNCTION,
|
||||||
|
function: {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
parameters: schema ?? { type: JsonSchemaType.OBJECT, properties: {}, required: [] }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
class ToolsStore {
|
class ToolsStore {
|
||||||
private _builtinTools = $state<OpenAIToolDefinition[]>([]);
|
private _builtinTools = $state<OpenAIToolDefinition[]>([]);
|
||||||
@ -20,12 +47,12 @@ class ToolsStore {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(DISABLED_TOOLS_LOCALSTORAGE_KEY);
|
const stored = localStorage.getItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const parsed = JSON.parse(stored);
|
const parsed = JSON.parse(stored);
|
||||||
if (Array.isArray(parsed)) {
|
if (Array.isArray(parsed)) {
|
||||||
for (const name of parsed) {
|
for (const key of parsed) {
|
||||||
if (typeof name === 'string') this._disabledTools.add(name);
|
if (typeof key === 'string') this._disabledTools.add(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -33,14 +60,13 @@ class ToolsStore {
|
|||||||
console.error('[ToolsStore] Failed to load disabled tools from localStorage:', err);
|
console.error('[ToolsStore] Failed to load disabled tools from localStorage:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize builtin tools on startup
|
|
||||||
this.fetchBuiltinTools();
|
this.fetchBuiltinTools();
|
||||||
}
|
}
|
||||||
|
|
||||||
private persistDisabledTools(): void {
|
private persistDisabledTools(): void {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
DISABLED_TOOLS_LOCALSTORAGE_KEY,
|
DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
|
||||||
JSON.stringify([...this._disabledTools])
|
JSON.stringify([...this._disabledTools])
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@ -78,167 +104,141 @@ class ToolsStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Flat list of all tool entries with source metadata */
|
/** Normalize MCP tools from live connections when available, fall back to health check data */
|
||||||
get allTools(): ToolEntry[] {
|
private mcpEntries(): {
|
||||||
const entries: ToolEntry[] = [];
|
serverId: string;
|
||||||
|
serverName: string;
|
||||||
|
definition: OpenAIToolDefinition;
|
||||||
|
}[] {
|
||||||
|
const out: { serverId: string; serverName: string; definition: OpenAIToolDefinition }[] = [];
|
||||||
|
|
||||||
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();
|
const connections = mcpStore.getConnections();
|
||||||
if (connections.size > 0) {
|
if (connections.size > 0) {
|
||||||
for (const [serverId, connection] of connections) {
|
for (const [serverId, connection] of connections) {
|
||||||
const serverName = mcpStore.getServerDisplayName(serverId);
|
const serverName = mcpStore.getServerDisplayName(serverId);
|
||||||
for (const tool of connection.tools) {
|
for (const tool of connection.tools) {
|
||||||
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
|
const schema = (tool.inputSchema as Record<string, unknown>) ?? undefined;
|
||||||
type: JsonSchemaType.OBJECT,
|
out.push({
|
||||||
properties: {},
|
|
||||||
required: []
|
|
||||||
};
|
|
||||||
entries.push({
|
|
||||||
source: ToolSource.MCP,
|
|
||||||
serverName,
|
|
||||||
serverId,
|
serverId,
|
||||||
definition: {
|
serverName,
|
||||||
type: ToolCallType.FUNCTION,
|
definition: mcpDefinition(tool.name, tool.description, schema)
|
||||||
function: {
|
|
||||||
name: tool.name,
|
|
||||||
description: tool.description,
|
|
||||||
parameters: rawSchema
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) {
|
for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) {
|
||||||
for (const tool of tools) {
|
for (const tool of tools) {
|
||||||
entries.push({
|
out.push({
|
||||||
source: ToolSource.MCP,
|
|
||||||
serverName,
|
|
||||||
serverId,
|
serverId,
|
||||||
definition: {
|
serverName,
|
||||||
type: ToolCallType.FUNCTION,
|
definition: mcpDefinition(tool.name, tool.description)
|
||||||
function: {
|
|
||||||
name: tool.name,
|
|
||||||
description: tool.description,
|
|
||||||
parameters: {
|
|
||||||
type: JsonSchemaType.OBJECT,
|
|
||||||
properties: {},
|
|
||||||
required: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical flat list of tool entries with source metadata and stable keys, deduped by key */
|
||||||
|
get allTools(): ToolEntry[] {
|
||||||
|
const entries: ToolEntry[] = [];
|
||||||
|
const seen = new SvelteSet<string>();
|
||||||
|
|
||||||
|
const push = (entry: ToolEntry) => {
|
||||||
|
if (seen.has(entry.key)) return;
|
||||||
|
seen.add(entry.key);
|
||||||
|
entries.push(entry);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const def of this._builtinTools) {
|
||||||
|
const name = def.function.name;
|
||||||
|
push({ source: ToolSource.BUILTIN, key: toolKey(ToolSource.BUILTIN, name), definition: def });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { serverId, serverName, definition } of this.mcpEntries()) {
|
||||||
|
const name = definition.function.name;
|
||||||
|
push({
|
||||||
|
source: ToolSource.MCP,
|
||||||
|
serverId,
|
||||||
|
serverName,
|
||||||
|
key: toolKey(ToolSource.MCP, name, serverId),
|
||||||
|
definition
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const def of this.customTools) {
|
for (const def of this.customTools) {
|
||||||
entries.push({ source: ToolSource.CUSTOM, definition: def });
|
const name = def.function.name;
|
||||||
|
push({ source: ToolSource.CUSTOM, key: toolKey(ToolSource.CUSTOM, name), definition: def });
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tools grouped by category for tree display */
|
/** Tools grouped by category for tree display, derived from the canonical entries */
|
||||||
get toolGroups(): ToolGroup[] {
|
get toolGroups(): ToolGroup[] {
|
||||||
const groups: ToolGroup[] = [];
|
const groups: ToolGroup[] = [];
|
||||||
|
const byKey = new SvelteMap<string, ToolGroup>();
|
||||||
|
|
||||||
if (this._builtinTools.length > 0) {
|
for (const entry of this.allTools) {
|
||||||
groups.push({
|
const groupKey =
|
||||||
source: ToolSource.BUILTIN,
|
entry.source === ToolSource.MCP ? `mcp:${entry.serverId ?? ''}` : entry.source;
|
||||||
label: TOOL_GROUP_LABELS[ToolSource.BUILTIN],
|
|
||||||
tools: this._builtinTools
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use live connections when available, fall back to health check data
|
let group = byKey.get(groupKey);
|
||||||
const connections = mcpStore.getConnections();
|
if (!group) {
|
||||||
if (connections.size > 0) {
|
group = {
|
||||||
for (const [serverId, connection] of connections) {
|
source: entry.source,
|
||||||
if (connection.tools.length === 0) continue;
|
label: this.groupLabel(entry),
|
||||||
const label = mcpStore.getServerDisplayName(serverId);
|
serverId: entry.serverId,
|
||||||
const tools: OpenAIToolDefinition[] = connection.tools.map((tool) => {
|
tools: []
|
||||||
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
|
};
|
||||||
type: JsonSchemaType.OBJECT,
|
byKey.set(groupKey, group);
|
||||||
properties: {},
|
groups.push(group);
|
||||||
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;
|
group.tools.push(entry);
|
||||||
if (custom.length > 0) {
|
|
||||||
groups.push({
|
|
||||||
source: ToolSource.CUSTOM,
|
|
||||||
label: TOOL_GROUP_LABELS[ToolSource.CUSTOM],
|
|
||||||
tools: custom
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only enabled tool definitions (for sending to the API) */
|
private groupLabel(entry: ToolEntry): string {
|
||||||
get enabledToolDefinitions(): OpenAIToolDefinition[] {
|
switch (entry.source) {
|
||||||
return this.allTools
|
case ToolSource.MCP:
|
||||||
.filter((t) => !this._disabledTools.has(t.definition.function.name))
|
return entry.serverName ?? '';
|
||||||
.map((t) => t.definition);
|
case ToolSource.CUSTOM:
|
||||||
|
return TOOL_GROUP_LABELS[ToolSource.CUSTOM];
|
||||||
|
default:
|
||||||
|
return TOOL_GROUP_LABELS[ToolSource.BUILTIN];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns enabled tool definitions for sending to the LLM.
|
* Enabled tool definitions for sending to the LLM.
|
||||||
* MCP tools use properly normalized schemas from mcpStore.
|
* MCP tools keep their normalized schemas from mcpStore.
|
||||||
* Filters out tools disabled via the UI checkboxes.
|
* The API identifies tools by name, so a name is sent at most once.
|
||||||
*/
|
*/
|
||||||
getEnabledToolsForLLM(): OpenAIToolDefinition[] {
|
getEnabledToolsForLLM(): OpenAIToolDefinition[] {
|
||||||
const disabled = this._disabledTools;
|
const enabledNames = new SvelteSet<string>();
|
||||||
|
for (const entry of this.allTools) {
|
||||||
|
if (!this._disabledTools.has(entry.key)) {
|
||||||
|
enabledNames.add(entry.definition.function.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result: OpenAIToolDefinition[] = [];
|
const result: OpenAIToolDefinition[] = [];
|
||||||
|
const seen = new SvelteSet<string>();
|
||||||
|
|
||||||
for (const tool of this._builtinTools) {
|
const take = (def: OpenAIToolDefinition) => {
|
||||||
if (!disabled.has(tool.function.name)) {
|
const name = def.function.name;
|
||||||
result.push(tool);
|
if (!enabledNames.has(name) || seen.has(name)) return;
|
||||||
}
|
seen.add(name);
|
||||||
}
|
result.push(def);
|
||||||
|
};
|
||||||
|
|
||||||
// MCP tools with properly normalized schemas
|
for (const def of this._builtinTools) take(def);
|
||||||
for (const tool of mcpStore.getToolDefinitionsForLLM()) {
|
for (const def of mcpStore.getToolDefinitionsForLLM()) take(def);
|
||||||
if (!disabled.has(tool.function.name)) {
|
for (const def of this.customTools) take(def);
|
||||||
result.push(tool);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const tool of this.customTools) {
|
|
||||||
if (!disabled.has(tool.function.name)) {
|
|
||||||
result.push(tool);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@ -263,61 +263,50 @@ class ToolsStore {
|
|||||||
return this._disabledTools;
|
return this._disabledTools;
|
||||||
}
|
}
|
||||||
|
|
||||||
isToolEnabled(toolName: string): boolean {
|
isToolEnabled(key: string): boolean {
|
||||||
return !this._disabledTools.has(toolName);
|
return !this._disabledTools.has(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleTool(toolName: string): void {
|
toggleTool(key: string): void {
|
||||||
if (this._disabledTools.has(toolName)) {
|
if (this._disabledTools.has(key)) {
|
||||||
this._disabledTools.delete(toolName);
|
this._disabledTools.delete(key);
|
||||||
} else {
|
} else {
|
||||||
this._disabledTools.add(toolName);
|
this._disabledTools.add(key);
|
||||||
}
|
}
|
||||||
this.persistDisabledTools();
|
this.persistDisabledTools();
|
||||||
}
|
}
|
||||||
|
|
||||||
setToolEnabled(toolName: string, enabled: boolean): void {
|
setToolEnabled(key: string, enabled: boolean): void {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
this._disabledTools.delete(toolName);
|
this._disabledTools.delete(key);
|
||||||
} else {
|
} else {
|
||||||
this._disabledTools.add(toolName);
|
this._disabledTools.add(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Enable all tools belonging to a specific MCP server */
|
||||||
* Enable all tools belonging to a specific MCP server.
|
|
||||||
* Called when a server is enabled for a conversation.
|
|
||||||
*/
|
|
||||||
enableAllToolsForServer(serverId: string): void {
|
enableAllToolsForServer(serverId: string): void {
|
||||||
const connection = mcpStore.getConnections().get(serverId);
|
const connection = mcpStore.getConnections().get(serverId);
|
||||||
if (!connection) return;
|
if (!connection) return;
|
||||||
for (const tool of connection.tools) {
|
for (const tool of connection.tools) {
|
||||||
this._disabledTools.delete(tool.name);
|
this._disabledTools.delete(toolKey(ToolSource.MCP, tool.name, serverId));
|
||||||
}
|
}
|
||||||
this.persistDisabledTools();
|
this.persistDisabledTools();
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleGroup(group: ToolGroup): void {
|
toggleGroup(group: ToolGroup): void {
|
||||||
const allEnabled = group.tools.every((t) => this.isToolEnabled(t.function.name));
|
const allEnabled = group.tools.every((t) => this.isToolEnabled(t.key));
|
||||||
for (const tool of group.tools) {
|
for (const tool of group.tools) {
|
||||||
this.setToolEnabled(tool.function.name, !allEnabled);
|
this.setToolEnabled(tool.key, !allEnabled);
|
||||||
}
|
}
|
||||||
this.persistDisabledTools();
|
this.persistDisabledTools();
|
||||||
}
|
}
|
||||||
|
|
||||||
isGroupFullyEnabled(group: ToolGroup): boolean {
|
isGroupFullyEnabled(group: ToolGroup): boolean {
|
||||||
return group.tools.length > 0 && group.tools.every((t) => this.isToolEnabled(t.function.name));
|
return group.tools.length > 0 && group.tools.every((t) => this.isToolEnabled(t.key));
|
||||||
}
|
}
|
||||||
|
|
||||||
isGroupPartiallyEnabled(group: ToolGroup): boolean {
|
/** Get MCP tools from health check data, used when live connections aren't established yet */
|
||||||
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(): {
|
private getMcpToolsFromHealthChecks(): {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
serverName: string;
|
serverName: string;
|
||||||
@ -337,60 +326,35 @@ class ToolsStore {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Determine the source of a tool by its name. */
|
/** First canonical entry matching a tool name, runtime tool calls resolve by name */
|
||||||
getToolSource(toolName: string): ToolSource | null {
|
private findEntryByName(toolName: string): ToolEntry | null {
|
||||||
if (this._builtinTools.some((t) => t.function.name === toolName)) {
|
|
||||||
return ToolSource.BUILTIN;
|
|
||||||
}
|
|
||||||
for (const entry of this.allTools) {
|
for (const entry of this.allTools) {
|
||||||
if (entry.definition.function.name === toolName) {
|
if (entry.definition.function.name === toolName) return entry;
|
||||||
return entry.source;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the display label for the server that owns a given tool. */
|
/** Determine the source of a tool by its name */
|
||||||
|
getToolSource(toolName: string): ToolSource | null {
|
||||||
|
return this.findEntryByName(toolName)?.source ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the display label for the server that owns a given tool */
|
||||||
getToolServerLabel(toolName: string): string {
|
getToolServerLabel(toolName: string): string {
|
||||||
for (const entry of this.allTools) {
|
const entry = this.findEntryByName(toolName);
|
||||||
if (entry.definition.function.name === toolName) {
|
if (!entry) return '';
|
||||||
if (entry.serverName) {
|
if (entry.serverName) return mcpStore.getServerDisplayName(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];
|
||||||
if (entry.source === ToolSource.BUILTIN) {
|
|
||||||
return TOOL_SERVER_LABELS[ToolSource.BUILTIN];
|
|
||||||
}
|
|
||||||
if (entry.source === ToolSource.CUSTOM) {
|
|
||||||
return TOOL_SERVER_LABELS[ToolSource.CUSTOM];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a permission key with category prefix, e.g. "mcp-<serverId>:tool_name" */
|
/** Permission key for a tool name, identical to the selection key */
|
||||||
getPermissionKey(toolName: string): string | null {
|
getPermissionKey(toolName: string): string | null {
|
||||||
for (const entry of this.allTools) {
|
return this.findEntryByName(toolName)?.key ?? null;
|
||||||
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). */
|
/** Check if there are any enabled tools available (builtin, MCP, or custom) */
|
||||||
get hasEnabledTools(): boolean {
|
get hasEnabledTools(): boolean {
|
||||||
return this.getEnabledToolsForLLM().length > 0;
|
return this.getEnabledToolsForLLM().length > 0;
|
||||||
}
|
}
|
||||||
@ -423,5 +387,4 @@ export const toolsStore = new ToolsStore();
|
|||||||
|
|
||||||
export const allTools = () => toolsStore.allTools;
|
export const allTools = () => toolsStore.allTools;
|
||||||
export const allToolDefinitions = () => toolsStore.allToolDefinitions;
|
export const allToolDefinitions = () => toolsStore.allToolDefinitions;
|
||||||
export const enabledToolDefinitions = () => toolsStore.enabledToolDefinitions;
|
|
||||||
export const toolGroups = () => toolsStore.toolGroups;
|
export const toolGroups = () => toolsStore.toolGroups;
|
||||||
|
|||||||
4
tools/ui/src/lib/types/tools.d.ts
vendored
4
tools/ui/src/lib/types/tools.d.ts
vendored
@ -7,6 +7,8 @@ export interface ToolEntry {
|
|||||||
serverName?: string;
|
serverName?: string;
|
||||||
/** For MCP tools, the server ID (used for permission keys) */
|
/** For MCP tools, the server ID (used for permission keys) */
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
|
/** Stable selection identity: builtin:name, mcp-<serverId>:name, mcp:name, custom:name */
|
||||||
|
key: string;
|
||||||
definition: OpenAIToolDefinition;
|
definition: OpenAIToolDefinition;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -15,5 +17,5 @@ export interface ToolGroup {
|
|||||||
label: string;
|
label: string;
|
||||||
/** For MCP groups, the server ID */
|
/** For MCP groups, the server ID */
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
tools: OpenAIToolDefinition[];
|
tools: ToolEntry[];
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user