feat: add skills management to chat component

- Introduced skills functionality in Chat.svelte, MessageInput.svelte, and related components.
- Added SkillsModal for displaying and managing available skills.
- Updated state management to include selectedSkillIds and integrate skills API.
- Enhanced UI to show available skills and their descriptions.
- Updated translations to support skills-related text.
This commit is contained in:
Mr. Meowgi 2026-05-24 07:27:26 +03:00
parent 359590ca9d
commit 7b4fab60ce
6 changed files with 233 additions and 3 deletions

View file

@ -38,6 +38,7 @@
showArtifacts,
artifactContents,
tools,
skills,
toolServers,
terminalServers,
functions,
@ -91,6 +92,7 @@
getTaskIdsByChatId
} from '$lib/apis';
import { getTools } from '$lib/apis/tools';
import { getSkills } from '$lib/apis/skills';
import { uploadFile } from '$lib/apis/files';
import { createOpenAITextStream } from '$lib/apis/streaming';
import { getFunctions } from '$lib/apis/functions';
@ -149,6 +151,7 @@
}
let selectedToolIds = [];
let selectedSkillIds = [];
let selectedFilterIds = [];
let pendingOAuthTools = [];
@ -206,6 +209,7 @@
files = [];
selectedToolIds = [];
selectedSkillIds = [];
selectedFilterIds = [];
webSearchEnabled = false;
imageGenerationEnabled = false;
@ -241,6 +245,7 @@
messageInput?.setText(input.prompt);
files = input.files;
selectedToolIds = input.selectedToolIds;
selectedSkillIds = input.selectedSkillIds ?? [];
selectedFilterIds = input.selectedFilterIds;
webSearchEnabled = input.webSearchEnabled;
imageGenerationEnabled = input.imageGenerationEnabled;
@ -301,6 +306,7 @@
const resetInput = async () => {
selectedToolIds = [];
selectedSkillIds = [];
selectedFilterIds = [];
pendingOAuthTools = [];
webSearchEnabled = false;
@ -327,6 +333,9 @@
if (!$functions) {
functions.set(await getFunctions(localStorage.token));
}
if (!$skills) {
skills.set(await getSkills(localStorage.token));
}
if (selectedModels.length !== 1 && !atSelectedModel) {
return;
}
@ -364,6 +373,19 @@
selectedToolIds = selectedToolIds.filter((id) => !id.startsWith('direct_server:'));
}
// Set Default Skills
if (model?.info?.meta?.skillIds) {
selectedSkillIds = [
...new Set(
[...(model?.info?.meta?.skillIds ?? [])].filter((id) =>
($skills ?? []).find((s) => s.id === id && s.is_active)
)
)
];
} else {
selectedSkillIds = [];
}
// Set Default Filters (Toggleable only)
if (model?.info?.meta?.defaultFilterIds) {
selectedFilterIds = model.info.meta.defaultFilterIds.filter((id) =>
@ -808,6 +830,7 @@
files = [];
selectedToolIds = [];
selectedSkillIds = [];
selectedFilterIds = [];
webSearchEnabled = false;
imageGenerationEnabled = false;
@ -820,6 +843,7 @@
messageInput?.setText(input.prompt);
files = input.files;
selectedToolIds = input.selectedToolIds;
selectedSkillIds = input.selectedSkillIds ?? [];
selectedFilterIds = input.selectedFilterIds;
webSearchEnabled = input.webSearchEnabled;
imageGenerationEnabled = input.imageGenerationEnabled;
@ -2361,11 +2385,15 @@
// Parse skill mentions (<$skillId|label>) from user messages
const skillMentionRegex = /<\$([^|>]+)\|?[^>]*>/g;
const skillIds = [];
const skillIds = [...selectedSkillIds];
const mentionSkillIds = [];
for (const message of messages) {
const content =
typeof message.content === 'string' ? message.content : (message.content?.[0]?.text ?? '');
for (const match of content.matchAll(skillMentionRegex)) {
if (!mentionSkillIds.includes(match[1])) {
mentionSkillIds.push(match[1]);
}
if (!skillIds.includes(match[1])) {
skillIds.push(match[1]);
}
@ -2373,7 +2401,7 @@
}
// Strip skill mentions from message content
if (skillIds.length > 0) {
if (mentionSkillIds.length > 0) {
messages = messages.map((message) => {
if (typeof message.content === 'string') {
return {
@ -3102,6 +3130,7 @@
bind:prompt
bind:autoScroll
bind:selectedToolIds
bind:selectedSkillIds
bind:selectedFilterIds
bind:imageGenerationEnabled
bind:codeInterpreterEnabled
@ -3183,6 +3212,7 @@
bind:prompt
bind:autoScroll
bind:selectedToolIds
bind:selectedSkillIds
bind:selectedFilterIds
bind:imageGenerationEnabled
bind:codeInterpreterEnabled

View file

@ -27,6 +27,7 @@
config,
showCallOverlay,
tools,
skills,
toolServers,
terminalServers,
user as _user,
@ -58,6 +59,7 @@
import { getChatById } from '$lib/apis/chats';
import { getSessionUser } from '$lib/apis/auths';
import { getTools } from '$lib/apis/tools';
import { getSkills } from '$lib/apis/skills';
import { WEBUI_BASE_URL, WEBUI_API_BASE_URL, PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
import { getOAuthClientAuthorizationUrl } from '$lib/apis/configs';
@ -69,6 +71,7 @@
import VoiceRecording from './MessageInput/VoiceRecording.svelte';
import ToolServersModal from './ToolServersModal.svelte';
import SkillsModal from './SkillsModal.svelte';
import RichTextInput from '../common/RichTextInput.svelte';
import Tooltip from '../common/Tooltip.svelte';
@ -80,6 +83,7 @@
import GlobeAlt from '../icons/GlobeAlt.svelte';
import Photo from '../icons/Photo.svelte';
import Wrench from '../icons/Wrench.svelte';
import Keyframes from '../icons/Keyframes.svelte';
import Sparkles from '../icons/Sparkles.svelte';
import InputVariablesModal from './MessageInput/InputVariablesModal.svelte';
@ -131,6 +135,7 @@
export let files = [];
export let selectedToolIds = [];
export let selectedSkillIds = [];
export let selectedFilterIds = [];
export let imageGenerationEnabled = false;
@ -176,6 +181,7 @@
};
}),
selectedToolIds,
selectedSkillIds,
selectedFilterIds,
imageGenerationEnabled,
webSearchEnabled,
@ -418,6 +424,7 @@
let suggestions = null;
let showTools = false;
let showSkills = false;
let loaded = false;
let recording = false;
@ -508,6 +515,9 @@
let showToolsButton = false;
$: showToolsButton = ($tools ?? []).length > 0 || ($toolServers ?? []).length > 0;
let showSkillsButton = false;
$: showSkillsButton = ($skills ?? []).some((skill) => skill.is_active);
let showWebSearchButton = false;
$: showWebSearchButton =
(atSelectedModel?.id ? [atSelectedModel.id] : selectedModels).length ===
@ -1098,6 +1108,7 @@
}
tools.set(await getTools(localStorage.token));
skills.set(await getSkills(localStorage.token));
};
initialize();
@ -1120,6 +1131,7 @@
</script>
<ToolServersModal bind:show={showTools} {selectedToolIds} />
<SkillsModal bind:show={showSkills} {selectedSkillIds} />
<InputVariablesModal
bind:show={showInputVariablesModal}
@ -1668,7 +1680,7 @@
</div>
</InputMenu>
{#if showWebSearchButton || showImageGenerationButton || showCodeInterpreterButton || showToolsButton || (toggleFilters && toggleFilters.length > 0)}
{#if showWebSearchButton || showImageGenerationButton || showCodeInterpreterButton || showToolsButton || showSkillsButton || (toggleFilters && toggleFilters.length > 0)}
<div
class="flex self-center w-[1px] h-4 mx-1 bg-gray-200/50 dark:bg-gray-800/50"
/>
@ -1680,6 +1692,7 @@
{showImageGenerationButton}
{showCodeInterpreterButton}
bind:selectedToolIds
bind:selectedSkillIds
bind:selectedFilterIds
bind:webSearchEnabled
bind:imageGenerationEnabled
@ -1751,6 +1764,29 @@
</Tooltip>
{/if}
{#if (selectedSkillIds ?? []).length > 0}
<Tooltip
content={$i18n.t('{{COUNT}} Available Skills', {
COUNT: (selectedSkillIds ?? []).length
})}
>
<button
class="translate-y-[0.5px] px-1 flex gap-1 items-center text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg self-center transition"
aria-label="Available Skills"
type="button"
on:click={() => {
showSkills = !showSkills;
}}
>
<Keyframes className="size-4" strokeWidth="1.75" />
<span class="text-sm">
{(selectedSkillIds ?? []).length}
</span>
</button>
</Tooltip>
{/if}
{#each selectedFilterIds as filterId (filterId)}
{@const filter = toggleFilters.find((f) => f.id === filterId)}
{#if filter}

View file

@ -6,6 +6,7 @@
config,
user,
tools as _tools,
skills as _skills,
mobile,
settings,
toolServers,
@ -15,6 +16,7 @@
import { getOAuthClientAuthorizationUrl } from '$lib/apis/configs';
import { deleteOAuthSession } from '$lib/apis/auths';
import { getTools } from '$lib/apis/tools';
import { getSkills } from '$lib/apis/skills';
import { toast } from 'svelte-sonner';
@ -24,6 +26,7 @@
import Switch from '$lib/components/common/Switch.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Wrench from '$lib/components/icons/Wrench.svelte';
import Keyframes from '$lib/components/icons/Keyframes.svelte';
import Sparkles from '$lib/components/icons/Sparkles.svelte';
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
import Photo from '$lib/components/icons/Photo.svelte';
@ -35,6 +38,7 @@
const i18n = getContext('i18n');
export let selectedToolIds: string[] = [];
export let selectedSkillIds: string[] = [];
export let selectedModels: string[] = [];
export let fileUploadCapableModels: string[] = [];
@ -58,6 +62,7 @@
let tab = '';
let tools = null;
let skills = null;
$: if (show) {
init();
@ -99,6 +104,26 @@
}
selectedToolIds = selectedToolIds.filter((id) => Object.keys(tools).includes(id));
if ($_skills === null) {
await _skills.set(await getSkills(localStorage.token));
}
if ($_skills) {
skills = $_skills
.filter((skill) => skill.is_active)
.reduce((a, skill) => {
a[skill.id] = {
name: skill.name,
description: skill.description,
enabled: selectedSkillIds.includes(skill.id),
...skill
};
return a;
}, {});
}
selectedSkillIds = selectedSkillIds.filter((id) => Object.keys(skills ?? {}).includes(id));
};
</script>
@ -141,6 +166,28 @@
</div>
</button>
{/if}
{#if skills && Object.keys(skills).length > 0}
<button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => {
tab = 'skills';
}}
>
<Keyframes className="size-4" strokeWidth="1.75" />
<div class="flex items-center w-full justify-between">
<div class=" line-clamp-1">
{$i18n.t('Skills')}
<span class="ml-0.5 text-gray-500">{Object.keys(skills).length}</span>
</div>
<div class="text-gray-500">
<ChevronRight />
</div>
</div>
</button>
{/if}
{:else}
<div class="py-4">
<Spinner />
@ -439,6 +486,59 @@
</button>
{/each}
</div>
{:else if tab === 'skills' && skills}
<div in:fly={{ x: 20, duration: 150 }}>
<button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => {
tab = '';
}}
>
<ChevronLeft />
<div class="flex items-center w-full justify-between">
<div>
{$i18n.t('Skills')}
<span class="ml-0.5 text-gray-500">{Object.keys(skills).length}</span>
</div>
</div>
</button>
{#each Object.keys(skills) as skillId}
<button
class="relative flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={async () => {
skills[skillId].enabled = !skills[skillId].enabled;
const state = skills[skillId].enabled;
await tick();
if (state) {
selectedSkillIds = [...selectedSkillIds, skillId];
} else {
selectedSkillIds = selectedSkillIds.filter((id) => id !== skillId);
}
}}
>
<div class="flex-1 truncate">
<div class="flex flex-1 gap-2 items-center">
<Tooltip content={skills[skillId]?.name ?? ''} placement="top">
<div class="shrink-0">
<Keyframes className="size-4" strokeWidth="1.75" />
</div>
</Tooltip>
<Tooltip content={skills[skillId]?.description ?? ''} placement="top-start">
<div class=" truncate">{skills[skillId].name}</div>
</Tooltip>
</div>
</div>
<div class=" shrink-0">
<Switch state={skills[skillId].enabled} />
</div>
</button>
{/each}
</div>
{/if}
</div>
</div>

View file

@ -47,6 +47,7 @@
export let messageInput = null;
export let selectedToolIds = [];
export let selectedSkillIds = [];
export let selectedFilterIds = [];
export let pendingOAuthTools = [];
@ -217,6 +218,7 @@
bind:prompt
bind:autoScroll
bind:selectedToolIds
bind:selectedSkillIds
bind:selectedFilterIds
bind:imageGenerationEnabled
bind:codeInterpreterEnabled

View file

@ -0,0 +1,60 @@
<script lang="ts">
import { getContext } from 'svelte';
import { skills } from '$lib/stores';
import Modal from '../common/Modal.svelte';
import Collapsible from '../common/Collapsible.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let show = false;
export let selectedSkillIds = [];
let selectedSkills = [];
$: selectedSkills = ($skills ?? []).filter((skill) => selectedSkillIds.includes(skill.id));
const i18n = getContext('i18n');
</script>
<Modal bind:show size="md">
<div>
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-0.5">
<div class=" text-lg font-medium self-center">{$i18n.t('Available Skills')}</div>
<button
class="self-center"
aria-label={$i18n.t('Close')}
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
{#if selectedSkills.length > 0}
<div class=" flex justify-between dark:text-gray-300 px-5 pb-1">
<div class=" text-base font-medium self-center">{$i18n.t('Skills')}</div>
</div>
<div class="px-5 pb-5 w-full flex flex-col justify-center">
<div class=" text-sm dark:text-gray-300 mb-1">
{#each selectedSkills as skill}
<Collapsible buttonClassName="w-full mb-0.5">
<div class="truncate">
<div class="text-sm font-medium dark:text-gray-100 text-gray-800 truncate">
{skill?.name}
</div>
{#if skill?.description}
<div class="text-xs text-gray-500">
{skill?.description}
</div>
{/if}
</div>
</Collapsible>
{/each}
</div>
</div>
{/if}
</div>
</Modal>

View file

@ -10,6 +10,7 @@
"[Yesterday at] h:mm A": "",
"{{ models }}": "",
"{{COUNT}} Available Tools": "",
"{{COUNT}} Available Skills": "",
"{{COUNT}} characters": "",
"{{COUNT}} extracted lines": "",
"{{COUNT}} files": "",
@ -240,6 +241,7 @@
"Available list": "",
"Available models": "",
"Available Tools": "",
"Available Skills": "",
"available users": "",
"available!": "",
"Away": "",