This commit is contained in:
Timothy Jaeryang Baek 2026-09-13 23:33:22 -04:00
parent 55b7343be8
commit e69236bccb
15 changed files with 386 additions and 40 deletions

View file

@ -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

View file

@ -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'<description>{skill.description or ""}</description>\n</skill>\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'<available_skills>\n{skill_manifest}</available_skills>',
@ -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,

View file

@ -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 name="{skill.get("name") or ""}">', skill.get('content') or '']
if skill.get('directory'):
parts.append(f'<directory>{skill["directory"]}</directory>')
if resources:
parts.append('<resources>')
parts.extend(f'<file>{resource}</file>' for resource in resources)
parts.append('</resources>')
parts.append('</skill>')
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>{location}</location>\n' if location else ''
return (
f'<skill>\n<id>{skill["id"]}</id>\n<name>{skill["name"]}</name>\n'
f'<description>{skill.get("description") or ""}</description>\n'
f'<source>terminal</source>\n'
f'{location_tag}'
f'</skill>\n'
)

View file

@ -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,
}

View file

@ -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<string, string> => ({
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<TerminalSkill[]> => {
if (!connection) return [];
const skills = await terminalRequest<TerminalSkill[]>(
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,

View file

@ -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)

View file

@ -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 =

View file

@ -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<TerminalSkill[]> => {
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)}
</div>
<div class="ml-2 max-w-24 shrink-0 truncate text-xs text-gray-500 dark:text-gray-400">
{skill.id}
{skill.source === 'terminal' ? $i18n.t('Terminal') : skill.id}
</div>
</div>
</button>

View file

@ -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<TerminalSkill[]> => {
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)}
</div>
<div class="ml-2 max-w-24 shrink-0 truncate text-xs text-gray-500 dark:text-gray-400">
{skill.id}
{skill.source === 'terminal' ? $i18n.t('Terminal') : skill.id}
</div>
</div>
</button>

View file

@ -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<TerminalSkill[]> => {
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')}
</div>
</Tooltip>
{#if skillSourceLabel(skills?.[skillId])}
<div class="shrink-0 text-[0.6875rem] text-gray-500 dark:text-gray-400">
{skillSourceLabel(skills?.[skillId])}
</div>
{/if}
</div>
</div>

View file

@ -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 @@
}}
></iframe>
{: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}
<MentionToken {token} />

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
import { getContext } from 'svelte';
import { skills } from '$lib/stores';
import { skills, terminalSkills } from '$lib/stores';
import Modal from '../common/Modal.svelte';
import Collapsible from '../common/Collapsible.svelte';
@ -12,7 +12,9 @@
let selectedSkills = [];
$: selectedSkills = ($skills ?? []).filter((skill) => selectedSkillIds.includes(skill.id));
$: selectedSkills = [...($skills ?? []), ...($terminalSkills ?? [])].filter((skill) =>
selectedSkillIds.includes(skill.id)
);
const i18n = getContext('i18n');
</script>
@ -51,6 +53,9 @@
{resolveLocalizedResource(skill, $i18n.language, 'description')}
</div>
{/if}
{#if skill.source === 'terminal'}
<div class="text-xs text-gray-500">{$i18n.t('Terminal')}</div>
{/if}
</div>
</Collapsible>
{/each}

View file

@ -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;
}

View file

@ -70,6 +70,7 @@ export const models: Writable<Model[]> = writable([]);
export const knowledge: Writable<null | Document[]> = writable(null);
export const tools = writable(null);
export const skills: Writable<null | any[]> = writable(null);
export const terminalSkills: Writable<any[]> = writable([]);
export const functions = writable(null);
export type WorkspaceSection = 'models' | 'knowledge' | 'prompts' | 'skills' | 'tools';

View file

@ -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';