diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index ed6ad9d4a6..8f81253759 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -10,6 +10,7 @@ import asyncio import logging import time from typing import Literal, Optional +from urllib.parse import unquote from fastapi import HTTPException, Request @@ -3468,6 +3469,7 @@ async def view_skill( id: str, __request__: Request = None, __user__: dict = None, + __metadata__: dict = None, ) -> str: """ Load the full instructions of a skill by its id from the available skills manifest. @@ -3483,6 +3485,16 @@ async def view_skill( return JSONCodec.dumps({'error': 'User context not available'}) try: + terminal_skill_prefix = 'terminal:' + if isinstance(id, str) and id.startswith(terminal_skill_prefix): + from open_webui.utils.terminals import get_terminal_skill + + skill_name = unquote(id.removeprefix(terminal_skill_prefix)) + skill = await get_terminal_skill(__request__, __user__, __metadata__ or {}, skill_name) + if not skill: + return JSONCodec.dumps({'error': f"Skill '{id}' not found"}) + return JSONCodec.dumps(skill, ensure_ascii=False) + from open_webui.models.access_grants import AccessGrants from open_webui.models.skills import Skills diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index a3b3632f4c..83ab30eb08 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -14,6 +14,7 @@ import textwrap import time from concurrent.futures import ThreadPoolExecutor from typing import Any, Optional +from urllib.parse import unquote from uuid import uuid4 from aiocache import cached @@ -2293,8 +2294,9 @@ def sanitize_tool_pairs(messages: list[dict]) -> list[dict]: return sanitized -# Match candidate mentions using the same ID characters allowed on skill creation. -SKILL_MENTION_RE = re.compile(r'<(?:\$([a-z0-9_-]+)(?:\|[^>]*)?|/([a-z0-9_-]+)\|[^>]*)>') +# Match DB skill IDs and terminal skill IDs created by the $ picker. +SKILL_ID_RE = r'(?:[a-z0-9_-]+|terminal:[^|>\s]+)' +SKILL_MENTION_RE = re.compile(rf'<(?:\$({SKILL_ID_RE})(?:\|[^>]*)?|/({SKILL_ID_RE})\|[^>]*)>') def _get_text_parts(message: dict) -> list[str]: @@ -2316,7 +2318,7 @@ def extract_skill_ids_from_messages(messages: list[dict]) -> set[str]: return ids -SKILL_MENTION_STRIP_RE = re.compile(r'<(?:\$([a-z0-9_-]+)(?:\|([^>]*))?|/([a-z0-9_-]+)\|([^>]*))>') +SKILL_MENTION_STRIP_RE = re.compile(rf'<(?:\$({SKILL_ID_RE})(?:\|([^>]*))?|/({SKILL_ID_RE})\|([^>]*))>') def strip_skill_mentions(messages: list[dict], skill_ids: set[str]) -> None: @@ -2751,6 +2753,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): terminal_id = form_data.pop('terminal_id', None) files = form_data.pop('files', None) form_data.pop('folder_id', None) + metadata['terminal_id'] = terminal_id # If the original caller provided tools, use them as-is (skip resolution). # Otherwise, save any tools that filter inlets added for merging later. @@ -2764,6 +2767,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): | mentioned_skill_ids ) available_skills = [] + terminal_skills = [] view_skill_ids = [] chat = None if is_saved_chat_id(metadata.get('chat_id')): @@ -2800,14 +2804,27 @@ async def process_chat_payload(request, form_data, user, metadata, model): and (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get('builtin_tools', True) ) - if skill_ids: + if skill_ids or use_builtin_tools: + import aiohttp + from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA from open_webui.models.skills import Skills as SkillsModel + from open_webui.utils.skills import format_terminal_skill_context, format_terminal_skill_manifest_entry + from open_webui.utils.terminals import get_terminal_request_info, get_terminal_skill - accessible_skills = {s.id: s for s in await SkillsModel.get_skills(user_id=user.id, ids=skill_ids)} - for sid in skill_ids: - s = accessible_skills.get(sid) - if s and s.is_active: - available_skills.append(s) + terminal_skill_prefix = 'terminal:' + db_skill_ids = [sid for sid in skill_ids if not sid.startswith(terminal_skill_prefix)] + terminal_skill_ids = [sid for sid in skill_ids if sid.startswith(terminal_skill_prefix)] + + if use_builtin_tools: + accessible_skills = {s.id: s for s in await SkillsModel.get_skills(user_id=user.id)} + db_skill_ids = sorted(accessible_skills) + else: + accessible_skills = {s.id: s for s in await SkillsModel.get_skills(user_id=user.id, ids=db_skill_ids)} + + for sid in db_skill_ids: + skill = accessible_skills.get(sid) + if skill and skill.is_active: + available_skills.append(skill) skill_manifest = '' for skill in available_skills: @@ -2824,6 +2841,46 @@ async def process_chat_payload(request, form_data, user, metadata, model): f'{skill.description or ""}\n\n' ) + terminal_request = ( + await get_terminal_request_info(request, user, metadata, extra_params) if terminal_id or terminal_skill_ids else None + ) + + listed_terminal_skills = [] + if terminal_request: + terminal_base_url, terminal_headers, terminal_cookies = terminal_request + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA) + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + async with session.get( + f'{terminal_base_url.rstrip("/")}/skills', + headers=terminal_headers, + cookies=terminal_cookies, + ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, + ) as response: + if response.status == 200: + listed = await response.json() + listed_terminal_skills = listed if isinstance(listed, list) else [] + + if terminal_id and use_builtin_tools: + terminal_skills = listed_terminal_skills + elif terminal_skill_ids: + terminal_skill_map = {skill['id']: skill for skill in listed_terminal_skills} + terminal_skills = [skill for sid in terminal_skill_ids if (skill := terminal_skill_map.get(sid))] + + for skill in terminal_skills: + sid = skill['id'] + if sid in mentioned_skill_ids or not use_builtin_tools: + skill_name = unquote(sid.removeprefix(terminal_skill_prefix)) + loaded = await get_terminal_skill(request, user.model_dump(), metadata, skill_name, extra_params) + if loaded: + form_data['messages'] = add_or_update_system_message( + format_terminal_skill_context(loaded), + form_data['messages'], + append=True, + ) + else: + view_skill_ids.append(sid) + skill_manifest += format_terminal_skill_manifest_entry(skill) + if skill_manifest: form_data['messages'] = add_or_update_system_message( f'\n{skill_manifest}', @@ -2832,7 +2889,8 @@ async def process_chat_payload(request, form_data, user, metadata, model): ) # Strip only resolved skill mentions; ordinary text such as Perl's <$fh> stays intact. - strip_skill_mentions(form_data.get('messages', []), {s.id for s in available_skills}) + resolved_skill_ids = {s.id for s in available_skills} | {s['id'] for s in terminal_skills} + strip_skill_mentions(form_data.get('messages', []), resolved_skill_ids) prompt = get_last_user_message(form_data['messages']) @@ -2841,7 +2899,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): # the stripped result is an empty string which causes 400 errors on providers # that reject empty content blocks (e.g. AWS Bedrock ConverseStream). if not prompt or not prompt.strip(): - fallback = ', '.join(s.name for s in available_skills) + fallback = ', '.join([s.name for s in available_skills] + [s['name'] for s in terminal_skills]) if fallback: set_last_user_message_content(fallback, form_data['messages']) prompt = fallback @@ -2959,7 +3017,10 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Resolve terminal tools if terminal_id is set (outside tool_ids check # so system terminals work even when no other tools are selected) terminal_capability = (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get('terminal', True) - if terminal_id and terminal_capability: + terminal_connection_ids = { + connection.get('id') for connection in await Config.get('terminal_server.connections', []) or [] + } + if terminal_id and terminal_capability and terminal_id in terminal_connection_ids: try: terminal_result = await get_terminal_tools( request, diff --git a/backend/open_webui/utils/skills.py b/backend/open_webui/utils/skills.py new file mode 100644 index 0000000000..d3c743078b --- /dev/null +++ b/backend/open_webui/utils/skills.py @@ -0,0 +1,26 @@ +"""Shared helpers for model-facing skill prompts.""" + + +def format_terminal_skill_context(skill: dict) -> str: + resources = skill.get('resources') if isinstance(skill.get('resources'), list) else [] + parts = [f'', skill.get('content') or ''] + if skill.get('directory'): + parts.append(f'{skill["directory"]}') + if resources: + parts.append('') + parts.extend(f'{resource}' for resource in resources) + parts.append('') + parts.append('') + return '\n'.join(parts) + + +def format_terminal_skill_manifest_entry(skill: dict) -> str: + location = skill.get('location') or skill.get('path') or '' + location_tag = f'{location}\n' if location else '' + return ( + f'\n{skill["id"]}\n{skill["name"]}\n' + f'{skill.get("description") or ""}\n' + f'terminal\n' + f'{location_tag}' + f'\n' + ) diff --git a/backend/open_webui/utils/terminals.py b/backend/open_webui/utils/terminals.py index 506c15bff9..eef46bc470 100644 --- a/backend/open_webui/utils/terminals.py +++ b/backend/open_webui/utils/terminals.py @@ -111,3 +111,98 @@ def terminal_chat_uploads(connection: dict) -> str: """Return normalized main-chat upload behavior for this connection.""" value = (connection.get('config') or {}).get('chat_uploads') return value if value in TERMINAL_CHAT_UPLOAD_MODES else 'default' + + +async def get_terminal_request_info(request, user, metadata: dict, extra_params: dict | None = None): + from open_webui.models.config import Config + from open_webui.models.groups import Groups + from open_webui.models.users import UserModel + from open_webui.utils.access_control import has_connection_access + from open_webui.utils.headers import bearer_auth_header + from open_webui.utils.tools import build_tool_server_headers + + metadata = metadata or {} + terminal_id = metadata.get('terminal_id') + if not terminal_id: + return None + + user_model = user if isinstance(user, UserModel) else UserModel(**user) + connections = await Config.get('terminal_server.connections', []) or [] + connection = next((item for item in connections if item.get('id') == terminal_id), None) + + if connection: + if not connection.get('enabled', True): + return None + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_model.id)} + if not await has_connection_access(user_model, connection, user_group_ids): + return None + + headers, cookies = await build_tool_server_headers( + connection, + request, + user_model, + metadata=metadata, + extra_params=extra_params, + ) + headers['Accept'] = 'application/json' + headers['X-User-Id'] = user_model.id + if metadata.get('chat_id'): + headers['X-Session-Id'] = metadata['chat_id'] + terminal_context = 'automation' if metadata.get('automation_id') else 'chat' + context_id = terminal_context_id(connection, metadata, terminal_context) + if context_id: + headers[TERMINAL_CONTEXT_HEADER] = context_id + return get_terminal_server_url(connection), headers, cookies + + selector = str(terminal_id).rstrip('/') + direct_terminal = next( + (server for server in metadata.get('tool_servers') or [] if str(server.get('url') or '').rstrip('/') == selector), + None, + ) + if not direct_terminal: + return None + + headers = {'Accept': 'application/json'} + key = str(direct_terminal.get('key') or '').strip() + if key: + headers.update(bearer_auth_header(key)) + if metadata.get('chat_id'): + headers['X-Session-Id'] = metadata['chat_id'] + return selector, headers, {} + + +async def get_terminal_skill(request, user, metadata: dict, skill_name: str, extra_params: dict | None = None) -> dict | None: + import aiohttp + from urllib.parse import quote + + from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA + + terminal_request = await get_terminal_request_info(request, user, metadata, extra_params) + if not terminal_request: + return None + base_url, headers, cookies = terminal_request + + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA) + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + async with session.get( + f'{base_url.rstrip("/")}/skills/{quote(skill_name, safe="")}', + headers=headers, + cookies=cookies, + ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, + ) as response: + skill = await response.json() if response.status == 200 else None + + if not isinstance(skill, dict): + return None + + location = skill.get('location') or skill.get('path') or '' + directory = location.rsplit('/', 1)[0] if '/' in location else location + resources = skill.get('resources') if isinstance(skill.get('resources'), list) else [] + return { + 'name': skill.get('name'), + 'description': skill.get('description'), + 'content': skill.get('content'), + 'directory': directory, + 'resources': resources, + } + diff --git a/src/lib/apis/terminal/index.ts b/src/lib/apis/terminal/index.ts index 9a95b2f915..2bdbfeab21 100644 --- a/src/lib/apis/terminal/index.ts +++ b/src/lib/apis/terminal/index.ts @@ -60,6 +60,18 @@ export type TerminalCwd = { root?: TerminalFileRoot; }; +export type TerminalSkill = { + id: string; + name: string; + description: string; + is_active: boolean; + source: 'terminal'; + terminal_path: string; + terminal_scope: 'global'; + terminal_selector: string; + terminal_name?: string; +}; + import { WEBUI_API_BASE_URL } from '$lib/constants'; export type TerminalConnection = { @@ -123,7 +135,7 @@ const bearerHeaders = (apiKey: string): Record => ({ Authorization: `Bearer ${apiKey.trim()}` }); -const joinTerminalPath = (base: string, child: string) => { +export const joinTerminalPath = (base: string, child: string) => { if (!child) return base; if (child.startsWith('/') || /^[A-Za-z]:[\\/]/.test(child)) return child; return `${base.replace(/[\\/]+$/, '')}/${child.replace(/^[\\/]+/, '')}`; @@ -330,6 +342,29 @@ export const readFile = async ( return json?.content ?? null; }; +export const listTerminalSkills = async ( + connection: TerminalConnection | null, + chatId?: string | null +): Promise => { + if (!connection) return []; + + const skills = await terminalRequest( + connection, + chatId ?? null, + '/skills' + ).catch(() => []); + + return (Array.isArray(skills) ? skills : []).map((skill) => ({ + ...skill, + is_active: true, + source: 'terminal', + terminal_path: skill.terminal_path ?? (skill as any).location ?? '', + terminal_scope: skill.terminal_scope ?? (skill as any).scope ?? 'global', + terminal_selector: connection.selector, + terminal_name: connection.selector + })); +}; + export const downloadFileBlob = async ( baseUrl: string, apiKey: string, diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 14cf24f4c1..2a5bb50da3 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -36,6 +36,7 @@ artifactContents, tools, skills, + terminalSkills, toolServers, terminalServers, functions, @@ -981,6 +982,13 @@ selectedTerminalId.set(null); } + let lastTerminalSkillSelector: string | null = null; + $: if ($selectedTerminalId !== lastTerminalSkillSelector) { + selectedSkillIds = selectedSkillIds.filter((id) => !id.startsWith('terminal:')); + terminalSkills.set([]); + lastTerminalSkillSelector = $selectedTerminalId; + } + let settingDefaults = false; const setDefaults = async () => { if (settingDefaults) return; @@ -2752,11 +2760,15 @@ const chatCompletionEventHandler = async (data, message, chatId) => { const { id, done, choices, content, output, sources, selected_model_id, error, usage } = data; - // Store raw OR-aligned output items from backend + // Store raw OR-aligned output items from backend if (output) { message.output = output; message.content = getOutputText(output); - if (data.type === 'response.output_text.delta' && navigator.vibrate && $settings?.hapticFeedback) { + if ( + data.type === 'response.output_text.delta' && + navigator.vibrate && + $settings?.hapticFeedback + ) { navigator.vibrate(5); } dispatchCallOverlayAudio(message); @@ -3565,11 +3577,7 @@ filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined, tool_ids: toolIds.length > 0 ? toolIds : undefined, skill_ids: skillIds.length > 0 ? skillIds : undefined, - terminal_id: - terminalEnabled && - ($terminalServers ?? []).some((t) => t.id && t.id === $selectedTerminalId) - ? $selectedTerminalId - : undefined, + terminal_id: terminalEnabled && $selectedTerminalId ? $selectedTerminalId : undefined, tool_servers: [ ...($toolServers ?? []).filter( (server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id) diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 57c144fdb3..c9f29fd2f6 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -32,6 +32,7 @@ showCallOverlay, tools, skills, + terminalSkills, toolServers, terminalServers, user as _user, @@ -803,7 +804,9 @@ $: showToolsButton = ($tools ?? []).length > 0 || ($toolServers ?? []).length > 0; let showSkillsButton = false; - $: showSkillsButton = ($skills ?? []).some((skill) => skill.is_active); + $: showSkillsButton = + ($skills ?? []).some((skill) => skill.is_active) || + ($terminalSkills ?? []).some((skill) => skill.is_active); let showWebSearchButton = false; $: showWebSearchButton = diff --git a/src/lib/components/chat/MessageInput/Commands/Skills.svelte b/src/lib/components/chat/MessageInput/Commands/Skills.svelte index 8a5d50076d..83ab12979d 100644 --- a/src/lib/components/chat/MessageInput/Commands/Skills.svelte +++ b/src/lib/components/chat/MessageInput/Commands/Skills.svelte @@ -2,6 +2,18 @@ import { resolveLocalizedResource } from '$lib/utils/localizedContent'; import { getContext, onDestroy } from 'svelte'; import { getSkillItems } from '$lib/apis/skills'; + import { + listTerminalSkills, + resolveTerminalConnection, + type TerminalSkill + } from '$lib/apis/terminal'; + import { + chatId, + selectedTerminalId, + settings, + terminalServers, + terminalSkills + } from '$lib/stores'; import Tooltip from '$lib/components/common/Tooltip.svelte'; import Cube from '$lib/components/icons/Cube.svelte'; @@ -27,10 +39,26 @@ }); const getItems = async () => { - const res = await getSkillItems(localStorage.token, query).catch(() => null); - if (res) { - filteredItems = res.items; - } + const [res, terminalItems] = await Promise.all([ + getSkillItems(localStorage.token, query).catch(() => null), + getTerminalItems(query) + ]); + filteredItems = [...(res?.items ?? []), ...terminalItems]; + }; + + const getTerminalItems = async (query = ''): Promise => { + const connection = resolveTerminalConnection( + $selectedTerminalId, + $terminalServers ?? [], + $settings?.terminalServers ?? [], + localStorage.token + ); + const items = await listTerminalSkills(connection, $chatId || null).catch(() => []); + terminalSkills.set(items); + const q = query.trim().toLowerCase(); + return q + ? items.filter((skill) => `${skill.name} ${skill.description}`.toLowerCase().includes(q)) + : items; }; $: if (query) { @@ -106,7 +134,7 @@ {resolveLocalizedResource(skill, $i18n.language)}
- {skill.id} + {skill.source === 'terminal' ? $i18n.t('Terminal') : skill.id}
diff --git a/src/lib/components/chat/MessageInput/Commands/SlashCommands.svelte b/src/lib/components/chat/MessageInput/Commands/SlashCommands.svelte index e2e7303484..6f0e2f964d 100644 --- a/src/lib/components/chat/MessageInput/Commands/SlashCommands.svelte +++ b/src/lib/components/chat/MessageInput/Commands/SlashCommands.svelte @@ -3,6 +3,18 @@ import { getContext, onDestroy } from 'svelte'; import { getPrompts } from '$lib/apis/prompts'; import { getSkillItems } from '$lib/apis/skills'; + import { + listTerminalSkills, + resolveTerminalConnection, + type TerminalSkill + } from '$lib/apis/terminal'; + import { + chatId, + selectedTerminalId, + settings, + terminalServers, + terminalSkills + } from '$lib/stores'; import Tooltip from '$lib/components/common/Tooltip.svelte'; import ChatBubbleDotted from '$lib/components/icons/ChatBubbleDotted.svelte'; import ChatBubbleDottedChecked from '$lib/components/icons/ChatBubbleDottedChecked.svelte'; @@ -84,17 +96,33 @@ clearTimeout(searchDebounceTimer); }); + const getTerminalItems = async (query = ''): Promise => { + const connection = resolveTerminalConnection( + $selectedTerminalId, + $terminalServers ?? [], + $settings?.terminalServers ?? [], + localStorage.token + ); + const items = await listTerminalSkills(connection, $chatId || null).catch(() => []); + terminalSkills.set(items); + const q = query.trim().toLowerCase(); + return q + ? items.filter((skill) => `${skill.name} ${skill.description}`.toLowerCase().includes(q)) + : items; + }; + const getItems = async () => { - const [promptRes, skillRes] = await Promise.all([ + const [promptRes, skillRes, terminalItems] = await Promise.all([ getPrompts(localStorage.token).catch(() => null), - getSkillItems(localStorage.token, query).catch(() => null) + getSkillItems(localStorage.token, query).catch(() => null), + getTerminalItems(query) ]); if (promptRes) { prompts = promptRes; } - skills = skillRes?.items ?? []; + skills = [...(skillRes?.items ?? []), ...terminalItems]; }; export const selectUp = () => { @@ -447,7 +475,7 @@ {resolveLocalizedResource(skill, $i18n.language)}
- {skill.id} + {skill.source === 'terminal' ? $i18n.t('Terminal') : skill.id}
diff --git a/src/lib/components/chat/MessageInput/IntegrationsMenu.svelte b/src/lib/components/chat/MessageInput/IntegrationsMenu.svelte index 920f40cec6..ad2a3109ac 100644 --- a/src/lib/components/chat/MessageInput/IntegrationsMenu.svelte +++ b/src/lib/components/chat/MessageInput/IntegrationsMenu.svelte @@ -4,11 +4,26 @@ import { getContext, onDestroy, tick } from 'svelte'; import { fly } from 'svelte/transition'; - import { user, tools as _tools, skills as _skills, toolServers } from '$lib/stores'; + import { + chatId, + selectedTerminalId, + settings, + terminalServers, + terminalSkills, + user, + tools as _tools, + skills as _skills, + toolServers + } from '$lib/stores'; import { deleteOAuthSession } from '$lib/apis/auths'; import { getTools } from '$lib/apis/tools'; import { getSkills } from '$lib/apis/skills'; + import { + listTerminalSkills, + resolveTerminalConnection, + type TerminalSkill + } from '$lib/apis/terminal'; import { toast } from 'svelte-sonner'; @@ -174,6 +189,18 @@ } }; + const getTerminalSkillItems = async (): Promise => { + const connection = resolveTerminalConnection( + $selectedTerminalId, + $terminalServers ?? [], + $settings?.terminalServers ?? [], + localStorage.token + ); + const items = await listTerminalSkills(connection, $chatId || null).catch(() => []); + terminalSkills.set(items); + return items; + }; + const loadTools = async (query = toolQuery) => { const requestId = ++toolRequestId; const q = query.trim(); @@ -195,7 +222,9 @@ await _skills.set(await getSkills(localStorage.token)); } if (requestId !== skillRequestId) return; - setSkills($_skills, q); + const terminalItems = await getTerminalSkillItems(); + if (requestId !== skillRequestId) return; + setSkills([...($_skills ?? []), ...terminalItems], q); }; const scheduleToolSearch = () => { @@ -252,6 +281,9 @@ } }; + const skillSourceLabel = (skill: IntegrationItem | undefined) => + skill?.source === 'terminal' ? $i18n.t('Terminal') : ''; + onDestroy(() => { clearTimeout(toolSearchDebounceTimer); clearTimeout(skillSearchDebounceTimer); @@ -680,6 +712,11 @@ {resolveLocalizedResource(skills?.[skillId], $i18n.language, 'name')} + {#if skillSourceLabel(skills?.[skillId])} +
+ {skillSourceLabel(skills?.[skillId])} +
+ {/if} diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte index e90b04d641..e085dcc9d2 100644 --- a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte +++ b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte @@ -9,7 +9,7 @@ const i18n = getContext('i18n'); import { WEBUI_BASE_URL } from '$lib/constants'; - import { skills } from '$lib/stores'; + import { skills, terminalSkills } from '$lib/stores'; import { copyToClipboard, safeLinkUrl, unescapeHtml } from '$lib/utils'; import Image from '$lib/components/common/Image.svelte'; @@ -129,7 +129,7 @@ }} > {:else if token.type === 'mention'} - {#if token.triggerChar === '$' && !$skills?.some((skill) => skill.id === token.id && skill.is_active)} + {#if token.triggerChar === '$' && ![...($skills ?? []), ...($terminalSkills ?? [])].some((skill) => skill.id === token.id && skill.is_active)} {token.raw} {:else} diff --git a/src/lib/components/chat/SkillsModal.svelte b/src/lib/components/chat/SkillsModal.svelte index 499fd4d013..8ad2572234 100644 --- a/src/lib/components/chat/SkillsModal.svelte +++ b/src/lib/components/chat/SkillsModal.svelte @@ -1,7 +1,7 @@ @@ -51,6 +53,9 @@ {resolveLocalizedResource(skill, $i18n.language, 'description')} {/if} + {#if skill.source === 'terminal'} +
{$i18n.t('Terminal')}
+ {/if} {/each} diff --git a/src/lib/components/common/RichTextInput.svelte b/src/lib/components/common/RichTextInput.svelte index 930994decc..5e4f5b1d90 100644 --- a/src/lib/components/common/RichTextInput.svelte +++ b/src/lib/components/common/RichTextInput.svelte @@ -2,7 +2,7 @@ import { marked } from 'marked'; import DOMPurify from 'dompurify'; import equal from 'fast-deep-equal'; - import { skills } from '$lib/stores'; + import { skills, terminalSkills } from '$lib/stores'; marked.use({ breaks: true, @@ -528,7 +528,9 @@ const mentionId = id || slashSkillId; if ( mentionChar === '$' && - !$skills?.some((skill) => skill.id === mentionId && skill.is_active) + ![...($skills ?? []), ...($terminalSkills ?? [])].some( + (skill) => skill.id === mentionId && skill.is_active + ) ) { return match; } diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts index 98a63429c0..7e0651994f 100644 --- a/src/lib/stores/index.ts +++ b/src/lib/stores/index.ts @@ -70,6 +70,7 @@ export const models: Writable = writable([]); export const knowledge: Writable = writable(null); export const tools = writable(null); export const skills: Writable = writable(null); +export const terminalSkills: Writable = writable([]); export const functions = writable(null); export type WorkspaceSection = 'models' | 'knowledge' | 'prompts' | 'skills' | 'tools'; diff --git a/src/lib/utils/marked/mention-extension.ts b/src/lib/utils/marked/mention-extension.ts index efbb74fdf0..ab0d0ae3f9 100644 --- a/src/lib/utils/marked/mention-extension.ts +++ b/src/lib/utils/marked/mention-extension.ts @@ -1,6 +1,6 @@ // mention-extension.ts import { get } from 'svelte/store'; -import { skills } from '$lib/stores'; +import { skills, terminalSkills } from '$lib/stores'; type MentionOptions = { triggerChar?: string; // default "@" @@ -23,7 +23,12 @@ function mentionStart(src: string) { function mentionRenderer(token: any, options: MentionOptions = {}) { const trigger = options.triggerChar ?? '@'; - if (trigger === '$' && !get(skills)?.some((skill) => skill.id === token.id && skill.is_active)) { + if ( + trigger === '$' && + ![...(get(skills) ?? []), ...(get(terminalSkills) ?? [])].some( + (skill) => skill.id === token.id && skill.is_active + ) + ) { return escapeHtml(token.raw); } const cls = options.className ?? 'mention';