Files
easyai-ai-gateway/apps/web/src/pages/playground-prompt-mention.tsx
T

576 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { FileText, Music2, Video } from 'lucide-react';
export type PlaygroundMentionUploadKind = 'audio' | 'file' | 'image' | 'video';
export interface PlaygroundMentionUpload {
id: string;
kind: PlaygroundMentionUploadKind;
name: string;
url: string;
}
const resourceTokenPrefix = '<<<playground-resource:';
const resourceTokenSuffix = '>>>';
const resourceTokenPattern = /<<<playground-resource:([^>]+)>>>/g;
const resourceTokenWithSpacePattern = /<<<playground-resource:([^>]+)>>>\s?/g;
export function buildPlaygroundResourceToken(id: string) {
return `${resourceTokenPrefix}${id}${resourceTokenSuffix}`;
}
export function removeInvalidPlaygroundResourceTokens(raw: string, uploads: PlaygroundMentionUpload[]) {
if (!raw.includes(resourceTokenPrefix)) return raw;
const validIds = new Set(uploads.map((item) => item.id));
return raw.replace(resourceTokenWithSpacePattern, (full, id: string) => validIds.has(id) ? full : '');
}
export function replacePlaygroundResourceTokens(
raw: string,
uploads: PlaygroundMentionUpload[],
mode: 'image' | 'video',
) {
if (!raw.includes(resourceTokenPrefix)) return raw;
const byId = new Map(uploads.map((item) => [item.id, item]));
return raw.replace(resourceTokenPattern, (full, id: string) => {
const item = byId.get(id);
if (!item) return '';
return resourcePromptLabel(item, uploads, mode);
});
}
export function PlaygroundPromptMentionInput(props: {
disabled?: boolean;
placeholder: string;
uploads: PlaygroundMentionUpload[];
value: string;
onChange: (value: string) => void;
}) {
const editableRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const blurTimerRef = useRef<number | undefined>(undefined);
const isComposingRef = useRef(false);
const [text, setText] = useState(props.value);
const [focused, setFocused] = useState(false);
const [hasEditableContent, setHasEditableContent] = useState(() => promptTextHasContent(props.value));
const [mentionOpen, setMentionOpen] = useState(false);
const [mentionAtIndex, setMentionAtIndex] = useState(-1);
const [mentionSearch, setMentionSearch] = useState('');
const [highlightIndex, setHighlightIndex] = useState(0);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, placement: 'bottom' as 'bottom' | 'top' });
const showPlaceholder = !hasEditableContent;
const uploadSignature = useMemo(
() => props.uploads.map((item) => `${item.id}:${item.kind}:${item.name}:${item.url}`).join('|'),
[props.uploads],
);
const mentionItems = useMemo(() => props.uploads.map((item, index) => ({
item,
label: mentionDisplayLabel(item, props.uploads),
searchText: `${item.name} ${mentionDisplayLabel(item, props.uploads)} ${uploadKindChinese(item.kind)} ${index + 1}`.toLowerCase(),
token: buildPlaygroundResourceToken(item.id),
})), [props.uploads]);
const filteredMentionItems = useMemo(() => {
const keyword = mentionSearch.trim().toLowerCase();
if (!keyword) return mentionItems;
return mentionItems.filter((item) => item.searchText.includes(keyword));
}, [mentionItems, mentionSearch]);
useEffect(() => {
if (props.value === text) {
setHasEditableContent(promptTextHasContent(props.value));
return;
}
setText(props.value);
setHasEditableContent(promptTextHasContent(props.value));
if (!focused) {
requestAnimationFrame(() => renderToEditable(props.value));
}
}, [focused, props.value, text]);
useEffect(() => {
const cleaned = removeInvalidPlaygroundResourceTokens(text, props.uploads);
if (cleaned !== text) {
setText(cleaned);
setHasEditableContent(promptTextHasContent(cleaned));
props.onChange(cleaned);
requestAnimationFrame(() => renderToEditable(cleaned));
return;
}
if (!focused) {
requestAnimationFrame(() => renderToEditable(text));
}
}, [uploadSignature]);
useEffect(() => {
requestAnimationFrame(() => renderToEditable(text));
return () => {
if (blurTimerRef.current) window.clearTimeout(blurTimerRef.current);
};
}, []);
const updateMentionDropdownPosition = useCallback(() => {
const editable = editableRef.current;
const root = editable?.parentElement;
if (!editable || !root) return;
const rootRect = root.getBoundingClientRect();
const caretRect = getCaretClientRect(editable) ?? editable.getBoundingClientRect();
const dropdown = dropdownRef.current;
const viewportPadding = 8;
const gap = 6;
const dropdownWidth = Math.min(dropdown?.offsetWidth ?? 320, Math.max(180, window.innerWidth - viewportPadding * 2));
const dropdownHeight = Math.min(dropdown?.offsetHeight ?? 220, Math.max(120, window.innerHeight - viewportPadding * 2));
const minLeft = viewportPadding - rootRect.left;
const maxLeft = window.innerWidth - viewportPadding - dropdownWidth - rootRect.left;
const left = clamp(caretRect.left - rootRect.left, minLeft, Math.max(minLeft, maxLeft));
const belowTop = caretRect.bottom + gap;
const aboveTop = caretRect.top - dropdownHeight - gap;
const shouldOpenAbove = belowTop + dropdownHeight > window.innerHeight - viewportPadding && aboveTop >= viewportPadding;
const viewportTop = shouldOpenAbove
? Math.max(viewportPadding, aboveTop)
: Math.min(belowTop, window.innerHeight - viewportPadding - dropdownHeight);
setDropdownPosition({
top: viewportTop - rootRect.top,
left,
placement: shouldOpenAbove ? 'top' : 'bottom',
});
}, []);
useEffect(() => {
if (!mentionOpen) return;
const frame = window.requestAnimationFrame(updateMentionDropdownPosition);
const handleViewportChange = () => updateMentionDropdownPosition();
window.addEventListener('resize', handleViewportChange);
window.addEventListener('scroll', handleViewportChange, true);
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener('resize', handleViewportChange);
window.removeEventListener('scroll', handleViewportChange, true);
};
}, [filteredMentionItems.length, mentionOpen, mentionSearch, updateMentionDropdownPosition]);
function renderToEditable(nextText = text, caret?: number) {
const editable = editableRef.current;
if (!editable) return;
editable.innerHTML = textToHtml(nextText, props.uploads);
setHasEditableContent(promptTextHasContent(nextText));
if (typeof caret === 'number') {
editable.focus();
setCaretOffset(editable, caret);
}
}
function commitFromEditable(shouldInspectMention: boolean, event?: InputEvent) {
const editable = editableRef.current;
if (!editable || props.disabled) return;
const nextText = serializeEditableToPlainText(editable);
setHasEditableContent(promptTextHasContent(nextText));
if (isComposingRef.current || event?.isComposing) return;
setText(nextText);
props.onChange(nextText);
if (!shouldInspectMention) return;
inspectMentionTrigger(nextText, getCaretOffset(editable), event);
}
function inspectMentionTrigger(nextText: string, caret: number, event?: InputEvent) {
const beforeCaret = nextText.slice(0, caret);
const atIndex = Math.max(beforeCaret.lastIndexOf('@'), beforeCaret.lastIndexOf(''));
if (atIndex < 0) {
closeMention();
return;
}
const search = beforeCaret.slice(atIndex + 1);
if (/\s/.test(search)) {
closeMention();
return;
}
const inputData = event?.data ?? '';
if (!mentionOpen && search.length > 0 && inputData !== '@' && inputData !== '') {
closeMention();
return;
}
setMentionAtIndex(atIndex);
setMentionSearch(search);
setMentionOpen(true);
setHighlightIndex(0);
requestAnimationFrame(updateMentionDropdownPosition);
}
function closeMention() {
setMentionOpen(false);
setMentionSearch('');
setMentionAtIndex(-1);
setHighlightIndex(0);
}
function applyMention(token: string) {
const editable = editableRef.current;
if (!editable || mentionAtIndex < 0) return;
const currentText = serializeEditableToPlainText(editable);
const replaceEnd = Math.max(mentionAtIndex + 1, mentionAtIndex + 1 + mentionSearch.length);
const before = currentText.slice(0, mentionAtIndex);
const after = currentText.slice(replaceEnd);
const nextText = `${before}${token} ${after}`;
const nextCaret = before.length + token.length + 1;
setText(nextText);
setHasEditableContent(promptTextHasContent(nextText));
props.onChange(nextText);
closeMention();
requestAnimationFrame(() => renderToEditable(nextText, nextCaret));
}
function handleKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
if (props.disabled) {
event.preventDefault();
return;
}
if (!mentionOpen || !filteredMentionItems.length) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
setHighlightIndex((current) => (current + 1) % filteredMentionItems.length);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
setHighlightIndex((current) => (current - 1 + filteredMentionItems.length) % filteredMentionItems.length);
return;
}
if (event.key === 'Enter') {
event.preventDefault();
const item = filteredMentionItems[Math.min(highlightIndex, filteredMentionItems.length - 1)];
if (item) applyMention(item.token);
return;
}
if (event.key === 'Escape') {
event.preventDefault();
closeMention();
}
}
return (
<div className="promptMentionInput">
<div
ref={editableRef}
className="promptMentionEditable"
contentEditable={!props.disabled}
role="textbox"
aria-label={props.placeholder}
aria-multiline="true"
suppressContentEditableWarning
onBlur={() => {
blurTimerRef.current = window.setTimeout(() => {
setFocused(false);
closeMention();
commitFromEditable(false);
}, 120);
}}
onCompositionEnd={() => {
isComposingRef.current = false;
setHasEditableContent(editablePromptTextHasContent(editableRef.current));
requestAnimationFrame(() => commitFromEditable(true));
}}
onCompositionStart={() => {
isComposingRef.current = true;
}}
onFocus={() => {
setFocused(true);
if (blurTimerRef.current) window.clearTimeout(blurTimerRef.current);
}}
onInput={(event) => commitFromEditable(true, event.nativeEvent instanceof InputEvent ? event.nativeEvent : undefined)}
onKeyDown={handleKeyDown}
onPaste={(event) => {
if (props.disabled) return;
event.preventDefault();
const plainText = event.clipboardData.getData('text/plain');
document.execCommand('insertText', false, plainText);
}}
/>
{showPlaceholder && <div className="promptMentionPlaceholder" aria-hidden="true">{props.placeholder}</div>}
{mentionOpen && (
<div
ref={dropdownRef}
className="promptMentionDropdown"
data-placement={dropdownPosition.placement}
style={{ left: dropdownPosition.left, top: dropdownPosition.top }}
onMouseDown={(event) => event.preventDefault()}
>
{filteredMentionItems.length ? (
filteredMentionItems.map((candidate, index) => (
<button
type="button"
className="promptMentionItem"
data-active={index === highlightIndex}
key={candidate.item.id}
onMouseEnter={() => setHighlightIndex(index)}
onMouseDown={(event) => {
event.preventDefault();
applyMention(candidate.token);
}}
>
<MentionThumb item={candidate.item} />
<span>
<strong>{candidate.label}</strong>
<small>{candidate.item.name}</small>
</span>
<em>{uploadKindChinese(candidate.item.kind)}</em>
</button>
))
) : (
<div className="promptMentionEmpty">暂无可引用资源</div>
)}
</div>
)}
</div>
);
}
function promptTextHasContent(raw: string) {
return raw.trim().length > 0;
}
function editablePromptTextHasContent(editable: HTMLElement | null) {
return editable ? promptTextHasContent(serializeEditableToPlainText(editable)) : false;
}
function MentionThumb(props: { item: PlaygroundMentionUpload }) {
if (props.item.kind === 'image') {
return <img className="promptMentionThumb" src={props.item.url} alt="" draggable={false} />;
}
if (props.item.kind === 'video') {
return <video className="promptMentionThumb" src={props.item.url} muted playsInline preload="metadata" />;
}
if (props.item.kind === 'audio') {
return <Music2 className="promptMentionThumbIcon" size={16} />;
}
return <FileText className="promptMentionThumbIcon" size={16} />;
}
function textToHtml(raw: string, uploads: PlaygroundMentionUpload[]) {
const uploadById = new Map(uploads.map((item) => [item.id, item]));
const parts: string[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
const re = new RegExp(resourceTokenPattern);
while ((match = re.exec(raw)) !== null) {
if (match.index > lastIndex) {
parts.push(escapeHtml(raw.slice(lastIndex, match.index)));
}
const token = match[0] ?? '';
const id = match[1] ?? '';
const item = uploadById.get(id);
parts.push(item ? mentionChipHtml(token, item, uploads) : '');
lastIndex = match.index + token.length;
}
if (lastIndex < raw.length) {
parts.push(escapeHtml(raw.slice(lastIndex)));
}
return parts.join('').replace(/\n/g, '<br>');
}
function mentionChipHtml(token: string, item: PlaygroundMentionUpload, uploads: PlaygroundMentionUpload[]) {
const label = mentionDisplayLabel(item, uploads);
const thumb = item.kind === 'image'
? `<img class="promptMentionChipThumb" src="${escapeAttr(item.url)}" alt="" draggable="false">`
: item.kind === 'video'
? `<video class="promptMentionChipThumb" src="${escapeAttr(item.url)}" muted preload="metadata" playsinline></video>`
: `<span class="promptMentionChipThumb promptMentionChipThumbPlaceholder">${escapeHtml(uploadKindShort(item.kind))}</span>`;
return `<span contenteditable="false" class="promptMentionChip" data-token="${escapeAttr(token)}">${thumb}<span>${escapeHtml(label)}</span></span>`;
}
function getCaretClientRect(editable: HTMLElement) {
const selection = document.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const originalRange = selection.getRangeAt(0);
if (!editable.contains(originalRange.startContainer)) return null;
const collapsedRange = originalRange.cloneRange();
collapsedRange.collapse(true);
let rect = collapsedRange.getBoundingClientRect();
if (rect.width || rect.height) return rect;
const marker = document.createElement('span');
marker.textContent = '\u200b';
const restoreRange = originalRange.cloneRange();
collapsedRange.insertNode(marker);
rect = marker.getBoundingClientRect();
marker.remove();
selection.removeAllRanges();
selection.addRange(restoreRange);
return rect.width || rect.height ? rect : null;
}
function serializeEditableToPlainText(editable: HTMLElement) {
const parts: string[] = [];
const walk = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
parts.push(node.textContent ?? '');
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
const element = node as HTMLElement;
const token = element.getAttribute('data-token');
if (token) {
parts.push(token);
return;
}
if (element.tagName === 'BR') {
parts.push('\n');
return;
}
if (element.tagName === 'DIV' && parts.length && !parts[parts.length - 1]?.endsWith('\n')) {
parts.push('\n');
}
element.childNodes.forEach(walk);
};
editable.childNodes.forEach(walk);
return parts.join('');
}
function getCaretOffset(editable: HTMLElement) {
const selection = document.getSelection();
if (!selection || selection.rangeCount === 0) return 0;
const range = selection.getRangeAt(0);
let offset = 0;
const walk = (node: Node): boolean => {
if (node.nodeType === Node.TEXT_NODE) {
const textLength = (node.textContent ?? '').length;
if (node === range.startContainer) {
offset += range.startOffset;
return true;
}
offset += textLength;
return false;
}
if (node.nodeType !== Node.ELEMENT_NODE) return false;
const element = node as HTMLElement;
const token = element.getAttribute('data-token');
if (token) {
if (node === range.startContainer || element.contains(range.startContainer)) {
offset += range.startOffset > 0 ? token.length : 0;
return true;
}
offset += token.length;
return false;
}
if (element.tagName === 'BR') {
offset += 1;
return false;
}
for (const child of Array.from(element.childNodes)) {
if (walk(child)) return true;
}
return false;
};
for (const child of Array.from(editable.childNodes)) {
if (walk(child)) break;
}
return offset;
}
function setCaretOffset(editable: HTMLElement, targetOffset: number) {
const selection = document.getSelection();
if (!selection) return;
let offset = 0;
let targetNode: Node | null = null;
let targetNodeOffset = 0;
const walk = (node: Node): boolean => {
if (node.nodeType === Node.TEXT_NODE) {
const textLength = (node.textContent ?? '').length;
if (offset + textLength >= targetOffset) {
targetNode = node;
targetNodeOffset = Math.max(0, targetOffset - offset);
return true;
}
offset += textLength;
return false;
}
if (node.nodeType !== Node.ELEMENT_NODE) return false;
const element = node as HTMLElement;
const token = element.getAttribute('data-token');
if (token) {
if (offset + token.length >= targetOffset) {
targetNode = node;
targetNodeOffset = targetOffset <= offset ? 0 : 1;
return true;
}
offset += token.length;
return false;
}
if (element.tagName === 'BR') {
if (offset + 1 >= targetOffset) {
targetNode = node;
targetNodeOffset = 0;
return true;
}
offset += 1;
return false;
}
for (const child of Array.from(element.childNodes)) {
if (walk(child)) return true;
}
return false;
};
for (const child of Array.from(editable.childNodes)) {
if (walk(child)) break;
}
if (!targetNode) {
targetNode = editable;
targetNodeOffset = editable.childNodes.length;
}
const range = document.createRange();
range.setStart(targetNode, targetNode.nodeType === Node.TEXT_NODE ? targetNodeOffset : Math.min(targetNodeOffset, targetNode.childNodes.length));
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
function mentionDisplayLabel(item: PlaygroundMentionUpload, uploads: PlaygroundMentionUpload[]) {
return `@${uploadKindChinese(item.kind)} ${uploadKindIndex(item, uploads)}`;
}
function resourcePromptLabel(item: PlaygroundMentionUpload, uploads: PlaygroundMentionUpload[], mode: 'image' | 'video') {
const kind = mode === 'image' ? 'image' : uploadKindEnglish(item.kind);
return `${kind} ${uploadKindIndex(item, uploads)}`;
}
function uploadKindIndex(item: PlaygroundMentionUpload, uploads: PlaygroundMentionUpload[]) {
const sameKind = uploads.filter((upload) => upload.kind === item.kind);
return Math.max(1, sameKind.findIndex((upload) => upload.id === item.id) + 1);
}
function uploadKindChinese(kind: PlaygroundMentionUploadKind) {
if (kind === 'image') return '图像';
if (kind === 'video') return '视频';
if (kind === 'audio') return '音频';
return '文件';
}
function uploadKindEnglish(kind: PlaygroundMentionUploadKind) {
if (kind === 'image') return 'image';
if (kind === 'video') return 'video';
if (kind === 'audio') return 'audio';
return 'file';
}
function uploadKindShort(kind: PlaygroundMentionUploadKind) {
if (kind === 'image') return '图';
if (kind === 'video') return '视';
if (kind === 'audio') return '音';
return '文';
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function escapeAttr(value: string) {
return escapeHtml(value).replace(/"/g, '&quot;');
}