feat: $ skills mention

This commit is contained in:
Timothy Jaeryang Baek
2026-02-11 15:08:59 -06:00
parent 46aa54b7dc
commit f7c5965a70
8 changed files with 228 additions and 4 deletions
+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') {
@@ -0,0 +1,20 @@
<script lang="ts">
export let className = 'size-4';
export let strokeWidth = '1.5';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class={className}
>
<path d="M16 5H19M22 5H19M19 5V2M19 5V8" />
<path
d="M16.8189 14.3287L11.4948 20.3183C10.6992 21.2134 9.30076 21.2134 8.50518 20.3183L3.18109 14.3287C2.50752 13.571 2.50752 12.429 3.18109 11.6713L8.50518 5.68167C9.30076 4.78664 10.6992 4.78664 11.4948 5.68167L16.8189 11.6713C17.4925 12.429 17.4925 13.571 16.8189 14.3287Z"
/>
</svg>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
export let className = 'size-4';
export let strokeWidth = '1.5';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class={className}
>
<path
d="M13.8476 13.317L9.50515 18.2798C8.70833 19.1905 7.29167 19.1905 6.49485 18.2798L2.15238 13.317C1.49259 12.563 1.49259 11.437 2.15238 10.683L6.49485 5.72018C7.29167 4.80952 8.70833 4.80952 9.50515 5.72017L13.8476 10.683C14.5074 11.437 14.5074 12.563 13.8476 13.317Z"
/>
<path d="M13 19L17.8844 13.3016C18.5263 12.5526 18.5263 11.4474 17.8844 10.6984L13 5" />
<path d="M17 19L21.8844 13.3016C22.5263 12.5526 22.5263 11.4474 21.8844 10.6984L17 5" />
</svg>