mirror of
https://github.com/open-webui/open-webui.git
synced 2026-06-13 19:20:05 +00:00
feat: add skills management to chat component (#25037)
- 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:
@@ -38,6 +38,7 @@
|
||||
showArtifacts,
|
||||
artifactContents,
|
||||
tools,
|
||||
skills,
|
||||
toolServers,
|
||||
terminalServers,
|
||||
functions,
|
||||
@@ -92,6 +93,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';
|
||||
@@ -150,6 +152,7 @@
|
||||
}
|
||||
|
||||
let selectedToolIds = [];
|
||||
let selectedSkillIds = [];
|
||||
let selectedFilterIds = [];
|
||||
let pendingOAuthTools = [];
|
||||
|
||||
@@ -208,6 +211,7 @@
|
||||
|
||||
files = [];
|
||||
selectedToolIds = [];
|
||||
selectedSkillIds = [];
|
||||
selectedFilterIds = [];
|
||||
webSearchEnabled = false;
|
||||
imageGenerationEnabled = false;
|
||||
@@ -243,6 +247,7 @@
|
||||
messageInput?.setText(input.prompt);
|
||||
files = input.files;
|
||||
selectedToolIds = input.selectedToolIds;
|
||||
selectedSkillIds = input.selectedSkillIds ?? [];
|
||||
selectedFilterIds = input.selectedFilterIds;
|
||||
webSearchEnabled = input.webSearchEnabled;
|
||||
imageGenerationEnabled = input.imageGenerationEnabled;
|
||||
@@ -303,6 +308,7 @@
|
||||
|
||||
const resetInput = async () => {
|
||||
selectedToolIds = [];
|
||||
selectedSkillIds = [];
|
||||
selectedFilterIds = [];
|
||||
pendingOAuthTools = [];
|
||||
webSearchEnabled = false;
|
||||
@@ -329,6 +335,9 @@
|
||||
if (!$functions) {
|
||||
functions.set(await getFunctions(localStorage.token));
|
||||
}
|
||||
if (!$skills) {
|
||||
skills.set(await getSkills(localStorage.token));
|
||||
}
|
||||
if (selectedModels.length !== 1 && !atSelectedModel) {
|
||||
return;
|
||||
}
|
||||
@@ -366,6 +375,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) =>
|
||||
@@ -830,6 +852,7 @@
|
||||
|
||||
files = [];
|
||||
selectedToolIds = [];
|
||||
selectedSkillIds = [];
|
||||
selectedFilterIds = [];
|
||||
webSearchEnabled = false;
|
||||
imageGenerationEnabled = false;
|
||||
@@ -842,6 +865,7 @@
|
||||
messageInput?.setText(input.prompt);
|
||||
files = input.files;
|
||||
selectedToolIds = input.selectedToolIds;
|
||||
selectedSkillIds = input.selectedSkillIds ?? [];
|
||||
selectedFilterIds = input.selectedFilterIds;
|
||||
webSearchEnabled = input.webSearchEnabled;
|
||||
imageGenerationEnabled = input.imageGenerationEnabled;
|
||||
@@ -2365,11 +2389,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]);
|
||||
}
|
||||
@@ -2377,7 +2405,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 {
|
||||
@@ -3109,6 +3137,7 @@
|
||||
bind:prompt
|
||||
bind:autoScroll
|
||||
bind:selectedToolIds
|
||||
bind:selectedSkillIds
|
||||
bind:selectedFilterIds
|
||||
bind:imageGenerationEnabled
|
||||
bind:codeInterpreterEnabled
|
||||
@@ -3190,6 +3219,7 @@
|
||||
bind:prompt
|
||||
bind:autoScroll
|
||||
bind:selectedToolIds
|
||||
bind:selectedSkillIds
|
||||
bind:selectedFilterIds
|
||||
bind:imageGenerationEnabled
|
||||
bind:codeInterpreterEnabled
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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": "",
|
||||
|
||||
Reference in New Issue
Block a user