mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-04 21:32:39 +02:00
Merge pull request #1319 from AnishSarkar22/fix/ui-mention-documents
fix: enhance mention documents
This commit is contained in:
commit
95200e444f
8 changed files with 329 additions and 168 deletions
|
|
@ -11,25 +11,14 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { Document } from "@/contracts/types/document.types";
|
||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Render a React element to an HTML string on the client without pulling
|
||||
// `react-dom/server` into the bundle. `createRoot` + `flushSync` use the
|
||||
// same `react-dom` package React itself imports, so this adds zero new
|
||||
// runtime weight.
|
||||
function renderElementToHTML(element: ReactElement): string {
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
flushSync(() => {
|
||||
root.render(element);
|
||||
});
|
||||
const html = container.innerHTML;
|
||||
root.unmount();
|
||||
return html;
|
||||
return renderToStaticMarkup(element);
|
||||
}
|
||||
|
||||
export interface MentionedDocument {
|
||||
|
|
@ -44,7 +33,10 @@ export interface InlineMentionEditorRef {
|
|||
setText: (text: string) => void;
|
||||
getText: () => string;
|
||||
getMentionedDocuments: () => MentionedDocument[];
|
||||
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
|
||||
insertDocumentChip: (
|
||||
doc: Pick<Document, "id" | "title" | "document_type">,
|
||||
options?: { removeTriggerText?: boolean }
|
||||
) => void;
|
||||
removeDocumentChip: (docId: number, docType?: string) => void;
|
||||
setDocumentChipStatus: (
|
||||
docId: number,
|
||||
|
|
@ -66,7 +58,6 @@ interface InlineMentionEditorProps {
|
|||
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
initialDocuments?: MentionedDocument[];
|
||||
initialText?: string;
|
||||
}
|
||||
|
||||
|
|
@ -118,7 +109,6 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
onKeyDown,
|
||||
disabled = false,
|
||||
className,
|
||||
initialDocuments = [],
|
||||
initialText,
|
||||
},
|
||||
ref
|
||||
|
|
@ -126,18 +116,49 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [mentionedDocs, setMentionedDocs] = useState<Map<string, MentionedDocument>>(
|
||||
() => new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d]))
|
||||
() => new Map()
|
||||
);
|
||||
const isComposingRef = useRef(false);
|
||||
const lastSelectionRangeRef = useRef<Range | null>(null);
|
||||
const isRangeInsideEditor = useCallback((range: Range | null): range is Range => {
|
||||
if (!range || !editorRef.current) return false;
|
||||
return (
|
||||
editorRef.current.contains(range.startContainer) &&
|
||||
editorRef.current.contains(range.endContainer)
|
||||
);
|
||||
}, []);
|
||||
const isSelectionInsideEditor = useCallback(
|
||||
(selection: Selection | null): selection is Selection => {
|
||||
if (!selection || selection.rangeCount === 0 || !editorRef.current) return false;
|
||||
const range = selection.getRangeAt(0);
|
||||
return isRangeInsideEditor(range);
|
||||
},
|
||||
[isRangeInsideEditor]
|
||||
);
|
||||
|
||||
const rememberSelection = useCallback(() => {
|
||||
const selection = window.getSelection();
|
||||
if (!isSelectionInsideEditor(selection)) return;
|
||||
lastSelectionRangeRef.current = selection.getRangeAt(0).cloneRange();
|
||||
}, [isSelectionInsideEditor]);
|
||||
|
||||
const restoreRememberedSelection = useCallback((): Selection | null => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return null;
|
||||
if (!isRangeInsideEditor(lastSelectionRangeRef.current)) return null;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(lastSelectionRangeRef.current.cloneRange());
|
||||
return selection;
|
||||
}, [isRangeInsideEditor]);
|
||||
|
||||
// Sync initial documents
|
||||
useEffect(() => {
|
||||
if (initialDocuments.length > 0) {
|
||||
setMentionedDocs(
|
||||
new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d]))
|
||||
);
|
||||
}
|
||||
}, [initialDocuments]);
|
||||
const handleSelectionChange = () => {
|
||||
if (document.activeElement !== editorRef.current) return;
|
||||
rememberSelection();
|
||||
};
|
||||
document.addEventListener("selectionchange", handleSelectionChange);
|
||||
return () => document.removeEventListener("selectionchange", handleSelectionChange);
|
||||
}, [rememberSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialText || !editorRef.current) return;
|
||||
|
|
@ -145,7 +166,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
editorRef.current.appendChild(document.createElement("br"));
|
||||
editorRef.current.appendChild(document.createElement("br"));
|
||||
setIsEmpty(false);
|
||||
onChange?.(initialText, Array.from(mentionedDocs.values()));
|
||||
onChange?.(initialText, []);
|
||||
editorRef.current.focus();
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
|
|
@ -157,7 +178,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
range.insertNode(anchor);
|
||||
anchor.scrollIntoView({ block: "end" });
|
||||
anchor.remove();
|
||||
}, [initialText]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [initialText, onChange]);
|
||||
|
||||
// Focus at the end of the editor
|
||||
const focusAtEnd = useCallback(() => {
|
||||
|
|
@ -211,6 +232,19 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
return Array.from(mentionedDocs.values());
|
||||
}, [mentionedDocs]);
|
||||
|
||||
const syncEditorState = useCallback(
|
||||
(docsOverride?: Map<string, MentionedDocument>) => {
|
||||
const docs = docsOverride
|
||||
? Array.from(docsOverride.values())
|
||||
: Array.from(mentionedDocs.values());
|
||||
const text = getText();
|
||||
const empty = text.length === 0 && docs.length === 0;
|
||||
setIsEmpty(empty);
|
||||
onChange?.(text, docs);
|
||||
},
|
||||
[getText, mentionedDocs, onChange]
|
||||
);
|
||||
|
||||
// Create a chip element for a document
|
||||
const createChipElement = useCallback(
|
||||
(doc: MentionedDocument): HTMLSpanElement => {
|
||||
|
|
@ -246,10 +280,11 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
chip.remove();
|
||||
const docKey = `${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
|
||||
const docKey = getMentionDocKey(doc);
|
||||
setMentionedDocs((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(docKey);
|
||||
syncEditorState(next);
|
||||
return next;
|
||||
});
|
||||
onDocumentRemove?.(doc.id, doc.document_type);
|
||||
|
|
@ -294,13 +329,17 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
|
||||
return chip;
|
||||
},
|
||||
[focusAtEnd, onDocumentRemove]
|
||||
[focusAtEnd, onDocumentRemove, syncEditorState]
|
||||
);
|
||||
|
||||
// Insert a document chip at the current cursor position
|
||||
const insertDocumentChip = useCallback(
|
||||
(doc: Pick<Document, "id" | "title" | "document_type">) => {
|
||||
(
|
||||
doc: Pick<Document, "id" | "title" | "document_type">,
|
||||
options?: { removeTriggerText?: boolean }
|
||||
) => {
|
||||
if (!editorRef.current) return;
|
||||
const removeTriggerText = options?.removeTriggerText ?? true;
|
||||
|
||||
// Validate required fields for type safety
|
||||
if (typeof doc.id !== "number" || typeof doc.title !== "string") {
|
||||
|
|
@ -315,25 +354,51 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
};
|
||||
|
||||
// Add to mentioned docs map using unique key
|
||||
const docKey = `${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
|
||||
const docKey = getMentionDocKey(doc);
|
||||
setMentionedDocs((prev) => new Map(prev).set(docKey, mentionDoc));
|
||||
const nextDocs = new Map(mentionedDocs);
|
||||
nextDocs.set(docKey, mentionDoc);
|
||||
|
||||
// Find and remove the @query text
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
// No selection, just append
|
||||
const hasActiveSelection = isSelectionInsideEditor(selection);
|
||||
const resolvedSelection = hasActiveSelection ? selection : restoreRememberedSelection();
|
||||
if (
|
||||
!resolvedSelection ||
|
||||
resolvedSelection.rangeCount === 0 ||
|
||||
!isSelectionInsideEditor(resolvedSelection)
|
||||
) {
|
||||
// No valid in-editor selection: deterministically insert at end.
|
||||
editorRef.current.focus();
|
||||
const endSelection = window.getSelection();
|
||||
if (!endSelection) return;
|
||||
const endRange = document.createRange();
|
||||
endRange.selectNodeContents(editorRef.current);
|
||||
endRange.collapse(false);
|
||||
endSelection.removeAllRanges();
|
||||
endSelection.addRange(endRange);
|
||||
|
||||
const chip = createChipElement(mentionDoc);
|
||||
editorRef.current.appendChild(chip);
|
||||
editorRef.current.appendChild(document.createTextNode(" "));
|
||||
focusAtEnd();
|
||||
endRange.insertNode(chip);
|
||||
endRange.setStartAfter(chip);
|
||||
endRange.collapse(true);
|
||||
const space = document.createTextNode(" ");
|
||||
endRange.insertNode(space);
|
||||
endRange.setStartAfter(space);
|
||||
endRange.collapse(true);
|
||||
endSelection.removeAllRanges();
|
||||
endSelection.addRange(endRange);
|
||||
|
||||
syncEditorState(nextDocs);
|
||||
rememberSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the @ symbol before the cursor and remove it along with any query text
|
||||
const range = selection.getRangeAt(0);
|
||||
const range = resolvedSelection.getRangeAt(0);
|
||||
const textNode = range.startContainer;
|
||||
|
||||
if (textNode.nodeType === Node.TEXT_NODE) {
|
||||
if (textNode.nodeType === Node.TEXT_NODE && removeTriggerText) {
|
||||
const text = textNode.textContent || "";
|
||||
const cursorPos = range.startOffset;
|
||||
|
||||
|
|
@ -369,8 +434,9 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
const newRange = document.createRange();
|
||||
newRange.setStart(afterNode, 1);
|
||||
newRange.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
resolvedSelection.removeAllRanges();
|
||||
resolvedSelection.addRange(newRange);
|
||||
rememberSelection();
|
||||
}
|
||||
} else {
|
||||
// No @ found, just insert at cursor
|
||||
|
|
@ -384,48 +450,56 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
range.insertNode(space);
|
||||
range.setStartAfter(space);
|
||||
range.collapse(true);
|
||||
resolvedSelection.removeAllRanges();
|
||||
resolvedSelection.addRange(range);
|
||||
rememberSelection();
|
||||
}
|
||||
} else {
|
||||
// Not in a text node, append to editor
|
||||
// Either explicit non-trigger insertion or no @query present.
|
||||
const chip = createChipElement(mentionDoc);
|
||||
editorRef.current.appendChild(chip);
|
||||
editorRef.current.appendChild(document.createTextNode(" "));
|
||||
focusAtEnd();
|
||||
range.insertNode(chip);
|
||||
range.setStartAfter(chip);
|
||||
range.collapse(true);
|
||||
const space = document.createTextNode(" ");
|
||||
range.insertNode(space);
|
||||
range.setStartAfter(space);
|
||||
range.collapse(true);
|
||||
resolvedSelection.removeAllRanges();
|
||||
resolvedSelection.addRange(range);
|
||||
rememberSelection();
|
||||
}
|
||||
|
||||
// Update empty state
|
||||
setIsEmpty(false);
|
||||
|
||||
// Trigger onChange
|
||||
if (onChange) {
|
||||
setTimeout(() => {
|
||||
onChange(getText(), getMentionedDocuments());
|
||||
}, 0);
|
||||
}
|
||||
syncEditorState(nextDocs);
|
||||
},
|
||||
[createChipElement, focusAtEnd, getText, getMentionedDocuments, onChange]
|
||||
[
|
||||
createChipElement,
|
||||
isSelectionInsideEditor,
|
||||
mentionedDocs,
|
||||
rememberSelection,
|
||||
restoreRememberedSelection,
|
||||
syncEditorState,
|
||||
]
|
||||
);
|
||||
|
||||
// Clear the editor
|
||||
const clear = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.innerHTML = "";
|
||||
setIsEmpty(true);
|
||||
setMentionedDocs(new Map());
|
||||
const emptyDocs = new Map<string, MentionedDocument>();
|
||||
setMentionedDocs(emptyDocs);
|
||||
syncEditorState(emptyDocs);
|
||||
}
|
||||
}, []);
|
||||
}, [syncEditorState]);
|
||||
|
||||
// Replace editor content with plain text and place cursor at end
|
||||
const setText = useCallback(
|
||||
(text: string) => {
|
||||
if (!editorRef.current) return;
|
||||
editorRef.current.innerText = text;
|
||||
const empty = text.length === 0;
|
||||
setIsEmpty(empty);
|
||||
onChange?.(text, Array.from(mentionedDocs.values()));
|
||||
syncEditorState();
|
||||
focusAtEnd();
|
||||
},
|
||||
[focusAtEnd, onChange, mentionedDocs]
|
||||
[focusAtEnd, syncEditorState]
|
||||
);
|
||||
|
||||
const setDocumentChipStatus = useCallback(
|
||||
|
|
@ -473,7 +547,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
const removeDocumentChip = useCallback(
|
||||
(docId: number, docType?: string) => {
|
||||
if (!editorRef.current) return;
|
||||
const chipKey = `${docType ?? "UNKNOWN"}:${docId}`;
|
||||
const chipKey = getMentionDocKey({ id: docId, document_type: docType });
|
||||
const chips = editorRef.current.querySelectorAll<HTMLSpanElement>(
|
||||
`span[${CHIP_DATA_ATTR}="true"]`
|
||||
);
|
||||
|
|
@ -486,14 +560,11 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
setMentionedDocs((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(chipKey);
|
||||
syncEditorState(next);
|
||||
return next;
|
||||
});
|
||||
|
||||
const text = getText();
|
||||
const empty = text.length === 0 && mentionedDocs.size <= 1;
|
||||
setIsEmpty(empty);
|
||||
},
|
||||
[getText, mentionedDocs.size]
|
||||
[syncEditorState]
|
||||
);
|
||||
|
||||
// Expose methods via ref
|
||||
|
|
@ -594,6 +665,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
|
||||
// Notify parent of change
|
||||
onChange?.(text, Array.from(mentionedDocs.values()));
|
||||
rememberSelection();
|
||||
}, [
|
||||
getText,
|
||||
mentionedDocs,
|
||||
|
|
@ -602,6 +674,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
onMentionClose,
|
||||
onActionTrigger,
|
||||
onActionClose,
|
||||
rememberSelection,
|
||||
]);
|
||||
|
||||
// Handle keydown
|
||||
|
|
@ -639,10 +712,14 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
const chipDocType = getChipDocType(prevSibling);
|
||||
if (chipId !== null) {
|
||||
prevSibling.remove();
|
||||
const chipKey = `${chipDocType}:${chipId}`;
|
||||
const chipKey = getMentionDocKey({
|
||||
id: chipId,
|
||||
document_type: chipDocType,
|
||||
});
|
||||
setMentionedDocs((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(chipKey);
|
||||
syncEditorState(next);
|
||||
return next;
|
||||
});
|
||||
// Notify parent that a document was removed
|
||||
|
|
@ -676,10 +753,14 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
const chipDocType = getChipDocType(prevChild);
|
||||
if (chipId !== null) {
|
||||
prevChild.remove();
|
||||
const chipKey = `${chipDocType}:${chipId}`;
|
||||
const chipKey = getMentionDocKey({
|
||||
id: chipId,
|
||||
document_type: chipDocType,
|
||||
});
|
||||
setMentionedDocs((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(chipKey);
|
||||
syncEditorState(next);
|
||||
return next;
|
||||
});
|
||||
// Notify parent that a document was removed
|
||||
|
|
@ -691,7 +772,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
}
|
||||
}
|
||||
},
|
||||
[onKeyDown, onSubmit, onDocumentRemove, onMentionClose]
|
||||
[onKeyDown, onSubmit, onDocumentRemove, onMentionClose, syncEditorState]
|
||||
);
|
||||
|
||||
// Handle paste - strip formatting
|
||||
|
|
@ -713,7 +794,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
{/** biome-ignore lint/a11y/useSemanticElements: <not important> */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: contenteditable mention editor requires a div for inline chips */}
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable={!disabled}
|
||||
|
|
@ -724,6 +805,9 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
onPaste={handlePaste}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onKeyUp={rememberSelection}
|
||||
onMouseUp={rememberSelection}
|
||||
onBlur={rememberSelection}
|
||||
className={cn(
|
||||
"min-h-[24px] max-h-32 overflow-y-auto",
|
||||
"text-sm outline-none",
|
||||
|
|
@ -733,9 +817,6 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
)}
|
||||
style={{ wordBreak: "break-word" }}
|
||||
data-placeholder={placeholder}
|
||||
aria-label="Message input with inline mentions"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
/>
|
||||
{/* Placeholder with fade animation on change */}
|
||||
{isEmpty && (
|
||||
|
|
|
|||
|
|
@ -39,12 +39,10 @@ import {
|
|||
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||
import {
|
||||
mentionedDocumentsAtom,
|
||||
sidebarSelectedDocumentsAtom,
|
||||
} from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
|
||||
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||
import {
|
||||
globalNewLLMConfigsAtom,
|
||||
|
|
@ -91,6 +89,7 @@ import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
|||
import { useCommentsSync } from "@/hooks/use-comments-sync";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||
import { captureDisplayToPngDataUrl } from "@/lib/chat/display-media-capture";
|
||||
import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/lib/layout-events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -364,12 +363,14 @@ const ClipboardChip: FC<{ text: string; onDismiss: () => void }> = ({ text, onDi
|
|||
const Composer: FC = () => {
|
||||
// Document mention state (atoms persist across component remounts)
|
||||
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
||||
const setSidebarDocs = useSetAtom(sidebarSelectedDocumentsAtom);
|
||||
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
|
||||
const [showPromptPicker, setShowPromptPicker] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState("");
|
||||
const [actionQuery, setActionQuery] = useState("");
|
||||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||
const prevMentionedDocsRef = useRef<
|
||||
Map<string, Pick<Document, "id" | "title" | "document_type">>
|
||||
>(new Map());
|
||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||
const promptPickerRef = useRef<PromptPickerRef>(null);
|
||||
const viewportRef = useRef<Element | null>(null);
|
||||
|
|
@ -605,7 +606,6 @@ const Composer: FC = () => {
|
|||
aui.composer().send();
|
||||
editorRef.current?.clear();
|
||||
setMentionedDocuments([]);
|
||||
setSidebarDocs([]);
|
||||
|
||||
// With turnAnchor="top", ViewportSlack adds min-height to the last
|
||||
// assistant message so that scrolling-to-bottom actually positions the
|
||||
|
|
@ -652,43 +652,71 @@ const Composer: FC = () => {
|
|||
clipboardInitialText,
|
||||
aui,
|
||||
setMentionedDocuments,
|
||||
setSidebarDocs,
|
||||
threadViewportStore,
|
||||
]);
|
||||
|
||||
const handleDocumentRemove = useCallback(
|
||||
(docId: number, docType?: string) => {
|
||||
setMentionedDocuments((prev) =>
|
||||
prev.filter((doc) => !(doc.id === docId && doc.document_type === docType))
|
||||
);
|
||||
setMentionedDocuments((prev) => {
|
||||
if (!docType) {
|
||||
// Defensive fallback: keep UI in sync even when chip type is unavailable.
|
||||
return prev.filter((doc) => doc.id !== docId);
|
||||
}
|
||||
const removedKey = getMentionDocKey({ id: docId, document_type: docType });
|
||||
return prev.filter((doc) => getMentionDocKey(doc) !== removedKey);
|
||||
});
|
||||
},
|
||||
[setMentionedDocuments]
|
||||
);
|
||||
|
||||
const handleDocumentsMention = useCallback(
|
||||
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
|
||||
const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
|
||||
const newDocs = documents.filter(
|
||||
(doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`)
|
||||
);
|
||||
const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? [];
|
||||
const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc)));
|
||||
|
||||
for (const doc of newDocs) {
|
||||
for (const doc of documents) {
|
||||
const key = getMentionDocKey(doc);
|
||||
if (editorDocKeys.has(key)) continue;
|
||||
editorRef.current?.insertDocumentChip(doc);
|
||||
}
|
||||
|
||||
setMentionedDocuments((prev) => {
|
||||
const existingKeySet = new Set(prev.map((d) => `${d.document_type}:${d.id}`));
|
||||
const uniqueNewDocs = documents.filter(
|
||||
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
|
||||
);
|
||||
const existingKeySet = new Set(prev.map((d) => getMentionDocKey(d)));
|
||||
const uniqueNewDocs = documents.filter((doc) => !existingKeySet.has(getMentionDocKey(doc)));
|
||||
return [...prev, ...uniqueNewDocs];
|
||||
});
|
||||
|
||||
setMentionQuery("");
|
||||
},
|
||||
[mentionedDocuments, setMentionedDocuments]
|
||||
[setMentionedDocuments]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
const nextDocsMap = new Map(mentionedDocuments.map((doc) => [getMentionDocKey(doc), doc]));
|
||||
const prevDocsMap = prevMentionedDocsRef.current;
|
||||
|
||||
if (!editor) {
|
||||
prevMentionedDocsRef.current = nextDocsMap;
|
||||
return;
|
||||
}
|
||||
|
||||
const editorKeys = new Set(editor.getMentionedDocuments().map(getMentionDocKey));
|
||||
|
||||
for (const [key, doc] of nextDocsMap) {
|
||||
if (prevDocsMap.has(key) || editorKeys.has(key)) continue;
|
||||
editor.insertDocumentChip(doc, { removeTriggerText: false });
|
||||
}
|
||||
|
||||
for (const [key, doc] of prevDocsMap) {
|
||||
if (!nextDocsMap.has(key)) {
|
||||
editor.removeDocumentChip(doc.id, doc.document_type);
|
||||
}
|
||||
}
|
||||
|
||||
prevMentionedDocsRef.current = nextDocsMap;
|
||||
}, [mentionedDocuments]);
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
|
||||
<ChatSessionStatus
|
||||
|
|
@ -767,8 +795,6 @@ interface ComposerActionProps {
|
|||
|
||||
const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false }) => {
|
||||
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
|
||||
const sidebarDocs = useAtomValue(sidebarSelectedDocumentsAtom);
|
||||
const setDocumentsSidebarOpen = useSetAtom(documentsSidebarOpenAtom);
|
||||
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
||||
const [toolsPopoverOpen, setToolsPopoverOpen] = useState(false);
|
||||
const isDesktop = useMediaQuery("(min-width: 640px)");
|
||||
|
|
@ -1226,15 +1252,6 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
</AnimatePresence>
|
||||
</button>
|
||||
)}
|
||||
{sidebarDocs.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDocumentsSidebarOpen(true)}
|
||||
className="rounded-full border border-border/60 bg-accent/50 px-2.5 py-1 text-xs font-medium text-foreground/80 transition-colors hover:bg-accent"
|
||||
>
|
||||
{sidebarDocs.length} {sidebarDocs.length === 1 ? "source" : "sources"} selected
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!hasModelConfigured && (
|
||||
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { ActionBarPrimitive, AuiIf, MessagePrimitive, useAuiState } from "@assistant-ui/react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { CheckIcon, CopyIcon, FileText, Pencil } from "lucide-react";
|
||||
import { CheckIcon, CopyIcon, Pencil } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { type FC, useState } from "react";
|
||||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
|
||||
interface AuthorMetadata {
|
||||
displayName: string | null;
|
||||
|
|
@ -48,6 +49,19 @@ const UserAvatar: FC<AuthorMetadata> = ({ displayName, avatarUrl }) => {
|
|||
|
||||
export const UserMessage: FC = () => {
|
||||
const messageId = useAuiState(({ message }) => message?.id);
|
||||
const messageText = useAuiState(({ message }) =>
|
||||
(message?.content ?? [])
|
||||
.map((part) =>
|
||||
typeof part === "object" &&
|
||||
part !== null &&
|
||||
"type" in part &&
|
||||
(part as { type?: string }).type === "text" &&
|
||||
"text" in part
|
||||
? String((part as { text?: string }).text ?? "")
|
||||
: ""
|
||||
)
|
||||
.join("")
|
||||
);
|
||||
const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom);
|
||||
const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined;
|
||||
const metadata = useAuiState(({ message }) => message?.metadata);
|
||||
|
|
@ -63,22 +77,12 @@ export const UserMessage: FC = () => {
|
|||
<div className="col-start-2 min-w-0">
|
||||
<div className="aui-user-message-content-wrapper flex items-end gap-2">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
{mentionedDocs && mentionedDocs.length > 0 && (
|
||||
<div className="flex flex-wrap items-end gap-2 mb-2 justify-end">
|
||||
{mentionedDocs?.map((doc) => (
|
||||
<span
|
||||
key={`${doc.document_type}:${doc.id}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20"
|
||||
title={doc.title}
|
||||
>
|
||||
<FileText className="size-3" />
|
||||
<span className="max-w-[150px] truncate">{doc.title}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="aui-user-message-content wrap-break-word rounded-2xl bg-muted px-4 py-2.5 text-foreground">
|
||||
<MessagePrimitive.Parts />
|
||||
{mentionedDocs && mentionedDocs.length > 0 ? (
|
||||
<UserMessageWithMentionChips text={messageText} mentionedDocs={mentionedDocs} />
|
||||
) : (
|
||||
<MessagePrimitive.Parts />
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute right-0 top-full mt-1 z-10 opacity-100 pointer-events-auto md:opacity-0 md:pointer-events-none md:transition-opacity md:duration-200 md:delay-300 md:group-hover/user-msg:opacity-100 md:group-hover/user-msg:delay-0 md:group-hover/user-msg:pointer-events-auto">
|
||||
<UserActionBar />
|
||||
|
|
@ -95,6 +99,64 @@ export const UserMessage: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const UserMessageWithMentionChips: FC<{
|
||||
text: string;
|
||||
mentionedDocs: { id: number; title: string; document_type: string }[];
|
||||
}> = ({ text, mentionedDocs }) => {
|
||||
type Segment =
|
||||
| { type: "text"; value: string; start: number }
|
||||
| { type: "mention"; doc: { id: number; title: string; document_type: string }; start: number };
|
||||
|
||||
const tokens = mentionedDocs
|
||||
.map((doc) => ({ doc, token: `@${doc.title}` }))
|
||||
.sort((a, b) => b.token.length - a.token.length);
|
||||
|
||||
const segments: Segment[] = [];
|
||||
let i = 0;
|
||||
let buffer = "";
|
||||
let bufferStart = 0;
|
||||
while (i < text.length) {
|
||||
const tokenMatch = tokens.find(({ token }) => text.startsWith(token, i));
|
||||
if (tokenMatch) {
|
||||
if (buffer) {
|
||||
segments.push({ type: "text", value: buffer, start: bufferStart });
|
||||
buffer = "";
|
||||
}
|
||||
segments.push({ type: "mention", doc: tokenMatch.doc, start: i });
|
||||
i += tokenMatch.token.length;
|
||||
bufferStart = i;
|
||||
continue;
|
||||
}
|
||||
if (!buffer) bufferStart = i;
|
||||
buffer += text[i];
|
||||
i += 1;
|
||||
}
|
||||
if (buffer) {
|
||||
segments.push({ type: "text", value: buffer, start: bufferStart });
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="whitespace-pre-wrap break-words">
|
||||
{segments.map((segment) =>
|
||||
segment.type === "text" ? (
|
||||
<span key={`txt-${segment.start}`}>{segment.value}</span>
|
||||
) : (
|
||||
<span
|
||||
key={`mention-${segment.doc.document_type}:${segment.doc.id}-${segment.start}`}
|
||||
className="inline-flex items-center gap-1 mx-0.5 px-1 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary/60 select-none align-baseline"
|
||||
title={segment.doc.title}
|
||||
>
|
||||
<span className="flex items-center text-muted-foreground">
|
||||
{getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "h-3 w-3")}
|
||||
</span>
|
||||
<span className="max-w-[120px] truncate">{segment.doc.title}</span>
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const UserActionBar: FC = () => {
|
||||
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue