diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py
index 8ab00a9185..a07bd9f6d0 100644
--- a/backend/open_webui/utils/middleware.py
+++ b/backend/open_webui/utils/middleware.py
@@ -2240,7 +2240,7 @@ def sanitize_tool_pairs(messages: list[dict]) -> list[dict]:
return sanitized
-# Ids are validated as [a-z0-9_-]+ on create; matching that keeps ordinary "<$..." text intact.
+# Match candidate mentions using the same ID characters allowed on skill creation.
SKILL_MENTION_RE = re.compile(r'<(?:\$([a-z0-9_-]+)(?:\|[^>]*)?|/([a-z0-9_-]+)\|[^>]*)>')
@@ -2263,25 +2263,27 @@ 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(r'<(?:\$([a-z0-9_-]+)(?:\|([^>]*))?|/([a-z0-9_-]+)\|([^>]*))>')
-def strip_skill_mentions(messages: list[dict]) -> None:
- """Replace <$skillId|label> and mention tags with the label in-place."""
+def strip_skill_mentions(messages: list[dict], skill_ids: set[str]) -> None:
+ """Replace mentions of resolved skills with their label, preserving all other text."""
def label(match):
- return match.group(1) or match.group(2) or ''
+ if (match.group(1) or match.group(3)) not in skill_ids:
+ return match.group(0)
+ return match.group(2) or match.group(4) or ''
for message in messages:
content = message.get('content')
if isinstance(content, str) and SKILL_MENTION_STRIP_RE.search(content):
- message['content'] = SKILL_MENTION_STRIP_RE.sub(label, content).strip()
+ message['content'] = SKILL_MENTION_STRIP_RE.sub(label, content)
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get('type') == 'text':
text = part.get('text', '')
if SKILL_MENTION_STRIP_RE.search(text):
- part['text'] = SKILL_MENTION_STRIP_RE.sub(label, text).strip()
+ part['text'] = SKILL_MENTION_STRIP_RE.sub(label, text)
async def connect_mcp_server(
@@ -2776,8 +2778,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
append=True,
)
- # Strip <$skillId|label> mention tags so the model doesn't see raw markup.
- strip_skill_mentions(form_data.get('messages', []))
+ # 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})
prompt = get_last_user_message(form_data['messages'])
diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte
index f5989cb40d..e90b04d641 100644
--- a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte
+++ b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte
@@ -9,6 +9,7 @@
const i18n = getContext('i18n');
import { WEBUI_BASE_URL } from '$lib/constants';
+ import { skills } from '$lib/stores';
import { copyToClipboard, safeLinkUrl, unescapeHtml } from '$lib/utils';
import Image from '$lib/components/common/Image.svelte';
@@ -128,7 +129,11 @@
}}
>
{:else if token.type === 'mention'}
-
+ {#if token.triggerChar === '$' && !$skills?.some((skill) => skill.id === token.id && skill.is_active)}
+ {token.raw}
+ {:else}
+
+ {/if}
{:else if token.type === 'footnote'}
{@html DOMPurify.sanitize(
``
diff --git a/src/lib/components/common/RichTextInput.svelte b/src/lib/components/common/RichTextInput.svelte
index d8f47dad9c..979fab49ba 100644
--- a/src/lib/components/common/RichTextInput.svelte
+++ b/src/lib/components/common/RichTextInput.svelte
@@ -2,6 +2,7 @@
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import equal from 'fast-deep-equal';
+ import { skills } from '$lib/stores';
marked.use({
breaks: true,
@@ -522,9 +523,15 @@
// Now replace the escaped mention patterns back into real spans
const withMentions = escaped.replace(
/<([@#$])([^|&\s]+)(?:\|([^&]*?))?>|<\/([\w.\-:/]+)\|([^&]*?)>/g,
- (_, ch, id, label, slashSkillId, slashSkillLabel) => {
+ (match, ch, id, label, slashSkillId, slashSkillLabel) => {
const mentionChar = ch || '$';
const mentionId = id || slashSkillId;
+ if (
+ mentionChar === '$' &&
+ !$skills?.some((skill) => skill.id === mentionId && skill.is_active)
+ ) {
+ return match;
+ }
const display = (label || slashSkillLabel)?.length
? label || slashSkillLabel
: mentionId;
diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts
index 270451dbf6..98a63429c0 100644
--- a/src/lib/stores/index.ts
+++ b/src/lib/stores/index.ts
@@ -69,7 +69,7 @@ export const models: Writable = writable([]);
export const knowledge: Writable = writable(null);
export const tools = writable(null);
-export const skills = writable(null);
+export const skills: Writable = writable(null);
export const functions = writable(null);
export type WorkspaceSection = 'models' | 'knowledge' | 'prompts' | 'skills' | 'tools';
diff --git a/src/lib/utils/marked/mention-extension.ts b/src/lib/utils/marked/mention-extension.ts
index 2932da4c7c..efbb74fdf0 100644
--- a/src/lib/utils/marked/mention-extension.ts
+++ b/src/lib/utils/marked/mention-extension.ts
@@ -1,4 +1,7 @@
// mention-extension.ts
+import { get } from 'svelte/store';
+import { skills } from '$lib/stores';
+
type MentionOptions = {
triggerChar?: string; // default "@"
className?: string; // default "mention"
@@ -20,6 +23,9 @@ 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)) {
+ return escapeHtml(token.raw);
+ }
const cls = options.className ?? 'mention';
const extra = options.extraAttrs ?? {};