refac/enh: mention token rendering

This commit is contained in:
Timothy Jaeryang Baek 2025-09-14 18:49:01 -04:00
parent 22e11760a1
commit 098f34f400
7 changed files with 108 additions and 6 deletions

View file

@ -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';
}

View file

@ -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) => {

View file

@ -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) {

View file

@ -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';"
></iframe>
{:else if token.type === 'mention'}
<MentionToken {token} />
{:else if token.type === 'text'}
<TextToken {token} {done} />
{/if}

View file

@ -0,0 +1,10 @@
<script lang="ts">
import Tooltip from '$lib/components/common/Tooltip.svelte';
import type { Token } from 'marked';
export let token: Token;
</script>
<Tooltip as="span" className="mention" content={token.id} placement="top">
{token.triggerChar}{token.label}
</Tooltip>

View file

@ -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 @@
});
</script>
<div bind:this={tooltipElement} class={className}>
<svelte:element this={as} bind:this={tooltipElement} class={className}>
<slot />
</div>
</svelte:element>
<slot name="tooltip"></slot>

View file

@ -0,0 +1,84 @@
// mention-extension.ts
type MentionOptions = {
triggerChar?: string; // default "@"
className?: string; // default "mention"
extraAttrs?: Record<string, string>; // additional HTML attrs
};
function escapeHtml(s: string) {
return s.replace(
/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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 `<span ${attrs}>${escapeHtml(trigger + token.label)}</span>`;
}
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>" →
// <span class="mention" data-type="mention" data-id="llama3.2:latest" data-mention-suggestion-char="@">@llama3.2:latest</span>
//
// "<@llama3.2:latest|friendly>" →
// <span class="mention" ...>@friendly</span>
//
// "<@llama3.2:latest|>" →
// <span class="mention" ...>@llama3.2:latest</span>
//
// If triggerChar = "#"