mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
Merge pull request #1439 from AnishSarkar22/fix/mention-documents
feat: improve composer mentions and connector account selection
This commit is contained in:
commit
820f541f08
22 changed files with 1533 additions and 783 deletions
|
|
@ -229,6 +229,20 @@ export const COMPOSIO_CONNECTORS = [
|
|||
},
|
||||
] as const;
|
||||
|
||||
export const CONNECTOR_DISPLAY_DEFINITIONS = [
|
||||
...OAUTH_CONNECTORS,
|
||||
...CRAWLERS,
|
||||
...OTHER_CONNECTORS,
|
||||
...COMPOSIO_CONNECTORS,
|
||||
] as const;
|
||||
|
||||
export function getConnectorTitle(connectorType: string): string {
|
||||
return (
|
||||
CONNECTOR_DISPLAY_DEFINITIONS.find((connector) => connector.connectorType === connectorType)
|
||||
?.title ?? connectorType
|
||||
);
|
||||
}
|
||||
|
||||
// Composio Toolkits (available integrations via Composio)
|
||||
export const COMPOSIO_TOOLKITS = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Folder as FolderIcon, X as XIcon } from "lucide-react";
|
||||
import { Folder as FolderIcon, Plug as PlugIcon, X as XIcon } from "lucide-react";
|
||||
import type { NodeEntry, TElement } from "platejs";
|
||||
import type { PlateElementProps } from "platejs/react";
|
||||
import {
|
||||
|
|
@ -20,31 +20,44 @@ import {
|
|||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { FOLDER_MENTION_DOCUMENT_TYPE } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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";
|
||||
|
||||
export type MentionKind = "doc" | "folder";
|
||||
export type MentionKind = "doc" | "folder" | "connector";
|
||||
|
||||
export interface MentionedDocument {
|
||||
id: number;
|
||||
title: string;
|
||||
document_type?: string;
|
||||
kind: MentionKind;
|
||||
connector_type?: string;
|
||||
account_name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input shape for inserting a chip. ``kind`` defaults to ``"doc"``.
|
||||
* Folder chips default ``document_type`` to ``FOLDER_MENTION_DOCUMENT_TYPE``
|
||||
* so the dedup key never collides with a doc chip sharing the same id.
|
||||
*/
|
||||
export type MentionChipInput = {
|
||||
id: number;
|
||||
title: string;
|
||||
document_type?: string;
|
||||
kind?: MentionKind;
|
||||
connector_type?: string;
|
||||
account_name?: string;
|
||||
};
|
||||
|
||||
export type SuggestionAnchorRect = {
|
||||
left: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
};
|
||||
|
||||
export type SuggestionTriggerInfo = {
|
||||
query: string;
|
||||
anchorRect: SuggestionAnchorRect | null;
|
||||
};
|
||||
|
||||
export interface InlineMentionEditorRef {
|
||||
|
|
@ -62,7 +75,12 @@ export interface InlineMentionEditorRef {
|
|||
doc: Pick<Document, "id" | "title" | "document_type">,
|
||||
options?: { removeTriggerText?: boolean }
|
||||
) => void;
|
||||
removeDocumentChip: (docId: number, docType?: string) => void;
|
||||
removeDocumentChip: (
|
||||
docId: number,
|
||||
docType?: string,
|
||||
kind?: MentionKind,
|
||||
connectorType?: string
|
||||
) => void;
|
||||
setDocumentChipStatus: (
|
||||
docId: number,
|
||||
docType: string | undefined,
|
||||
|
|
@ -73,13 +91,13 @@ export interface InlineMentionEditorRef {
|
|||
|
||||
interface InlineMentionEditorProps {
|
||||
placeholder?: string;
|
||||
onMentionTrigger?: (query: string) => void;
|
||||
onMentionTrigger?: (trigger: SuggestionTriggerInfo) => void;
|
||||
onMentionClose?: () => void;
|
||||
onActionTrigger?: (query: string) => void;
|
||||
onActionTrigger?: (trigger: SuggestionTriggerInfo) => void;
|
||||
onActionClose?: () => void;
|
||||
onSubmit?: () => void;
|
||||
onChange?: (text: string, docs: MentionedDocument[]) => void;
|
||||
onDocumentRemove?: (docId: number, docType?: string) => void;
|
||||
onDocumentRemove?: (docId: number, docType?: string, kind?: MentionKind, connectorType?: string) => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
|
|
@ -95,6 +113,8 @@ type MentionElementNode = {
|
|||
document_type?: string;
|
||||
/** Discriminator; defaults to ``"doc"`` for legacy nodes. */
|
||||
kind?: MentionKind;
|
||||
connector_type?: string;
|
||||
account_name?: string;
|
||||
statusLabel?: string | null;
|
||||
statusKind?: MentionStatusKind;
|
||||
children: [{ text: "" }];
|
||||
|
|
@ -117,7 +137,12 @@ const EMPTY_VALUE: ComposerValue = [{ type: "p", children: [{ text: "" }] }];
|
|||
* the X button and Backspace go through the same call site.
|
||||
*/
|
||||
type MentionEditorContextValue = {
|
||||
removeChip: (docId: number, docType: string | undefined) => void;
|
||||
removeChip: (
|
||||
docId: number,
|
||||
docType: string | undefined,
|
||||
kind: MentionKind | undefined,
|
||||
connectorType: string | undefined
|
||||
) => void;
|
||||
};
|
||||
const MentionEditorContext = createContext<MentionEditorContextValue | null>(null);
|
||||
|
||||
|
|
@ -134,6 +159,7 @@ const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({
|
|||
: "text-amber-700";
|
||||
|
||||
const isFolder = element.kind === "folder";
|
||||
const isConnector = element.kind === "connector";
|
||||
const ctx = useContext(MentionEditorContext);
|
||||
|
||||
return (
|
||||
|
|
@ -144,24 +170,35 @@ const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({
|
|||
<span className="flex items-center justify-center transition-opacity group-hover:opacity-0">
|
||||
{isFolder ? (
|
||||
<FolderIcon className="h-3 w-3" />
|
||||
) : isConnector ? (
|
||||
getConnectorIcon(element.connector_type ?? element.document_type ?? "UNKNOWN", "h-3 w-3") ?? (
|
||||
<PlugIcon className="h-3 w-3" />
|
||||
)
|
||||
) : (
|
||||
getConnectorIcon(element.document_type ?? "UNKNOWN", "h-3 w-3")
|
||||
)}
|
||||
</span>
|
||||
{ctx ? (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Remove mention ${element.title}`}
|
||||
title={`Remove ${element.title}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
ctx.removeChip(element.id, element.document_type);
|
||||
ctx.removeChip(
|
||||
element.id,
|
||||
element.document_type,
|
||||
element.kind,
|
||||
element.connector_type
|
||||
);
|
||||
}}
|
||||
className="absolute inset-0 flex items-center justify-center rounded-sm opacity-0 transition-opacity hover:text-primary focus-visible:opacity-100 focus-visible:outline-none group-hover:opacity-100"
|
||||
className="absolute inset-0 size-3 rounded-sm p-0 opacity-0 transition-opacity hover:bg-transparent hover:text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-0 group-hover:opacity-100 [&_svg]:size-3"
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
</button>
|
||||
<XIcon />
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
|
|
@ -228,6 +265,8 @@ function getMentionedDocuments(value: ComposerValue): MentionedDocument[] {
|
|||
title: node.title,
|
||||
document_type: node.document_type,
|
||||
kind,
|
||||
connector_type: node.connector_type,
|
||||
account_name: node.account_name,
|
||||
};
|
||||
map.set(getMentionDocKey(doc), doc);
|
||||
}
|
||||
|
|
@ -299,6 +338,36 @@ function scanActiveTrigger(text: string, cursor: number) {
|
|||
return { triggerChar, query };
|
||||
}
|
||||
|
||||
function rectToAnchor(rect: DOMRect): SuggestionAnchorRect {
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
};
|
||||
}
|
||||
|
||||
function getSelectionAnchorRect(root: HTMLElement | null): SuggestionAnchorRect | null {
|
||||
if (!root || typeof window === "undefined") return null;
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0 || !selection.anchorNode) return null;
|
||||
if (!root.contains(selection.anchorNode)) return null;
|
||||
|
||||
const range = selection.getRangeAt(0).cloneRange();
|
||||
const rect = range.getClientRects()[0] ?? range.getBoundingClientRect();
|
||||
if (rect.width > 0 || rect.height > 0) return rectToAnchor(rect);
|
||||
|
||||
if (range.collapsed && range.startContainer.nodeType === Node.TEXT_NODE && range.startOffset > 0) {
|
||||
const fallbackRange = range.cloneRange();
|
||||
fallbackRange.setStart(range.startContainer, range.startOffset - 1);
|
||||
fallbackRange.setEnd(range.startContainer, range.startOffset);
|
||||
const fallbackRect = fallbackRange.getClientRects()[0] ?? fallbackRange.getBoundingClientRect();
|
||||
if (fallbackRect.width > 0 || fallbackRect.height > 0) return rectToAnchor(fallbackRect);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMentionEditorProps>(
|
||||
(
|
||||
{
|
||||
|
|
@ -360,14 +429,19 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
return;
|
||||
}
|
||||
|
||||
const triggerInfo: SuggestionTriggerInfo = {
|
||||
query: trigger.query,
|
||||
anchorRect: getSelectionAnchorRect(editableRef.current),
|
||||
};
|
||||
|
||||
if (trigger.triggerChar === "@") {
|
||||
onMentionTrigger?.(trigger.query);
|
||||
onActionClose?.();
|
||||
onMentionTrigger?.(triggerInfo);
|
||||
return;
|
||||
}
|
||||
|
||||
onActionTrigger?.(trigger.query);
|
||||
onMentionClose?.();
|
||||
onActionTrigger?.(triggerInfo);
|
||||
},
|
||||
[editor.selection, onActionClose, onActionTrigger, onChange, onMentionClose, onMentionTrigger]
|
||||
);
|
||||
|
|
@ -394,14 +468,14 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
|
||||
const removeTriggerText = options?.removeTriggerText ?? true;
|
||||
const kind: MentionKind = mention.kind ?? "doc";
|
||||
const document_type =
|
||||
mention.document_type ?? (kind === "folder" ? FOLDER_MENTION_DOCUMENT_TYPE : undefined);
|
||||
const mentionNode: MentionElementNode = {
|
||||
type: MENTION_TYPE,
|
||||
id: mention.id,
|
||||
title: mention.title,
|
||||
document_type,
|
||||
document_type: mention.document_type,
|
||||
kind,
|
||||
connector_type: mention.connector_type,
|
||||
account_name: mention.account_name,
|
||||
children: [{ text: "" }],
|
||||
};
|
||||
|
||||
|
|
@ -457,17 +531,33 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
[insertMentionChip]
|
||||
);
|
||||
|
||||
// Remove chip(s) matching (id, document_type). Iterates in
|
||||
// Remove chip(s) matching the mention identity. Iterates in
|
||||
// descending path order so removing one entry can't invalidate
|
||||
// later paths. Chips are deduped today, so this typically runs
|
||||
// at most once.
|
||||
const removeDocumentChip = useCallback(
|
||||
(docId: number, docType?: string) => {
|
||||
(docId: number, docType?: string, kind?: MentionKind, connectorType?: string) => {
|
||||
const match = (n: unknown) => {
|
||||
if (!n || typeof n !== "object" || !("type" in n)) return false;
|
||||
const node = n as MentionElementNode;
|
||||
if (node.type !== MENTION_TYPE) return false;
|
||||
if (node.id !== docId) return false;
|
||||
if (kind) {
|
||||
return (
|
||||
getMentionDocKey({
|
||||
id: node.id,
|
||||
kind: node.kind ?? "doc",
|
||||
document_type: node.document_type,
|
||||
connector_type: node.connector_type,
|
||||
}) ===
|
||||
getMentionDocKey({
|
||||
id: docId,
|
||||
kind,
|
||||
document_type: docType,
|
||||
connector_type: connectorType,
|
||||
})
|
||||
);
|
||||
}
|
||||
return (node.document_type ?? "UNKNOWN") === (docType ?? "UNKNOWN");
|
||||
};
|
||||
|
||||
|
|
@ -485,9 +575,14 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
// Single removal call site for Backspace and the X button so the
|
||||
// two can never diverge (e.g. one forgetting to notify the parent).
|
||||
const removeChip = useCallback(
|
||||
(docId: number, docType: string | undefined) => {
|
||||
removeDocumentChip(docId, docType);
|
||||
onDocumentRemove?.(docId, docType);
|
||||
(
|
||||
docId: number,
|
||||
docType: string | undefined,
|
||||
kind: MentionKind | undefined,
|
||||
connectorType: string | undefined
|
||||
) => {
|
||||
removeDocumentChip(docId, docType, kind, connectorType);
|
||||
onDocumentRemove?.(docId, docType, kind, connectorType);
|
||||
},
|
||||
[onDocumentRemove, removeDocumentChip]
|
||||
);
|
||||
|
|
@ -610,7 +705,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
if (!isMentionNode(prev)) return;
|
||||
|
||||
e.preventDefault();
|
||||
removeChip(prev.id, prev.document_type);
|
||||
removeChip(prev.id, prev.document_type, prev.kind, prev.connector_type);
|
||||
},
|
||||
[editor.selection, getCurrentValue, onKeyDown, onSubmit, removeChip]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import type { MouseEventHandler, ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -60,13 +61,15 @@ export function MentionChip({
|
|||
const isInteractive = Boolean(onClick) && !disabled;
|
||||
|
||||
const chip = (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel ?? label}
|
||||
className={cn(
|
||||
"inline-flex h-5 items-center gap-1 rounded bg-primary/10 px-1 align-middle text-xs font-bold text-primary/60 leading-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
"h-5 gap-1 rounded bg-primary/10 px-1 align-middle text-xs font-bold text-primary/60 leading-none hover:bg-primary/10 hover:text-primary/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
isInteractive ? "cursor-pointer" : "cursor-default",
|
||||
disabled && "opacity-60",
|
||||
className
|
||||
|
|
@ -74,7 +77,7 @@ export function MentionChip({
|
|||
>
|
||||
<span className="inline-flex shrink-0 text-muted-foreground">{icon}</span>
|
||||
<span className="max-w-[120px] truncate leading-none">{label}</span>
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!tooltip) return chip;
|
||||
|
|
|
|||
|
|
@ -62,13 +62,17 @@ import {
|
|||
InlineMentionEditor,
|
||||
type InlineMentionEditorRef,
|
||||
type MentionedDocument,
|
||||
type SuggestionAnchorRect,
|
||||
type SuggestionTriggerInfo,
|
||||
} from "@/components/assistant-ui/inline-mention-editor";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { UserMessage } from "@/components/assistant-ui/user-message";
|
||||
import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer-suggestion-popup";
|
||||
import {
|
||||
DocumentMentionPicker,
|
||||
promoteRecentMention,
|
||||
type DocumentMentionPickerRef,
|
||||
} from "@/components/new-chat/document-mention-picker";
|
||||
} from "../new-chat/document-mention-picker";
|
||||
import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
|
||||
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -90,6 +94,7 @@ import {
|
|||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Popover, PopoverAnchor } from "@/components/ui/popover";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
|
|
@ -110,6 +115,34 @@ import { cn } from "@/lib/utils";
|
|||
|
||||
const COMPOSER_PLACEHOLDER = "Ask anything, type / for prompts, type @ to mention docs";
|
||||
|
||||
type ComposerSuggestionAnchorPoint = {
|
||||
left: number;
|
||||
top: number;
|
||||
};
|
||||
|
||||
function ComposerSuggestionAnchor({ point }: { point: ComposerSuggestionAnchorPoint }) {
|
||||
return (
|
||||
<PopoverAnchor
|
||||
className="pointer-events-none fixed size-0"
|
||||
style={{
|
||||
left: point.left,
|
||||
top: point.top,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getComposerSuggestionAnchorPoint(
|
||||
triggerRect: SuggestionAnchorRect | null,
|
||||
side: "top" | "bottom"
|
||||
): ComposerSuggestionAnchorPoint | null {
|
||||
if (!triggerRect) return null;
|
||||
return {
|
||||
left: triggerRect.left,
|
||||
top: side === "bottom" ? triggerRect.bottom : triggerRect.top,
|
||||
};
|
||||
}
|
||||
|
||||
export const Thread: FC = () => {
|
||||
return <ThreadContent />;
|
||||
};
|
||||
|
|
@ -409,6 +442,8 @@ const Composer: FC = () => {
|
|||
const [showPromptPicker, setShowPromptPicker] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState("");
|
||||
const [actionQuery, setActionQuery] = useState("");
|
||||
const [suggestionAnchorPoint, setSuggestionAnchorPoint] =
|
||||
useState<ComposerSuggestionAnchorPoint | null>(null);
|
||||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||
const prevMentionedDocsRef = useRef<Map<string, MentionedDocumentInfo>>(new Map());
|
||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||
|
|
@ -489,6 +524,7 @@ const Composer: FC = () => {
|
|||
lastSeenSlideoutTickRef.current = slideoutOpenedTick;
|
||||
setShowDocumentPopover(false);
|
||||
setMentionQuery("");
|
||||
setSuggestionAnchorPoint(null);
|
||||
}, [slideoutOpenedTick]);
|
||||
|
||||
// Sync editor text into assistant-ui's composer and mirror the chip
|
||||
|
|
@ -507,44 +543,96 @@ const Composer: FC = () => {
|
|||
return prev;
|
||||
}
|
||||
}
|
||||
return docs.map<MentionedDocumentInfo>((d) => ({
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
// Atom requires a string; ``"UNKNOWN"`` matches the
|
||||
// sentinel ``getMentionDocKey`` and the editor's
|
||||
// match predicates use.
|
||||
document_type: d.document_type ?? "UNKNOWN",
|
||||
kind: d.kind,
|
||||
}));
|
||||
return docs.map<MentionedDocumentInfo>((d) => {
|
||||
if (d.kind === "connector") {
|
||||
return {
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
kind: "connector",
|
||||
connector_type: d.connector_type ?? "UNKNOWN",
|
||||
account_name: d.account_name ?? d.title,
|
||||
};
|
||||
}
|
||||
if (d.kind === "folder") {
|
||||
return {
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
kind: "folder",
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
document_type: d.document_type ?? "UNKNOWN",
|
||||
kind: "doc",
|
||||
};
|
||||
});
|
||||
});
|
||||
},
|
||||
[aui, setMentionedDocuments]
|
||||
);
|
||||
|
||||
const handleMentionTrigger = useCallback((query: string) => {
|
||||
const handleMentionTrigger = useCallback((trigger: SuggestionTriggerInfo) => {
|
||||
const anchorPoint = getComposerSuggestionAnchorPoint(trigger.anchorRect, "top");
|
||||
if (!anchorPoint) {
|
||||
setShowDocumentPopover(false);
|
||||
setMentionQuery("");
|
||||
setSuggestionAnchorPoint(null);
|
||||
return;
|
||||
}
|
||||
setSuggestionAnchorPoint((current) => current ?? anchorPoint);
|
||||
setShowDocumentPopover(true);
|
||||
setMentionQuery(query);
|
||||
setMentionQuery(trigger.query);
|
||||
}, []);
|
||||
|
||||
const handleMentionClose = useCallback(() => {
|
||||
if (showDocumentPopover) {
|
||||
setShowDocumentPopover(false);
|
||||
setMentionQuery("");
|
||||
setSuggestionAnchorPoint(null);
|
||||
}
|
||||
}, [showDocumentPopover]);
|
||||
|
||||
const handleActionTrigger = useCallback((query: string) => {
|
||||
setShowPromptPicker(true);
|
||||
setActionQuery(query);
|
||||
const handleDocumentPopoverOpenChange = useCallback((open: boolean) => {
|
||||
setShowDocumentPopover(open);
|
||||
if (!open) {
|
||||
setMentionQuery("");
|
||||
setSuggestionAnchorPoint(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleActionTrigger = useCallback((trigger: SuggestionTriggerInfo) => {
|
||||
const anchorPoint = getComposerSuggestionAnchorPoint(
|
||||
trigger.anchorRect,
|
||||
clipboardInitialText ? "bottom" : "top"
|
||||
);
|
||||
if (!anchorPoint) {
|
||||
setShowPromptPicker(false);
|
||||
setActionQuery("");
|
||||
setSuggestionAnchorPoint(null);
|
||||
return;
|
||||
}
|
||||
setSuggestionAnchorPoint((current) => current ?? anchorPoint);
|
||||
setShowPromptPicker(true);
|
||||
setActionQuery(trigger.query);
|
||||
}, [clipboardInitialText]);
|
||||
|
||||
const handleActionClose = useCallback(() => {
|
||||
if (showPromptPicker) {
|
||||
setShowPromptPicker(false);
|
||||
setActionQuery("");
|
||||
setSuggestionAnchorPoint(null);
|
||||
}
|
||||
}, [showPromptPicker]);
|
||||
|
||||
const handlePromptPickerOpenChange = useCallback((open: boolean) => {
|
||||
setShowPromptPicker(open);
|
||||
if (!open) {
|
||||
setActionQuery("");
|
||||
setSuggestionAnchorPoint(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleActionSelect = useCallback(
|
||||
(action: { name: string; prompt: string; mode: "transform" | "explore" }) => {
|
||||
let userText = editorRef.current?.getText() ?? "";
|
||||
|
|
@ -561,6 +649,7 @@ const Composer: FC = () => {
|
|||
aui.composer().setText(finalPrompt);
|
||||
setShowPromptPicker(false);
|
||||
setActionQuery("");
|
||||
setSuggestionAnchorPoint(null);
|
||||
},
|
||||
[actionQuery, aui]
|
||||
);
|
||||
|
|
@ -576,6 +665,7 @@ const Composer: FC = () => {
|
|||
aui.composer().setText(finalPrompt);
|
||||
setShowPromptPicker(false);
|
||||
setActionQuery("");
|
||||
setSuggestionAnchorPoint(null);
|
||||
setClipboardInitialText(undefined);
|
||||
},
|
||||
[clipboardInitialText, electronAPI, aui]
|
||||
|
|
@ -604,6 +694,7 @@ const Composer: FC = () => {
|
|||
e.preventDefault();
|
||||
setShowPromptPicker(false);
|
||||
setActionQuery("");
|
||||
setSuggestionAnchorPoint(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -625,8 +716,12 @@ const Composer: FC = () => {
|
|||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (documentPickerRef.current?.goBack()) {
|
||||
return;
|
||||
}
|
||||
setShowDocumentPopover(false);
|
||||
setMentionQuery("");
|
||||
setSuggestionAnchorPoint(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -659,13 +754,14 @@ const Composer: FC = () => {
|
|||
]);
|
||||
|
||||
const handleDocumentRemove = useCallback(
|
||||
(docId: number, docType?: string) => {
|
||||
(docId: number, docType?: string, kind?: "doc" | "folder" | "connector", connectorType?: string) => {
|
||||
setMentionedDocuments((prev) => {
|
||||
if (!docType) {
|
||||
// Fallback when chip type is unavailable.
|
||||
return prev.filter((doc) => doc.id !== docId);
|
||||
}
|
||||
const removedKey = getMentionDocKey({ id: docId, document_type: docType });
|
||||
const removedKey = getMentionDocKey({
|
||||
id: docId,
|
||||
document_type: docType,
|
||||
kind,
|
||||
connector_type: connectorType,
|
||||
});
|
||||
return prev.filter((doc) => getMentionDocKey(doc) !== removedKey);
|
||||
});
|
||||
},
|
||||
|
|
@ -673,6 +769,7 @@ const Composer: FC = () => {
|
|||
);
|
||||
|
||||
const handleDocumentsMention = useCallback((mentions: MentionedDocumentInfo[]) => {
|
||||
const parsedSearchSpaceId = Number(search_space_id);
|
||||
const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? [];
|
||||
const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc)));
|
||||
|
||||
|
|
@ -680,6 +777,9 @@ const Composer: FC = () => {
|
|||
const key = getMentionDocKey(mention);
|
||||
if (editorDocKeys.has(key)) continue;
|
||||
editorRef.current?.insertMentionChip(mention);
|
||||
if (Number.isFinite(parsedSearchSpaceId)) {
|
||||
promoteRecentMention(parsedSearchSpaceId, mention);
|
||||
}
|
||||
// Track within the loop so a duplicate-in-batch can't double-insert.
|
||||
editorDocKeys.add(key);
|
||||
}
|
||||
|
|
@ -687,7 +787,8 @@ const Composer: FC = () => {
|
|||
// Atom is reconciled by ``handleEditorChange`` via the editor's
|
||||
// onChange — no second write path here.
|
||||
setMentionQuery("");
|
||||
}, []);
|
||||
setSuggestionAnchorPoint(null);
|
||||
}, [search_space_id]);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
|
|
@ -708,7 +809,12 @@ const Composer: FC = () => {
|
|||
|
||||
for (const [key, doc] of prevDocsMap) {
|
||||
if (!nextDocsMap.has(key)) {
|
||||
editor.removeDocumentChip(doc.id, doc.document_type);
|
||||
editor.removeDocumentChip(
|
||||
doc.id,
|
||||
doc.kind === "doc" ? doc.document_type : undefined,
|
||||
doc.kind,
|
||||
doc.kind === "connector" ? doc.connector_type : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -723,39 +829,46 @@ const Composer: FC = () => {
|
|||
currentUserId={currentUser?.id ?? null}
|
||||
members={members ?? []}
|
||||
/>
|
||||
{showDocumentPopover && (
|
||||
<div className="absolute bottom-full left-0 z-[9999] mb-2">
|
||||
<DocumentMentionPicker
|
||||
ref={documentPickerRef}
|
||||
searchSpaceId={Number(search_space_id)}
|
||||
onSelectionChange={handleDocumentsMention}
|
||||
onDone={() => {
|
||||
setShowDocumentPopover(false);
|
||||
setMentionQuery("");
|
||||
}}
|
||||
initialSelectedDocuments={mentionedDocuments}
|
||||
externalSearch={mentionQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showPromptPicker && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 z-[9999]",
|
||||
clipboardInitialText ? "top-full mt-2" : "bottom-full mb-2"
|
||||
)}
|
||||
>
|
||||
<PromptPicker
|
||||
ref={promptPickerRef}
|
||||
onSelect={clipboardInitialText ? handleQuickAskSelect : handleActionSelect}
|
||||
onDone={() => {
|
||||
setShowPromptPicker(false);
|
||||
setActionQuery("");
|
||||
}}
|
||||
externalSearch={actionQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Popover open={showDocumentPopover} onOpenChange={handleDocumentPopoverOpenChange}>
|
||||
{suggestionAnchorPoint ? (
|
||||
<>
|
||||
<ComposerSuggestionAnchor point={suggestionAnchorPoint} />
|
||||
<ComposerSuggestionPopoverContent side="top">
|
||||
<DocumentMentionPicker
|
||||
ref={documentPickerRef}
|
||||
searchSpaceId={Number(search_space_id)}
|
||||
onSelectionChange={handleDocumentsMention}
|
||||
onDone={() => {
|
||||
setShowDocumentPopover(false);
|
||||
setMentionQuery("");
|
||||
setSuggestionAnchorPoint(null);
|
||||
}}
|
||||
initialSelectedDocuments={mentionedDocuments}
|
||||
externalSearch={mentionQuery}
|
||||
/>
|
||||
</ComposerSuggestionPopoverContent>
|
||||
</>
|
||||
) : null}
|
||||
</Popover>
|
||||
<Popover open={showPromptPicker} onOpenChange={handlePromptPickerOpenChange}>
|
||||
{suggestionAnchorPoint ? (
|
||||
<>
|
||||
<ComposerSuggestionAnchor point={suggestionAnchorPoint} />
|
||||
<ComposerSuggestionPopoverContent side={clipboardInitialText ? "bottom" : "top"}>
|
||||
<PromptPicker
|
||||
ref={promptPickerRef}
|
||||
onSelect={clipboardInitialText ? handleQuickAskSelect : handleActionSelect}
|
||||
onDone={() => {
|
||||
setShowPromptPicker(false);
|
||||
setActionQuery("");
|
||||
setSuggestionAnchorPoint(null);
|
||||
}}
|
||||
externalSearch={actionQuery}
|
||||
/>
|
||||
</ComposerSuggestionPopoverContent>
|
||||
</>
|
||||
) : null}
|
||||
</Popover>
|
||||
<div className="flex w-full flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -782,7 +895,7 @@ const Composer: FC = () => {
|
|||
onDocumentRemove={handleDocumentRemove}
|
||||
onSubmit={handleSubmit}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="min-h-[24px]"
|
||||
className="min-h-[24px] **:data-slate-placeholder:font-normal"
|
||||
/>
|
||||
</div>
|
||||
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
|
||||
|
|
@ -964,7 +1077,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
<Switch
|
||||
checked={isWebSearchEnabled}
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none h-4 w-7 shrink-0 border [&>span]:h-3 [&>span]:w-3 [&>span[data-state=checked]]:translate-x-3"
|
||||
className="pointer-events-none shrink-0 origin-right scale-[0.6]"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
|
@ -1037,9 +1150,10 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
>
|
||||
<div className="flex w-full items-center gap-3 px-4 py-2 hover:bg-accent hover:text-accent-foreground transition-colors">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
variant="ghost"
|
||||
className="h-auto min-w-0 flex-1 justify-start gap-3 p-0 text-left hover:bg-transparent hover:text-inherit"
|
||||
>
|
||||
{iconInfo ? (
|
||||
<Image
|
||||
|
|
@ -1061,7 +1175,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
) : (
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<Switch
|
||||
checked={!allDisabled}
|
||||
|
|
@ -1203,7 +1317,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
<Switch
|
||||
checked={isWebSearchEnabled}
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none h-4 w-7 shrink-0 border [&>span]:h-3 [&>span]:w-3 [&>span[data-state=checked]]:translate-x-3"
|
||||
className="pointer-events-none shrink-0 origin-right scale-[0.6]"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
|
@ -1253,7 +1367,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
<Switch
|
||||
checked={!isDisabled}
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none shrink-0 scale-[0.6]"
|
||||
className="pointer-events-none shrink-0 origin-right scale-[0.6]"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
|
@ -1305,7 +1419,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onCheckedChange={() => toggleToolGroup(toolNames)}
|
||||
className="shrink-0 scale-[0.6]"
|
||||
className="mr-2 shrink-0 origin-right scale-[0.6]"
|
||||
/>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
|
|
@ -1334,7 +1448,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
<Switch
|
||||
checked={!isDisabled}
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none shrink-0 scale-[0.6]"
|
||||
className="pointer-events-none shrink-0 origin-right scale-[0.6]"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
|
@ -1374,7 +1488,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
<Switch
|
||||
checked={!isDisabled}
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none shrink-0 scale-[0.6]"
|
||||
className="pointer-events-none shrink-0 origin-right scale-[0.6]"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
useMessagePartText,
|
||||
} from "@assistant-ui/react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { CheckIcon, CopyIcon, Folder as FolderIcon, Pencil } from "lucide-react";
|
||||
import { CheckIcon, CopyIcon, Folder as FolderIcon, Pencil, Plug } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useParams } from "next/navigation";
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
|
|
@ -100,8 +100,13 @@ const UserTextPart: FC = () => {
|
|||
return <span key={`txt-${segment.start}`}>{segment.value}</span>;
|
||||
}
|
||||
const isFolder = segment.doc.kind === "folder";
|
||||
const isConnector = segment.doc.kind === "connector";
|
||||
const icon = isFolder ? (
|
||||
<FolderIcon className="size-3.5" />
|
||||
) : isConnector ? (
|
||||
getConnectorIcon(segment.doc.connector_type, "size-3.5") ?? (
|
||||
<Plug className="size-3.5" />
|
||||
)
|
||||
) : (
|
||||
getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "size-3.5")
|
||||
);
|
||||
|
|
@ -110,8 +115,16 @@ const UserTextPart: FC = () => {
|
|||
key={`mention-${getMentionDocKey(segment.doc)}-${segment.start}`}
|
||||
icon={icon}
|
||||
label={segment.doc.title}
|
||||
tooltip={isFolder ? `Folder: ${segment.doc.title}` : segment.doc.title}
|
||||
onClick={isFolder ? undefined : () => handleOpenDoc(segment.doc.id, segment.doc.title)}
|
||||
tooltip={
|
||||
isFolder
|
||||
? `Folder: ${segment.doc.title}`
|
||||
: isConnector
|
||||
? `Connector account: ${segment.doc.title}`
|
||||
: segment.doc.title
|
||||
}
|
||||
onClick={
|
||||
isFolder || isConnector ? undefined : () => handleOpenDoc(segment.doc.id, segment.doc.title)
|
||||
}
|
||||
className="mx-0.5"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue