diff --git a/src/app.css b/src/app.css index 756b0d9a08..6f40acab32 100644 --- a/src/app.css +++ b/src/app.css @@ -409,14 +409,14 @@ input[type='number'] { } } -.tiptap .mention { +.mention { border-radius: 0.4rem; box-decoration-break: clone; padding: 0.1rem 0.3rem; @apply text-blue-900 dark:text-blue-100 bg-blue-300/20 dark:bg-blue-500/20; } -.tiptap .mention::after { +.mention::after { content: '\200B'; } diff --git a/src/lib/components/channel/MessageInput/MentionList.svelte b/src/lib/components/channel/MessageInput/MentionList.svelte index 4bc2b5c8a1..330ea01fdb 100644 --- a/src/lib/components/channel/MessageInput/MentionList.svelte +++ b/src/lib/components/channel/MessageInput/MentionList.svelte @@ -15,7 +15,8 @@ const select = (index: number) => { const item = filteredItems[index]; - if (item) command({ id: item.id, label: item.name }); + // Add the "A:" prefix to the id to indicate it's an assistant model + if (item) command({ id: `A:${item.id}|${item.name}`, label: item.name }); }; const onKeyDown = (event: KeyboardEvent) => { diff --git a/src/lib/components/chat/Messages/Markdown.svelte b/src/lib/components/chat/Messages/Markdown.svelte index 736c93cb0d..c2ef2b923e 100644 --- a/src/lib/components/chat/Messages/Markdown.svelte +++ b/src/lib/components/chat/Messages/Markdown.svelte @@ -5,6 +5,7 @@ import markedExtension from '$lib/utils/marked/extension'; import markedKatexExtension from '$lib/utils/marked/katex-extension'; + import { mentionExtension } from '$lib/utils/marked/mention-extension'; import MarkdownTokens from './Markdown/MarkdownTokens.svelte'; @@ -37,6 +38,7 @@ marked.use(markedKatexExtension(options)); marked.use(markedExtension(options)); + marked.use({ extensions: [mentionExtension({ triggerChar: '@' })] }); $: (async () => { if (content) { diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte index c49d60df69..8a0358a752 100644 --- a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte +++ b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte @@ -16,6 +16,7 @@ import HtmlToken from './HTMLToken.svelte'; import TextToken from './MarkdownInlineTokens/TextToken.svelte'; import CodespanToken from './MarkdownInlineTokens/CodespanToken.svelte'; + import MentionToken from './MarkdownInlineTokens/MentionToken.svelte'; export let id: string; export let done = true; @@ -60,6 +61,8 @@ frameborder="0" onload="this.style.height=(this.contentWindow.document.body.scrollHeight+20)+'px';" > + {:else if token.type === 'mention'} + {:else if token.type === 'text'} {/if} diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte new file mode 100644 index 0000000000..c23307e515 --- /dev/null +++ b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte @@ -0,0 +1,10 @@ + + + + {token.triggerChar}{token.label} + diff --git a/src/lib/components/common/Tooltip.svelte b/src/lib/components/common/Tooltip.svelte index 3d1566e650..59575520e6 100644 --- a/src/lib/components/common/Tooltip.svelte +++ b/src/lib/components/common/Tooltip.svelte @@ -7,10 +7,12 @@ export let elementId = ''; + export let as = 'div'; + export let className = 'flex'; + export let placement = 'top'; export let content = `I'm a tooltip!`; export let touch = true; - export let className = 'flex'; export let theme = ''; export let offset = [0, 4]; export let allowHTML = true; @@ -59,8 +61,8 @@ }); -
+ -
+ diff --git a/src/lib/utils/marked/mention-extension.ts b/src/lib/utils/marked/mention-extension.ts new file mode 100644 index 0000000000..21aa6bc687 --- /dev/null +++ b/src/lib/utils/marked/mention-extension.ts @@ -0,0 +1,84 @@ +// mention-extension.ts +type MentionOptions = { + triggerChar?: string; // default "@" + className?: string; // default "mention" + extraAttrs?: Record; // additional HTML attrs +}; + +function escapeHtml(s: string) { + return s.replace( + /[&<>"']/g, + (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]! + ); +} + +function mentionStart(src: string) { + // Find the first "<" followed by trigger char + // We'll refine inside tokenizer + return src.indexOf('<'); +} + +function mentionTokenizer(this: any, src: string, options: MentionOptions = {}) { + const trigger = options.triggerChar ?? '@'; + + // Build dynamic regex for `<@id>`, `<@id|label>`, `<@id|>` + const re = new RegExp(`^<\\${trigger}([\\w.\\-:]+)(?:\\|([^>]*))?>`); + const m = re.exec(src); + if (!m) return; + + const [, id, label] = m; + return { + type: 'mention', + raw: m[0], + triggerChar: trigger, + id, + label: label && label.length > 0 ? label : id + }; +} + +function mentionRenderer(token: any, options: MentionOptions = {}) { + const trigger = options.triggerChar ?? '@'; + const cls = options.className ?? 'mention'; + const extra = options.extraAttrs ?? {}; + + const attrs = Object.entries({ + class: cls, + 'data-type': 'mention', + 'data-id': token.id, + 'data-mention-suggestion-char': trigger, + ...extra + }) + .map(([k, v]) => `${k}="${escapeHtml(String(v))}"`) + .join(' '); + + return `${escapeHtml(trigger + token.label)}`; +} + +export function mentionExtension(opts: MentionOptions = {}) { + return { + name: 'mention', + level: 'inline' as const, + start: mentionStart, + tokenizer(src: string) { + return mentionTokenizer.call(this, src, opts); + }, + renderer(token: any) { + return mentionRenderer(token, opts); + } + }; +} + +// Usage: +// import { marked } from 'marked'; +// marked.use({ extensions: [mentionExtension({ triggerChar: '@' })] }); +// +// "<@llama3.2:latest>" → +// @llama3.2:latest +// +// "<@llama3.2:latest|friendly>" → +// @friendly +// +// "<@llama3.2:latest|>" → +// @llama3.2:latest +// +// If triggerChar = "#"