This commit is contained in:
Timothy Jaeryang Baek 2026-10-14 15:54:56 +04:00
parent d51fffb468
commit f9c524d31d
11 changed files with 327 additions and 60 deletions

View file

@ -1,5 +1,5 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
import type { App } from '$lib/apis/apps';
import { getApps, type App } from '$lib/apis/apps';
type ChannelForm = {
type?: string;
@ -970,6 +970,11 @@ export const getChannelAppsById = async (
return res;
};
export const getChannelMentionApps = async (token: string, channelId: string): Promise<App[]> => {
const [apps, joined] = await Promise.all([getApps(token), getChannelAppsById(token, channelId)]);
return apps.filter((app) => app.is_active && joined.some((member) => member.id === app.id));
};
export const inviteAppToChannel = async (
token: string = '',
channel_id: string,

View file

@ -374,7 +374,7 @@
<Messages
{channel}
{top}
{messages}
bind:messages
{replyToMessage}
onReply={async (message) => {
replyToMessage = message;
@ -407,6 +407,7 @@
<div class=" pb-3 px-3">
<MessageInput
id="root"
{messages}
bind:chatInputElement
bind:replyToMessage
{typingUsers}

View file

@ -7,8 +7,19 @@
const i18n: any = getContext('i18n');
import { channelRequestQueues, config, mobile, settings, socket, user } from '$lib/stores';
import {
channelRequestQueues,
channels,
models,
config,
mobile,
settings,
socket,
user
} from '$lib/stores';
import { processingQueueChannels, processNextInQueue } from '$lib/utils/channelQueue';
import { getMentionUserQueries, resolveMentions, getChannelMentionCandidates } from '$lib/utils/channelMentions';
import type { Editor } from '@tiptap/core';
import QueuedMessageItem from '../chat/MessageInput/QueuedMessageItem.svelte';
import {
convertHeicToJpeg,
@ -50,6 +61,7 @@
export let id: string | null = null;
export let channel: any = null;
export let messages: any[] | null = [];
export let typingUsers = [];
export let inputLoading = false;
@ -77,6 +89,7 @@
let recording = false;
let content = '';
let inputEditor: Editor | null = null;
let files: any[] = [];
let isDestroyed = false;
$: parentId = id === 'root' ? null : id;
@ -579,10 +592,43 @@
void processNextInQueue(queueKey);
};
const resolveInputMentions = async () => {
const targetQueueKey = queueKey;
const editor = inputEditor;
if (editor && /[@#]/.test(content)) {
const state = editor.state;
try {
const candidates = await getChannelMentionCandidates(
localStorage.token,
channel.id,
getMentionUserQueries(state.doc),
{
users: userSuggestions,
apps: $config?.features?.enable_apps ?? false,
models: $models,
channels: channelSuggestions ? $channels : []
}
);
if (isDestroyed || targetQueueKey !== queueKey || editor.isDestroyed) return false;
const tr = resolveMentions(state, candidates);
// A paste lookup may have finished while Send was waiting for the same result.
if (editor.state.doc.eq(tr.doc)) return true;
if (editor.state.doc !== state.doc) return false;
if (tr.docChanged) editor.view.dispatch(tr);
} catch (error) {
toast.error(`${error}`);
return false;
}
}
return true;
};
const submitHandler = async () => {
if (disabled || !$user?.id || (content === '' && files.length === 0)) return;
const targetQueueKey = queueKey;
if (inputEditor && /[@#]/.test(content) && !(await resolveInputMentions())) return;
if (content === '' && files.length === 0) return;
const entry = {
id: uuidv4(),
prompt: content,
@ -999,6 +1045,7 @@
id="chat-input"
className="input-prose min-h-fit h-full text-sm!"
bind:this={chatInputElement}
bind:editor={inputEditor}
json={true}
messageInput={true}
editable={!disabled}
@ -1015,6 +1062,7 @@
largeTextAsFile={$settings?.largeTextAsFile ?? false}
floatingMenuPlacement={'top-start'}
{suggestions}
on:pasted={resolveInputMentions}
onChange={(e) => {
const { md } = e;
content = md;
@ -1028,6 +1076,30 @@
document.getElementById('suggestions-container');
if (!suggestionsContainerElement) {
if (
e.key === 'ArrowUp' &&
!e.isComposing &&
!isCtrlPressed &&
!e.shiftKey &&
!e.altKey &&
!disabled &&
content === '' &&
files.length === 0
) {
const message = messages?.find(
(message) => message.user_id === $user?.id && !message.temp_id
);
if (message) {
const messageId = parentId ? `${parentId}-${message.id}` : message.id;
const editButton = document.getElementById(
`edit-message-${messageId}`
);
if (editButton) {
e.preventDefault();
editButton.click();
}
}
}
if (
!$mobile ||
!(

View file

@ -3,8 +3,8 @@
import { getAbortSignal, getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { getChannelAppsById } from '$lib/apis/channels';
import { getApps } from '$lib/apis/apps';
import { getChannelMentionApps } from '$lib/apis/channels';
import { mentionAttributes } from '$lib/utils/channelMentions';
import { channels, models, config } from '$lib/stores';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Grid from '$lib/components/icons/Grid.svelte';
@ -90,14 +90,7 @@
const item = filteredItems[index];
if (!item) return;
// Add the "U:", "M:" or "C:" prefix to the id
// and also append the label after a pipe |
// so that the mention renderer can show the label
if (item)
command({
id: `${item.type === 'user' ? 'U' : item.type === 'model' ? 'M' : item.type === 'app' ? 'A' : 'C'}:${item.id}|${item.label}`,
label: item.label
});
command(mentionAttributes(item));
};
const onKeyDown = (event: KeyboardEvent) => {
@ -138,16 +131,14 @@
onMount(() => {
if ($config?.features?.enable_apps && channelId && triggerChar === '@') {
Promise.all([getApps(localStorage.token), getChannelAppsById(localStorage.token, channelId)])
.then(([apps, joined]) => {
_apps = apps
.filter((a) => a.is_active && joined.some((member) => member.id === a.id))
.map((a) => ({
type: 'app',
id: a.id,
label: a.name,
profile_image_url: a.data.manifest.profile_image_url
}));
getChannelMentionApps(localStorage.token, channelId)
.then((apps) => {
_apps = apps.map((a) => ({
type: 'app',
id: a.id,
label: a.name,
profile_image_url: a.data.manifest.profile_image_url
}));
})
.catch(() => {});
}

View file

@ -160,6 +160,9 @@
disabled={!channel?.write_access || message?.temp_id}
pending={!!message?.temp_id}
showUserProfile={messageIdx === 0 ||
Object.values(appReplies).some(
(event) => event.message_id === messageList.at(messageIdx - 1)?.id
) ||
messageList.at(messageIdx - 1)?.user_id !== message.user_id ||
messageList.at(messageIdx - 1)?.user?.id !== message.user?.id ||
messageList.at(messageIdx - 1)?.meta?.model_id !== message?.meta?.model_id ||

View file

@ -14,7 +14,7 @@
dayjs.extend(isYesterday);
dayjs.extend(localizedFormat);
import { getContext } from 'svelte';
import { getContext, tick } from 'svelte';
const i18n = getContext<Writable<i18nType>>('i18n');
import { formatDate } from '$lib/utils';
@ -286,10 +286,18 @@
{#if onEdit}
<Tooltip content={$i18n.t('Edit')}>
<button
id="edit-message-{renderedMessageId}"
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
on:click={async () => {
edit = true;
editedContent = message.content;
await tick();
const textarea = document
.getElementById(`message-${renderedMessageId}`)
?.querySelector('textarea');
textarea?.focus();
textarea?.setSelectionRange(textarea.value.length, textarea.value.length);
textarea?.scrollIntoView({ block: 'center' });
}}
>
<Pencil />
@ -543,7 +551,7 @@
{#if edit}
<div class="py-2">
<Textarea
className=" bg-transparent outline-hidden w-full resize-none"
className=" bg-transparent outline-hidden w-full resize-none text-sm leading-[1.375rem]"
bind:value={editedContent}
onKeydown={(e) => {
if (e.key === 'Escape') {

View file

@ -222,7 +222,7 @@
id={threadId}
{channel}
{top}
{messages}
bind:messages
{replyToMessage}
thread={true}
{onPin}
@ -258,6 +258,7 @@
<div class=" pb-3 px-3 w-full">
<MessageInput
{messages}
bind:replyToMessage
bind:chatInputElement
id={threadId}

View file

@ -134,37 +134,45 @@
<LinkPreview.Root openDelay={0} closeDelay={0} bind:open={openPreview}>
<LinkPreview.Trigger class=" cursor-pointer no-underline! font-normal! ">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
{#snippet child({ props })}
<span {...props}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span
class="mention"
on:click={async () => {
if (triggerChar === '@') {
if (idType === 'U') {
// Open user profile
console.log('Clicked user mention', id);
} else if (idType === 'M') {
console.log('Clicked model mention', id);
await goto(`/?model=${id}`);
}
} else if (triggerChar === '#') {
if (idType === 'C') {
// Open channel
if ($channels.find((c) => c.id === id)) {
await goto(`/channels/${id}`);
<span
class="mention"
data-type="mention"
data-id={`${token.id.split('|')[0]}|${label}`}
data-label={label}
data-mention-suggestion-char={triggerChar}
on:click={async () => {
if (triggerChar === '@') {
if (idType === 'U') {
// Open user profile
console.log('Clicked user mention', id);
} else if (idType === 'M') {
console.log('Clicked model mention', id);
await goto(`/?model=${id}`);
}
} else if (triggerChar === '#') {
if (idType === 'C') {
// Open channel
if ($channels.find((c) => c.id === id)) {
await goto(`/channels/${id}`);
}
} else if (idType === 'T') {
// Open thread
}
} else {
// Unknown trigger char, just log
console.log('Clicked mention', id);
}
} else if (idType === 'T') {
// Open thread
}
} else {
// Unknown trigger char, just log
console.log('Clicked mention', id);
}
}}
>
{triggerChar}{label}
</span>
}}
>
{triggerChar}{label}
</span>
</span>
{/snippet}
</LinkPreview.Trigger>
{#if triggerChar === '@' && idType === 'U'}

View file

@ -138,7 +138,7 @@
const i18n = getContext<typeof import('$lib/i18n').default>('i18n');
const eventDispatch = createEventDispatcher();
import { Fragment, DOMParser } from 'prosemirror-model';
import { Fragment, DOMParser, Slice, type Node, type Schema } from 'prosemirror-model';
import { EditorState, Plugin, PluginKey, TextSelection, Selection } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';
import { Editor, Extension, markInputRule, mergeAttributes } from '@tiptap/core';
@ -303,6 +303,27 @@
return `${char}${label}`;
};
// Plain-text mode still preserves mention identities from the clipboard's HTML.
const plainMentionPaste = (slice: Slice, schema: Schema): Fragment | null => {
const nodes: Node[] = [];
let hasMention = false;
let firstBlock = true;
slice.content.descendants((node) => {
if (node.isTextblock) {
if (!firstBlock) nodes.push(schema.nodes.hardBreak.create());
firstBlock = false;
}
if (node.type.name === 'mention') {
hasMention = true;
nodes.push(node.mark([]));
return false;
}
if (node.isText) nodes.push(schema.text(node.text!));
else if (node.type.name === 'hardBreak') nodes.push(node.mark([]));
});
return hasMention ? Fragment.fromArray(nodes) : null;
};
export let onSelectionUpdate = (e) => {};
export let id = '';
@ -992,6 +1013,7 @@
json: jsonValue,
md: mdValue
});
if (transaction.getMeta('paste')) eventDispatch('pasted');
if (json) {
value = jsonValue;
@ -1016,6 +1038,18 @@
// the tiptap placeholder never becomes the field's accessible name;
// function form so a placeholder change is picked up after mount
attributes: () => ({ id, 'aria-label': _placeholder }),
transformPasted: (slice) => {
const stripMentionLinks = (fragment: Fragment): Fragment => {
const nodes: Node[] = [];
fragment.forEach((node) => {
nodes.push(node.type.name === 'mention'
? node.mark(node.marks.filter((mark) => mark.type.name !== 'link'))
: node.isLeaf ? node : node.copy(stripMentionLinks(node.content)));
});
return Fragment.fromArray(nodes);
};
return new Slice(stripMentionLinks(slice.content), slice.openStart, slice.openEnd);
},
handleDrop: (view, event) => {
// Intercept sidebar chat item drops to prevent ProseMirror
// from inserting the raw JSON as text. The actual handling
@ -1034,7 +1068,7 @@
}
return false;
},
handlePaste: (view, event) => {
handlePaste: (view, event, slice) => {
// Paste literal text when automatic formatting is disabled.
if (!richText || !autoFormat) {
// swallow HTML completely
@ -1051,6 +1085,15 @@
return true;
}
const mentions = plainMentionPaste(slice, state.schema);
if (mentions) {
dispatch(
state.tr.replaceWith(state.selection.from, state.selection.to, mentions)
.setMeta('paste', true).scrollIntoView()
);
return true;
}
const lines = plainText.split('\n');
const nodes = [];
@ -1067,6 +1110,7 @@
dispatch(
state.tr
.replaceWith(state.selection.from, state.selection.to, fragment)
.setMeta('paste', true)
.scrollIntoView()
);
@ -1252,7 +1296,7 @@
});
const fragment = Fragment.fromArray(nodes);
const tr = state.tr.replaceWith(from, to, fragment);
const tr = state.tr.replaceWith(from, to, fragment).setMeta('paste', true);
dispatch(tr.scrollIntoView());
event.preventDefault();
return true;

View file

@ -0,0 +1,133 @@
import { searchUsers } from '$lib/apis/users';
import { getChannelMentionApps } from '$lib/apis/channels';
import type { Node } from '@tiptap/pm/model';
import type { EditorState } from '@tiptap/pm/state';
export type MentionItem = {
type: 'user' | 'model' | 'app' | 'channel';
id: string;
label: string;
};
export const mentionAttributes = (item: MentionItem) => ({
id: `${{ user: 'U', model: 'M', app: 'A', channel: 'C' }[item.type]}:${item.id}|${item.label}`,
label: item.label,
mentionSuggestionChar: item.type === 'channel' ? '#' : '@'
});
const mentionTextBlocks = (doc: Node) => {
const blocks: { text: string; pos: number }[] = [];
doc.descendants((node, pos) => {
if (node.type.spec.code) return false;
if (!node.isTextblock) return;
let text = '';
node.forEach((child) => {
text +=
child.type.name === 'hardBreak'
? '\n'
: child.isText &&
!child.marks.some((mark) => mark.type.spec.code || mark.type.name === 'link')
? child.text
: '\ufffc'.repeat(child.nodeSize);
});
blocks.push({ text, pos: pos + 1 });
return false;
});
return blocks;
};
export const getMentionUserQueries = (doc: Node) => {
const queries = new Set<string>();
for (const { text } of mentionTextBlocks(doc)) {
for (const match of text.matchAll(
/<[@#$][^>]*>|(?:^|[\s([{])@([^\s@#<>.,!?;:()[\]{}\ufffc]+)/gu
)) {
if (match[1]) queries.add(match[1].toLowerCase());
}
}
return [...queries];
};
export const resolveMentions = (state: EditorState, items: MentionItem[]) => {
const tr = state.tr;
const names = new Map<string, Map<string, MentionItem>>();
for (const item of items) {
if (!item.label || /[<>|\r\n]/.test(item.label)) continue;
const attrs = mentionAttributes(item);
for (const name of [item.label, item.id]) {
const key = `${attrs.mentionSuggestionChar}${name}`.toLowerCase();
if (!names.has(key)) names.set(key, new Map());
names.get(key)!.set(attrs.id, item);
}
}
if (!names.size || !state.schema.nodes.mention) return tr;
const pattern = new RegExp(
`<[@#$][^>]*>|(^|[\\s([{])(${[...names.keys()]
.sort((a, b) => b.length - a.length)
.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('|')})(?=$|[\\s.,!?;:)\\]}])`,
'giu'
);
for (const { text, pos } of mentionTextBlocks(state.doc)) {
for (const match of text.matchAll(pattern)) {
if (!match[2]) continue;
const matches = names.get(match[2].toLowerCase())!;
// Ambiguous names require an explicit selection from the menu.
if (matches.size !== 1) continue;
const item = [...matches.values()][0];
const from = pos + match.index! + match[1].length;
tr.replaceWith(
tr.mapping.map(from),
tr.mapping.map(from + match[2].length),
state.schema.nodes.mention.create(
mentionAttributes(item),
null,
state.doc.resolve(from).marks()
)
);
}
}
return tr;
};
export const getChannelMentionCandidates = async (
token: string,
channelId: string,
queries: string[],
options: {
users: boolean;
apps: boolean;
models: { id: string; name: string; direct?: boolean }[];
channels: { id: string; name?: unknown; type?: string | null }[];
}
): Promise<MentionItem[]> => {
const [users, apps] = await Promise.all([
options.users
? Promise.all(
queries.map(async (query) => {
const users: { id: string; name: string }[] = [];
for (let page = 1; ; page++) {
const result = await searchUsers(token, query, 'name', 'asc', page);
if (!result) throw new Error('Failed to load mention suggestions');
users.push(...result.users);
if (!result.users.length || users.length >= result.total) break;
}
return users;
})
).then((results) => results.flat())
: [],
options.apps && queries.length ? getChannelMentionApps(token, channelId) : []
]);
return [
...users.map((u) => ({ type: 'user' as const, id: u.id, label: u.name })),
...options.models
.filter((m) => !m.direct)
.map((m) => ({ type: 'model' as const, id: m.id, label: m.name })),
...apps.map((a) => ({ type: 'app' as const, id: a.id, label: a.name })),
...options.channels
.filter((c) => c.type !== 'dm' && typeof c.name === 'string')
.map((c) => ({ type: 'channel' as const, id: c.id, label: c.name as string }))
];
};

View file

@ -37,7 +37,8 @@ function mentionRenderer(token: any, options: MentionOptions = {}) {
const attrs = Object.entries({
class: cls,
'data-type': 'mention',
'data-id': token.id,
'data-id': `${token.id}|${token.label}`,
'data-label': token.label,
'data-mention-suggestion-char': trigger,
...extra
})