feat: $ skills mention

This commit is contained in:
Timothy Jaeryang Baek committed 2026-02-11 15:08:59 -06:00
1 parent 46aa54b7dc
commit f7c5965a70
8 files changed
+228 -4

No files matched your search

+35
View File
@@ -2023,6 +2023,40 @@
}
}
// Parse skill mentions (<$skillId|label>) from user messages
const skillMentionRegex = /<\$([^|>]+)\|?[^>]*>/g;
const skillIds = [];
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 (!skillIds.includes(match[1])) {
skillIds.push(match[1]);
}
}
}
// Strip skill mentions from message content
if (skillIds.length > 0) {
messages = messages.map((message) => {
if (typeof message.content === 'string') {
return {
...message,
content: message.content.replace(/<\$[^>]+>/g, '').trim()
};
} else if (Array.isArray(message.content)) {
return {
...message,
content: message.content.map((part) =>
part.type === 'text'
? { ...part, text: part.text.replace(/<\$[^>]+>/g, '').trim() }
: part
)
};
}
return message;
});
}
const res = await generateOpenAIChatCompletion(
localStorage.token,
{
@@ -2044,6 +2078,7 @@
filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined,
tool_ids: toolIds.length > 0 ? toolIds : undefined,
skill_ids: skillIds.length > 0 ? skillIds : undefined,
tool_servers: ($toolServers ?? []).filter(
(server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id)
),
+13 -1
View File
@@ -390,7 +390,7 @@
let command = '';
export let showCommands = false;
$: showCommands = ['/', '#', '@'].includes(command?.charAt(0)) || '\\#' === command?.slice(0, 2);
$: showCommands = ['/', '#', '@', '$'].includes(command?.charAt(0)) || '\\#' === command?.slice(0, 2);
let suggestions = null;
let showTools = false;
@@ -946,6 +946,18 @@
}
}
})
},
{
char: '$',
render: getSuggestionRenderer(CommandSuggestionList, {
i18n,
onSelect: (e) => {
document.getElementById('chat-input')?.focus();
},
insertTextHandler: insertTextAtCursor,
onUpload: () => {}
})
}
];
loaded = true;
@@ -7,6 +7,7 @@
import Prompts from './Commands/Prompts.svelte';
import Knowledge from './Commands/Knowledge.svelte';
import Models from './Commands/Models.svelte';
import Skills from './Commands/Skills.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import { onMount } from 'svelte';
@@ -138,6 +139,27 @@
}
}}
/>
{:else if char === '$'}
<Skills
bind:this={suggestionElement}
{query}
bind:filteredItems
onSelect={(e) => {
const { type, data } = e;
if (type === 'skill') {
command({
id: `${data.id}|${data.name}`,
label: data.name
});
onSelect({
type: 'skill',
data: data
});
}
}}
/>
{/if}
{:else}
<div class="py-4 flex flex-col w-full rounded-xl text-gray-700 dark:text-gray-300">
@@ -0,0 +1,100 @@
<script lang="ts">
import Fuse from 'fuse.js';
import { getContext } from 'svelte';
import { skills } from '$lib/stores';
import { getSkillList } from '$lib/apis/skills';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Keyframes from '$lib/components/icons/Keyframes.svelte';
const i18n = getContext('i18n');
export let query = '';
export let onSelect = (e) => {};
let selectedIdx = 0;
export let filteredItems = [];
let _skills = [];
const loadSkills = async () => {
if ($skills) {
_skills = $skills;
} else {
_skills = await getSkillList(localStorage.token);
skills.set(_skills);
}
};
loadSkills();
$: fuse = new Fuse(
(_skills ?? []).filter((s) => s.enabled !== false),
{
keys: ['name', 'id', 'meta.description'],
threshold: 0.5
}
);
$: filteredItems = query
? fuse.search(query).map((e) => e.item)
: (_skills ?? []).filter((s) => s.enabled !== false);
$: if (query) {
selectedIdx = 0;
}
export const selectUp = () => {
selectedIdx = Math.max(0, selectedIdx - 1);
};
export const selectDown = () => {
selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1);
};
export const select = async () => {
const skill = filteredItems[selectedIdx];
if (skill) {
onSelect({ type: 'skill', data: skill });
}
};
</script>
<div class="px-2 text-xs text-gray-500 py-1">
{$i18n.t('Skills')}
</div>
{#if filteredItems.length > 0}
{#each filteredItems as skill, skillIdx}
<Tooltip content={skill.id} placement="top-start">
<button
class="px-2.5 py-1.5 rounded-xl w-full text-left {skillIdx === selectedIdx
? 'bg-gray-50 dark:bg-gray-800 selected-command-option-button'
: ''}"
type="button"
on:click={() => {
onSelect({ type: 'skill', data: skill });
}}
on:mousemove={() => {
selectedIdx = skillIdx;
}}
on:focus={() => {}}
data-selected={skillIdx === selectedIdx}
>
<div class="flex text-black dark:text-gray-100 line-clamp-1">
<div class="flex items-center justify-center size-5 mr-2 shrink-0">
<Keyframes className="size-4" />
</div>
<div class="truncate">
{skill.name}
</div>
{#if skill.meta?.description}
<div class="ml-2 text-xs text-gray-500 truncate">
{skill.meta.description}
</div>
{/if}
</div>
</button>
</Tooltip>
{/each}
{/if}
@@ -46,7 +46,7 @@
marked.use(footnoteExtension(options));
marked.use(disableSingleTilde);
marked.use({
extensions: [mentionExtension({ triggerChar: '@' }), mentionExtension({ triggerChar: '#' })]
extensions: [mentionExtension({ triggerChar: '@' }), mentionExtension({ triggerChar: '#' }), mentionExtension({ triggerChar: '$' })]
});
$: (async () => {
@@ -25,8 +25,23 @@
const init = () => {
const _id = token?.id;
// split by : and take first part as idType and second part as id
triggerChar = token?.triggerChar ?? '@';
if (triggerChar === '$') {
// Skill mention: id format is "skillId|label"
const pipeIdx = _id?.indexOf('|') ?? -1;
if (pipeIdx > 0) {
id = _id.substring(0, pipeIdx);
label = _id.substring(pipeIdx + 1);
} else {
id = _id;
label = _id;
}
idType = null;
return;
}
// split by : and take first part as idType and second part as id
const parts = _id?.split(':');
if (parts) {
idType = parts[0];
@@ -37,7 +52,6 @@
}
label = token?.label ?? id;
triggerChar = token?.triggerChar ?? '@';
if (triggerChar === '#') {
if (idType === 'C') {