mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-07 02:58:21 +00:00
refac
This commit is contained in:
parent
cf5755f949
commit
fb741ebcd2
7 changed files with 240 additions and 107 deletions
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
|
@ -44,7 +45,8 @@ from open_webui.utils.plugin import (
|
|||
replace_imports,
|
||||
resolve_valves_schema_options,
|
||||
)
|
||||
from open_webui.utils.tools import get_tool_servers, get_tool_specs
|
||||
from open_webui.utils.tools import connect_mcp_server, get_tool_servers
|
||||
from open_webui.utils.tools import get_tool_specs as get_local_tool_specs
|
||||
from pydantic import BaseModel, HttpUrl
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -196,6 +198,32 @@ async def get_tools(
|
|||
return tools
|
||||
|
||||
|
||||
@router.get('/id/{id}/specs')
|
||||
async def get_tool_specs(request: Request, id: str, user=Depends(get_verified_user)):
|
||||
"""Discover tools for an accessible connection. Currently supports MCP."""
|
||||
if not id.startswith('server:mcp:'):
|
||||
raise HTTPException(status_code=404, detail='Tool not found')
|
||||
|
||||
try:
|
||||
# Keep connect, discovery and cleanup in one task for the MCP transport.
|
||||
async with asyncio.timeout(15):
|
||||
result = await connect_mcp_server(request, id.removeprefix('server:mcp:'), user, {}, {})
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail='Tool not found')
|
||||
client, specs = result
|
||||
try:
|
||||
return {'specs': [{'name': spec['name'], 'description': spec.get('description', '')} for spec in specs]}
|
||||
finally:
|
||||
await client.disconnect()
|
||||
except HTTPException:
|
||||
raise
|
||||
except TimeoutError:
|
||||
raise HTTPException(status_code=504, detail='Tool discovery timed out')
|
||||
except Exception:
|
||||
log.exception('Failed to discover tool specs')
|
||||
raise HTTPException(status_code=502, detail='Unable to load tools')
|
||||
|
||||
|
||||
############################
|
||||
# GetToolList
|
||||
############################
|
||||
|
|
@ -397,7 +425,7 @@ async def create_new_tools(
|
|||
TOOLS = get_tools_cache(request)
|
||||
TOOLS[form_data.id] = tool_module
|
||||
|
||||
specs = get_tool_specs(TOOLS[form_data.id])
|
||||
specs = get_local_tool_specs(TOOLS[form_data.id])
|
||||
tools = await Tools.insert_new_tool(user.id, form_data, specs, db=db)
|
||||
|
||||
tool_cache_dir = CACHE_DIR / 'tools' / form_data.id
|
||||
|
|
@ -539,7 +567,7 @@ async def update_tools_by_id(
|
|||
TOOLS = get_tools_cache(request)
|
||||
TOOLS[id] = tool_module
|
||||
|
||||
specs = get_tool_specs(TOOLS[id])
|
||||
specs = get_local_tool_specs(TOOLS[id])
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
await Config.get('user.permissions'),
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ from open_webui.socket.main import (
|
|||
get_event_emitter,
|
||||
)
|
||||
from open_webui.tasks import clear_response_stream, save_response_stream
|
||||
from open_webui.utils.access_control import has_connection_access, has_permission
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.access_control.files import get_owner_accessible_folder_files
|
||||
from open_webui.utils.access_control.folders import has_folder_access
|
||||
from open_webui.utils.ask_user import stage_ask_user_tool_calls
|
||||
|
|
@ -104,7 +104,6 @@ from open_webui.utils.filter import (
|
|||
process_filter_functions,
|
||||
)
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from open_webui.utils.mcp.client import MCPClient
|
||||
from open_webui.utils.memory import add_memory_context, review_memory_after_turn
|
||||
from open_webui.utils.misc import (
|
||||
add_or_update_system_message,
|
||||
|
|
@ -122,7 +121,6 @@ from open_webui.utils.misc import (
|
|||
get_response_error_detail,
|
||||
get_system_message,
|
||||
is_raster_image_content_type,
|
||||
is_string_allowed,
|
||||
merge_system_messages,
|
||||
prepend_to_first_user_message_content,
|
||||
replace_system_message_content,
|
||||
|
|
@ -145,7 +143,7 @@ from open_webui.utils.task import (
|
|||
tools_function_calling_generation_template,
|
||||
)
|
||||
from open_webui.utils.tools import (
|
||||
build_tool_server_headers,
|
||||
connect_mcp_server,
|
||||
get_attached_knowledge,
|
||||
get_builtin_tools,
|
||||
get_terminal_tools,
|
||||
|
|
@ -2320,61 +2318,6 @@ def sanitize_tool_pairs(messages: list[dict]) -> list[dict]:
|
|||
return sanitized
|
||||
|
||||
|
||||
async def connect_mcp_server(
|
||||
request,
|
||||
server_id: str,
|
||||
user,
|
||||
metadata: dict,
|
||||
extra_params: dict,
|
||||
) -> tuple[MCPClient, list[dict]] | None:
|
||||
"""Resolve an MCP server connection, authenticate, and return (client, tool_specs).
|
||||
|
||||
Returns None if the server is not found or access is denied.
|
||||
"""
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
log.debug('MCP resolution skipped: external plugins are disabled')
|
||||
return None
|
||||
|
||||
mcp_server_connection = None
|
||||
for server_connection in await Config.get('tool_server.connections', []):
|
||||
if server_connection.get('type', '') == 'mcp' and (server_connection.get('info') or {}).get('id') == server_id:
|
||||
mcp_server_connection = server_connection
|
||||
break
|
||||
|
||||
if not mcp_server_connection:
|
||||
log.error(f'MCP server with id {server_id} not found')
|
||||
return None
|
||||
|
||||
if not await has_connection_access(user, mcp_server_connection):
|
||||
log.warning(f'Access denied to MCP server {server_id} for user {user.id}')
|
||||
return None
|
||||
|
||||
headers, _ = await build_tool_server_headers(
|
||||
mcp_server_connection,
|
||||
request,
|
||||
user,
|
||||
server_id=server_id,
|
||||
metadata=metadata,
|
||||
extra_params=extra_params,
|
||||
)
|
||||
|
||||
client = MCPClient()
|
||||
await client.connect(
|
||||
url=mcp_server_connection.get('url', ''),
|
||||
headers=headers if headers else None,
|
||||
)
|
||||
|
||||
function_name_filter_list = mcp_server_connection.get('config', {}).get('function_name_filter_list', '')
|
||||
if isinstance(function_name_filter_list, str):
|
||||
function_name_filter_list = function_name_filter_list.split(',')
|
||||
|
||||
tool_specs = await client.list_tool_specs()
|
||||
if function_name_filter_list:
|
||||
tool_specs = [spec for spec in tool_specs if is_string_allowed(spec['name'], function_name_filter_list)]
|
||||
|
||||
return client, tool_specs
|
||||
|
||||
|
||||
async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
# Ensure chat_id is always a string — external API clients may omit it.
|
||||
if not isinstance(metadata.get('chat_id'), str):
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from urllib.parse import quote, urlencode
|
|||
|
||||
import aiohttp
|
||||
import yaml
|
||||
from fastapi import Request
|
||||
from fastapi import HTTPException, Request
|
||||
from langchain_core.utils.function_calling import (
|
||||
convert_to_openai_function as convert_pydantic_model_to_openai_function_spec,
|
||||
)
|
||||
|
|
@ -110,6 +110,7 @@ from open_webui.utils.headers import (
|
|||
normalize_bearer_token,
|
||||
)
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from open_webui.utils.mcp.client import MCPClient
|
||||
from open_webui.utils.misc import is_string_allowed
|
||||
from open_webui.utils.plugin import get_tool_contents_cache, get_tools_cache, load_tool_module_by_id
|
||||
from open_webui.utils.terminals import (
|
||||
|
|
@ -185,6 +186,75 @@ async def build_tool_server_headers(
|
|||
return headers, cookies
|
||||
|
||||
|
||||
async def connect_mcp_server(
|
||||
request,
|
||||
server_id: str,
|
||||
user,
|
||||
metadata: dict,
|
||||
extra_params: dict,
|
||||
) -> tuple[MCPClient, list[dict]] | None:
|
||||
"""Resolve an MCP server connection, authenticate, and return (client, tool_specs).
|
||||
|
||||
Returns None if the server is not found or access is denied.
|
||||
"""
|
||||
if not ENABLE_TOOL_SERVERS:
|
||||
log.debug('MCP resolution skipped: external plugins are disabled')
|
||||
return None
|
||||
|
||||
mcp_server_connection = None
|
||||
for server_connection in await Config.get('tool_server.connections', []):
|
||||
if server_connection.get('type', '') == 'mcp' and (server_connection.get('info') or {}).get('id') == server_id:
|
||||
mcp_server_connection = server_connection
|
||||
break
|
||||
|
||||
if not mcp_server_connection or not (mcp_server_connection.get('config') or {}).get('enable'):
|
||||
log.error(f'MCP server with id {server_id} not found')
|
||||
return None
|
||||
|
||||
if not await has_connection_access(user, mcp_server_connection):
|
||||
log.warning(f'Access denied to MCP server {server_id} for user {user.id}')
|
||||
return None
|
||||
|
||||
if mcp_server_connection.get('auth_type') == 'system_oauth' and not extra_params.get('__oauth_token__'):
|
||||
session_id = request.cookies.get('oauth_session_id')
|
||||
if session_id:
|
||||
extra_params = {
|
||||
**extra_params,
|
||||
'__oauth_token__': await request.app.state.oauth_manager.get_oauth_token(user.id, session_id),
|
||||
}
|
||||
|
||||
headers, _ = await build_tool_server_headers(
|
||||
mcp_server_connection,
|
||||
request,
|
||||
user,
|
||||
server_id=server_id,
|
||||
metadata=metadata,
|
||||
extra_params=extra_params,
|
||||
)
|
||||
|
||||
if mcp_server_connection.get('auth_type') in ('oauth_2.1', 'oauth_2.1_static') and not headers.get('Authorization'):
|
||||
raise HTTPException(status_code=401, detail='Auth required')
|
||||
|
||||
client = MCPClient()
|
||||
try:
|
||||
await client.connect(
|
||||
url=mcp_server_connection.get('url', ''),
|
||||
headers=headers if headers else None,
|
||||
)
|
||||
function_name_filter_list = (mcp_server_connection.get('config') or {}).get('function_name_filter_list', '')
|
||||
if isinstance(function_name_filter_list, str):
|
||||
function_name_filter_list = function_name_filter_list.split(',')
|
||||
|
||||
tool_specs = await client.list_tool_specs()
|
||||
if function_name_filter_list:
|
||||
tool_specs = [spec for spec in tool_specs if is_string_allowed(spec['name'], function_name_filter_list)]
|
||||
return client, tool_specs
|
||||
except BaseException:
|
||||
# MCP sessions must be closed in the same task that opened them.
|
||||
await client.disconnect()
|
||||
raise
|
||||
|
||||
|
||||
# Let no function be called without need, and let what
|
||||
# it yields justify the cost of running it.
|
||||
async def get_async_tool_function_and_apply_extra_params(
|
||||
|
|
@ -271,9 +341,7 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr
|
|||
return {}
|
||||
|
||||
enabled_ids = [
|
||||
tool_id
|
||||
for tool_id in tool_ids
|
||||
if (ENABLE_TOOL_SERVERS if tool_id.startswith('server:') else ENABLE_TOOLS)
|
||||
tool_id for tool_id in tool_ids if (ENABLE_TOOL_SERVERS if tool_id.startswith('server:') else ENABLE_TOOLS)
|
||||
]
|
||||
if len(enabled_ids) != len(tool_ids):
|
||||
log.debug('Excluded tools disabled by plugin configuration')
|
||||
|
|
|
|||
|
|
@ -99,6 +99,16 @@ export const getTools = async (token: string = '', query: string | null = null)
|
|||
return res;
|
||||
};
|
||||
|
||||
export const getToolSpecs = async (token: string, id: string) => {
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/tools/id/${encodeURIComponent(id)}/specs`, {
|
||||
headers: { Accept: 'application/json', authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw { status: res.status };
|
||||
}
|
||||
return (await res.json()).specs;
|
||||
};
|
||||
|
||||
export const getToolList = async (token: string = '') => {
|
||||
let error = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -1640,7 +1640,12 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<ToolServersModal bind:show={showTools} {selectedToolIds} />
|
||||
<ToolServersModal
|
||||
bind:show={showTools}
|
||||
{selectedToolIds}
|
||||
onConnect={(id) =>
|
||||
oauthRedirectHandler({ id, serverId: id.split(':').at(-1), authType: 'mcp' }, chatInputDraft)}
|
||||
/>
|
||||
<SkillsModal bind:show={showSkills} {selectedSkillIds} />
|
||||
|
||||
<InputVariablesModal
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { getToolSpecs } from '$lib/apis/tools';
|
||||
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
|
||||
import { getContext } from 'svelte';
|
||||
import { toolServers, tools } from '$lib/stores';
|
||||
|
|
@ -9,26 +10,62 @@
|
|||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
|
||||
export let show = false;
|
||||
export let selectedToolIds = [];
|
||||
export let selectedToolIds: string[] = [];
|
||||
export let onConnect: (id: string) => void = () => {};
|
||||
|
||||
let selectedTools = [];
|
||||
let discovery: Record<
|
||||
string,
|
||||
{ specs?: any[]; loading?: boolean; error?: 'auth' | 'unavailable' }
|
||||
> = {};
|
||||
|
||||
$: selectedTools = ($tools ?? []).filter((tool) => selectedToolIds.includes(tool.id));
|
||||
const loadSpecs = async (tool: { id: string }) => {
|
||||
if (discovery[tool.id]?.loading) return;
|
||||
discovery = { ...discovery, [tool.id]: { loading: true } };
|
||||
try {
|
||||
const specs = await getToolSpecs(localStorage.token, tool.id);
|
||||
discovery = { ...discovery, [tool.id]: { specs } };
|
||||
} catch (error) {
|
||||
discovery = {
|
||||
...discovery,
|
||||
[tool.id]: {
|
||||
error:
|
||||
error && typeof error === 'object' && 'status' in error && error.status === 401
|
||||
? 'auth'
|
||||
: 'unavailable'
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
const reconnect = (tool: { id: string }) => {
|
||||
show = false;
|
||||
onConnect(tool.id);
|
||||
};
|
||||
|
||||
const authStatus = (tool) =>
|
||||
let selectedTools: any[] = [];
|
||||
|
||||
$: selectedTools = (($tools ?? []) as any[]).filter((tool) => selectedToolIds.includes(tool.id));
|
||||
|
||||
$: selectedToolServers = (($toolServers ?? []) as any[]).filter((server, idx) =>
|
||||
selectedToolIds.some((id) => {
|
||||
if (!id.startsWith('direct_server:')) return false;
|
||||
const serverId = id.slice('direct_server:'.length);
|
||||
return !isNaN(parseInt(serverId)) ? parseInt(serverId) === idx : serverId === server?.id;
|
||||
})
|
||||
);
|
||||
|
||||
const i18n = getContext<any>('i18n');
|
||||
|
||||
const authStatus = (tool: { id: string; authenticated?: boolean }) =>
|
||||
tool?.authenticated === false
|
||||
? {
|
||||
label: $i18n.t('Auth required'),
|
||||
dot: 'bg-amber-500',
|
||||
pill: 'text-amber-700 dark:text-amber-300'
|
||||
dot: 'bg-amber-500'
|
||||
}
|
||||
: tool?.authenticated === true
|
||||
? {
|
||||
label: $i18n.t('Connected'),
|
||||
dot: 'bg-green-500',
|
||||
pill: 'text-green-700 dark:text-green-300'
|
||||
dot: 'bg-green-500'
|
||||
}
|
||||
: null;
|
||||
</script>
|
||||
|
|
@ -49,7 +86,7 @@
|
|||
</div>
|
||||
|
||||
{#if selectedTools.length > 0}
|
||||
{#if $toolServers.length > 0}
|
||||
{#if selectedToolServers.length > 0}
|
||||
<div class=" flex justify-between dark:text-gray-300 px-5 pb-1">
|
||||
<div class=" text-base font-normal self-center">{$i18n.t('Tools')}</div>
|
||||
</div>
|
||||
|
|
@ -57,29 +94,24 @@
|
|||
|
||||
<div class="px-3 pb-3 w-full flex flex-col justify-center">
|
||||
<div class=" text-sm dark:text-gray-300 mb-1">
|
||||
{#each selectedTools as tool}
|
||||
{@const status = authStatus(tool)}
|
||||
{@const toolSpecs = tool?.specs ?? []}
|
||||
{#each selectedTools as tool (tool.id)}
|
||||
{@const isMcp = tool.id.startsWith('server:mcp:')}
|
||||
{@const state = discovery[tool.id]}
|
||||
{@const needsAuth = tool.authenticated === false || state?.error === 'auth'}
|
||||
{@const status = authStatus(needsAuth ? { ...tool, authenticated: false } : tool)}
|
||||
{@const toolSpecs = needsAuth ? undefined : isMcp ? state?.specs : tool.specs}
|
||||
<Collapsible
|
||||
buttonClassName="w-full mb-1 rounded-lg px-2 py-1.5"
|
||||
chevron={toolSpecs.length > 0}
|
||||
disabled={toolSpecs.length === 0}
|
||||
chevron
|
||||
onChange={(open: boolean) => {
|
||||
if (open && isMcp && !needsAuth && !state) loadSpecs(tool);
|
||||
}}
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div class="text-sm font-normal dark:text-gray-100 text-gray-800 truncate">
|
||||
{resolveLocalizedResource(tool, $i18n.language)}
|
||||
</div>
|
||||
{#if tool?.authenticated === false && status}
|
||||
<span class="text-[0.6875rem] {status.pill} shrink-0">{status.label}</span>
|
||||
{/if}
|
||||
{#if toolSpecs.length > 0}
|
||||
<span
|
||||
class="inline-flex min-w-3 items-center justify-center text-center text-[0.6875rem] leading-none text-gray-500 dark:text-gray-400 shrink-0"
|
||||
>
|
||||
{toolSpecs.length}
|
||||
</span>
|
||||
{/if}
|
||||
{#if status}
|
||||
<Tooltip content={status.label} className="flex shrink-0 p-1 -m-1">
|
||||
<span
|
||||
|
|
@ -89,22 +121,59 @@
|
|||
></span>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{#if needsAuth}
|
||||
<span class="text-[0.6875rem] text-amber-700 dark:text-amber-300 shrink-0"
|
||||
>{$i18n.t('Auth required')}</span
|
||||
>
|
||||
{:else if toolSpecs !== undefined}
|
||||
<span class="text-[0.6875rem] text-gray-500 dark:text-gray-400 shrink-0">
|
||||
{toolSpecs.length === 1
|
||||
? $i18n.t('1 tool')
|
||||
: $i18n.t('{{COUNT}} tools', { COUNT: toolSpecs.length })}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if resolveLocalizedResource(tool, $i18n.language, 'description')}
|
||||
<div class="text-xs text-gray-500 truncate">
|
||||
{resolveLocalizedResource(tool, $i18n.language, 'description')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div slot="content" class="pl-4 pr-2 pb-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{#if toolSpecs.length > 0}
|
||||
{#each toolSpecs as toolSpec}
|
||||
<div class="mt-1 truncate">
|
||||
{toolSpec?.name ?? toolSpec?.function?.name}
|
||||
</div>
|
||||
{/each}
|
||||
<div slot="content" class="px-2 pb-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{#if needsAuth}
|
||||
<button
|
||||
class="my-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition underline"
|
||||
on:click={() => reconnect(tool)}>{$i18n.t('Reconnect')}</button
|
||||
>
|
||||
{:else if state?.loading}
|
||||
<p class="my-1" role="status">{$i18n.t('Loading tools...')}</p>
|
||||
{:else if state?.error}
|
||||
<div class="flex items-center justify-between gap-2 my-1" role="status">
|
||||
<span>{$i18n.t('Unable to load tools')}</span>
|
||||
<button
|
||||
class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition underline"
|
||||
on:click={() => loadSpecs(tool)}>{$i18n.t('Retry')}</button
|
||||
>
|
||||
</div>
|
||||
{:else if toolSpecs?.length > 0}
|
||||
<div class="max-h-64 space-y-2 overflow-y-auto overscroll-contain py-1">
|
||||
{#each toolSpecs as toolSpec}
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
class="text-xs font-normal leading-4 text-gray-700 dark:text-gray-300 break-words"
|
||||
>
|
||||
{toolSpec?.name ?? toolSpec?.function?.name}
|
||||
</div>
|
||||
{#if toolSpec?.description ?? toolSpec?.function?.description}
|
||||
<div class="text-[0.6875rem] leading-4 text-gray-500 break-words">
|
||||
{toolSpec?.description ?? toolSpec?.function?.description}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="my-1">{$i18n.t('No tools found')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Collapsible>
|
||||
|
|
@ -113,7 +182,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $toolServers.length > 0}
|
||||
{#if selectedToolServers.length > 0}
|
||||
<div class=" flex justify-between dark:text-gray-300 px-5 pb-0.5">
|
||||
<div class=" text-base font-normal self-center">{$i18n.t('Tool Servers')}</div>
|
||||
</div>
|
||||
|
|
@ -130,7 +199,7 @@
|
|||
>
|
||||
</div>
|
||||
<div class=" text-sm dark:text-gray-300 mb-1">
|
||||
{#each $toolServers as toolServer}
|
||||
{#each selectedToolServers as toolServer}
|
||||
<Collapsible buttonClassName="w-full" chevron>
|
||||
<div>
|
||||
<div class="text-sm font-normal dark:text-gray-100 text-gray-800">
|
||||
|
|
@ -146,14 +215,19 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div slot="content">
|
||||
<div
|
||||
slot="content"
|
||||
class="max-h-64 space-y-2 overflow-y-auto overscroll-contain py-1"
|
||||
>
|
||||
{#each toolServer?.specs ?? [] as tool_spec}
|
||||
<div class="my-1">
|
||||
<div class="font-normal text-gray-800 dark:text-gray-100">
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
class="text-xs font-normal leading-4 text-gray-700 dark:text-gray-300 break-words"
|
||||
>
|
||||
{tool_spec?.name}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-[0.6875rem] leading-4 text-gray-500 break-words">
|
||||
{tool_spec?.description}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
{
|
||||
"1 tool": "",
|
||||
"{{COUNT}} tools": "",
|
||||
"Reconnect": "",
|
||||
"Loading tools...": "",
|
||||
"Unable to load tools": "",
|
||||
"-1 for no limit, or a positive integer for a specific limit": "",
|
||||
"(latest)": "",
|
||||
"(leave blank for to use commercial endpoint)": "",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue