feat(web): enhance mention handling to support connectors and improve document key management

This commit is contained in:
Anish Sarkar 2026-05-26 21:52:04 +05:30
parent a41b16b73e
commit 2d134439ec
11 changed files with 160 additions and 164 deletions

View file

@ -203,13 +203,11 @@ class NewChatUserImagePart(BaseModel):
class MentionedDocumentInfo(BaseModel): class MentionedDocumentInfo(BaseModel):
"""Display metadata for a single ``@``-mention chip. """Display metadata for a single ``@``-mention chip.
Carries either a knowledge-base document or a knowledge-base folder Carries a knowledge-base document, knowledge-base folder, or
(discriminated by ``kind``). The full triple connected account (discriminated by ``kind``). Each kind uses its
``{id, title, document_type}`` is forwarded by the frontend mention real identity fields: docs carry ``document_type``, folders carry
chip so the server can embed it in the persisted user message only their folder id/title, and connectors carry ``connector_type``
``ContentPart[]`` (single ``mentioned-documents`` part). The plus account metadata.
history loader then renders the chips on reload without an extra
fetch mirrors the pre-refactor frontend ``persistUserTurn`` shape.
``kind`` defaults to ``"doc"`` so legacy clients and persisted rows ``kind`` defaults to ``"doc"`` so legacy clients and persisted rows
that predate folder mentions deserialise unchanged. that predate folder mentions deserialise unchanged.
@ -217,17 +215,14 @@ class MentionedDocumentInfo(BaseModel):
id: int id: int
title: str = Field(..., min_length=1, max_length=500) title: str = Field(..., min_length=1, max_length=500)
document_type: str = Field(..., min_length=1, max_length=100) document_type: str | None = Field(default=None, min_length=1, max_length=100)
kind: Literal["doc", "folder", "connector"] = Field( kind: Literal["doc", "folder", "connector"] = Field(
default="doc", default="doc",
description=( description=(
"Discriminator for the chip's referent: ``doc`` is a " "Discriminator for the chip's referent: ``doc`` is a "
"knowledge-base ``Document`` row, ``folder`` is a " "knowledge-base ``Document`` row, ``folder`` is a "
"knowledge-base ``Folder`` row, and ``connector`` is a " "knowledge-base ``Folder`` row, and ``connector`` is a "
"concrete connected account. Folders carry the sentinel " "concrete connected account."
"``document_type='FOLDER'`` to keep the frontend dedup key "
"``(kind:document_type:id)`` from colliding doc and folder "
"ids that happen to share an integer value."
), ),
) )
connector_type: str | None = Field(default=None, max_length=100) connector_type: str | None = Field(default=None, max_length=100)

View file

@ -109,7 +109,7 @@ def _build_user_content(
[{"type": "text", "text": "..."}, [{"type": "text", "text": "..."},
{"type": "image", "image": "data:..."}, {"type": "image", "image": "data:..."},
{"type": "mentioned-documents", "documents": [{"id": int, {"type": "mentioned-documents", "documents": [{"id": int,
"title": str, "document_type": str, "kind": "doc" | "folder"}, "title": str, "kind": "doc" | "folder" | "connector", ...},
...]}] ...]}]
The companion reader is The companion reader is
@ -117,8 +117,8 @@ def _build_user_content(
which expects exactly this shape keep them in sync. which expects exactly this shape keep them in sync.
``mentioned_documents``: optional list of mention chip dicts. Each ``mentioned_documents``: optional list of mention chip dicts. Each
dict may include a ``kind`` discriminator (``"doc"`` or ``"folder"``) dict may include a ``kind`` discriminator so the persisted
so the persisted ContentPart round-trips folder chips on reload. ContentPart round-trips folder and connector chips on reload.
When ``kind`` is missing we default to ``"doc"`` so legacy clients When ``kind`` is missing we default to ``"doc"`` so legacy clients
that haven't migrated to the union schema still persist correctly. that haven't migrated to the union schema still persist correctly.
""" """
@ -134,18 +134,23 @@ def _build_user_content(
doc_id = doc.get("id") doc_id = doc.get("id")
title = doc.get("title") title = doc.get("title")
document_type = doc.get("document_type") document_type = doc.get("document_type")
if doc_id is None or title is None or document_type is None:
continue
kind_raw = doc.get("kind", "doc") kind_raw = doc.get("kind", "doc")
kind = kind_raw if kind_raw in ("doc", "folder", "connector") else "doc" kind = kind_raw if kind_raw in ("doc", "folder", "connector") else "doc"
if doc_id is None or title is None:
continue
if kind == "doc" and document_type is None:
continue
item = { item = {
"id": doc_id, "id": doc_id,
"title": str(title), "title": str(title),
"document_type": str(document_type),
"kind": kind, "kind": kind,
} }
if document_type is not None:
item["document_type"] = str(document_type)
if kind == "connector": if kind == "connector":
connector_type = doc.get("connector_type") or document_type connector_type = doc.get("connector_type")
if connector_type is None:
continue
account_name = doc.get("account_name") or title account_name = doc.get("account_name") or title
item["connector_type"] = str(connector_type) item["connector_type"] = str(connector_type)
item["account_name"] = str(account_name) item["account_name"] = str(account_name)

View file

@ -73,6 +73,7 @@ import {
convertToThreadMessage, convertToThreadMessage,
reconcileInterruptedAssistantMessages, reconcileInterruptedAssistantMessages,
} from "@/lib/chat/message-utils"; } from "@/lib/chat/message-utils";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { import {
isPodcastGenerating, isPodcastGenerating,
looksLikePodcastRequest, looksLikePodcastRequest,
@ -206,7 +207,7 @@ function pairBundleToolCallIds(
const MentionedDocumentInfoSchema = z.object({ const MentionedDocumentInfoSchema = z.object({
id: z.number(), id: z.number(),
title: z.string(), title: z.string(),
document_type: z.string(), document_type: z.string().optional(),
kind: z kind: z
.union([z.literal("doc"), z.literal("folder"), z.literal("connector")]) .union([z.literal("doc"), z.literal("folder"), z.literal("connector")])
.optional() .optional()
@ -234,9 +235,8 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
return { return {
id: doc.id, id: doc.id,
title: doc.title, title: doc.title,
document_type: doc.document_type,
kind: "connector", kind: "connector",
connector_type: doc.connector_type ?? doc.document_type, connector_type: doc.connector_type ?? doc.document_type ?? "UNKNOWN",
account_name: doc.account_name ?? doc.title, account_name: doc.account_name ?? doc.title,
}; };
} }
@ -244,14 +244,13 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
return { return {
id: doc.id, id: doc.id,
title: doc.title, title: doc.title,
document_type: "FOLDER",
kind: "folder", kind: "folder",
}; };
} }
return { return {
id: doc.id, id: doc.id,
title: doc.title, title: doc.title,
document_type: doc.document_type, document_type: doc.document_type ?? "UNKNOWN",
kind: "doc", kind: "doc",
}; };
}); });
@ -957,15 +956,13 @@ export default function NewChatPage() {
}); });
// Collect unique mention chips for display & persistence. // Collect unique mention chips for display & persistence.
// Dedup key is ``kind:document_type:id`` so a folder and a // The ``kind`` field is forwarded to the backend
// doc with the same integer id never collapse into one
// entry. The ``kind`` field is forwarded to the backend
// so the persisted ``mentioned-documents`` content part // so the persisted ``mentioned-documents`` content part
// can render the correct chip type on reload. // can render the correct chip type on reload.
const allMentionedDocs: MentionedDocumentInfo[] = []; const allMentionedDocs: MentionedDocumentInfo[] = [];
const seenDocKeys = new Set<string>(); const seenDocKeys = new Set<string>();
for (const doc of mentionedDocuments) { for (const doc of mentionedDocuments) {
const key = `${doc.kind}:${doc.document_type}:${doc.id}`; const key = getMentionDocKey(doc);
if (seenDocKeys.has(key)) continue; if (seenDocKeys.has(key)) continue;
seenDocKeys.add(key); seenDocKeys.add(key);
allMentionedDocs.push(doc); allMentionedDocs.push(doc);

View file

@ -3,13 +3,6 @@
import { atom } from "jotai"; import { atom } from "jotai";
import type { Document } from "@/contracts/types/document.types"; import type { Document } from "@/contracts/types/document.types";
/**
* Sentinel ``document_type`` used for folder mention chips so the
* dedup key (`kind:document_type:id`) never collides a document with a
* folder that happens to share an integer id.
*/
export const FOLDER_MENTION_DOCUMENT_TYPE = "FOLDER";
/** /**
* Display metadata for a single ``@``-mention chip. * Display metadata for a single ``@``-mention chip.
* *
@ -27,13 +20,11 @@ export type MentionedDocumentInfo =
| { | {
id: number; id: number;
title: string; title: string;
document_type: typeof FOLDER_MENTION_DOCUMENT_TYPE;
kind: "folder"; kind: "folder";
} }
| { | {
id: number; id: number;
title: string; title: string;
document_type: string;
kind: "connector"; kind: "connector";
connector_type: string; connector_type: string;
account_name: string; account_name: string;
@ -51,8 +42,7 @@ type LegacyDocMention = Pick<Document, "id" | "title" | "document_type">;
* Normalize an arbitrary chip-like input into the discriminated * Normalize an arbitrary chip-like input into the discriminated
* ``MentionedDocumentInfo`` shape. Existing call sites that only have * ``MentionedDocumentInfo`` shape. Existing call sites that only have
* ``{id, title, document_type}`` flow through here so they don't have * ``{id, title, document_type}`` flow through here so they don't have
* to thread ``kind`` everywhere the helper defaults to ``"doc"`` and * to thread ``kind`` everywhere the helper defaults to ``"doc"``.
* rewrites the document type for folders.
*/ */
export function toMentionedDocumentInfo( export function toMentionedDocumentInfo(
input: LegacyDocMention | MentionedDocumentInfo input: LegacyDocMention | MentionedDocumentInfo
@ -78,31 +68,32 @@ export function makeFolderMention(input: { id: number; name: string }): Mentione
return { return {
id: input.id, id: input.id,
title: input.name, title: input.name,
document_type: FOLDER_MENTION_DOCUMENT_TYPE,
kind: "folder", kind: "folder",
}; };
} }
/** /**
* Atom to store the full mention objects (documents + folders) attached * Atom to store the full context objects attached via @-mention chips in
* via @-mention chips in the current chat composer. Persists across * the current chat composer. Persists across component remounts.
* component remounts.
*/ */
export const mentionedDocumentsAtom = atom<MentionedDocumentInfo[]>([]); export const mentionedDocumentsAtom = atom<MentionedDocumentInfo[]>([]);
/** /**
* Derived read-only atom that maps deduplicated mention chips into * Derived read-only atom that maps deduplicated mention chips into
* backend payload fields. Doc chips split by ``document_type`` exactly * backend payload fields. Each mention kind maps to its own explicit
* like before; folder chips are projected into a separate * payload bucket so non-document context never has to masquerade as a
* ``folder_ids`` bucket so the route can forward * document type.
* ``mentioned_folder_ids`` to the agent without the priority middleware
* conflating them with hybrid-search ids.
*/ */
export const mentionedDocumentIdsAtom = atom((get) => { export const mentionedDocumentIdsAtom = atom((get) => {
const allMentions = get(mentionedDocumentsAtom); const allMentions = get(mentionedDocumentsAtom);
const seen = new Set<string>(); const seen = new Set<string>();
const deduped = allMentions.filter((m) => { const deduped = allMentions.filter((m) => {
const key = `${m.kind}:${m.document_type}:${m.id}`; const key =
m.kind === "doc"
? `doc:${m.document_type}:${m.id}`
: m.kind === "connector"
? `connector:${m.connector_type}:${m.id}`
: `folder:${m.id}`;
if (seen.has(key)) return false; if (seen.has(key)) return false;
seen.add(key); seen.add(key);
return true; return true;
@ -120,7 +111,6 @@ export const mentionedDocumentIdsAtom = atom((get) => {
connectors: connectors.map((c) => ({ connectors: connectors.map((c) => ({
id: c.id, id: c.id,
title: c.title, title: c.title,
document_type: c.document_type,
kind: c.kind, kind: c.kind,
connector_type: c.connector_type, connector_type: c.connector_type,
account_name: c.account_name, account_name: c.account_name,

View file

@ -20,7 +20,6 @@ import {
useMemo, useMemo,
useRef, useRef,
} from "react"; } from "react";
import { FOLDER_MENTION_DOCUMENT_TYPE } from "@/atoms/chat/mentioned-documents.atom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { Document } from "@/contracts/types/document.types"; import type { Document } from "@/contracts/types/document.types";
@ -40,8 +39,6 @@ export interface MentionedDocument {
/** /**
* Input shape for inserting a chip. ``kind`` defaults to ``"doc"``. * 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 = { export type MentionChipInput = {
id: number; id: number;
@ -78,7 +75,12 @@ export interface InlineMentionEditorRef {
doc: Pick<Document, "id" | "title" | "document_type">, doc: Pick<Document, "id" | "title" | "document_type">,
options?: { removeTriggerText?: boolean } options?: { removeTriggerText?: boolean }
) => void; ) => void;
removeDocumentChip: (docId: number, docType?: string) => void; removeDocumentChip: (
docId: number,
docType?: string,
kind?: MentionKind,
connectorType?: string
) => void;
setDocumentChipStatus: ( setDocumentChipStatus: (
docId: number, docId: number,
docType: string | undefined, docType: string | undefined,
@ -95,7 +97,7 @@ interface InlineMentionEditorProps {
onActionClose?: () => void; onActionClose?: () => void;
onSubmit?: () => void; onSubmit?: () => void;
onChange?: (text: string, docs: MentionedDocument[]) => 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; onKeyDown?: (e: React.KeyboardEvent) => void;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
@ -135,7 +137,12 @@ const EMPTY_VALUE: ComposerValue = [{ type: "p", children: [{ text: "" }] }];
* the X button and Backspace go through the same call site. * the X button and Backspace go through the same call site.
*/ */
type MentionEditorContextValue = { 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); const MentionEditorContext = createContext<MentionEditorContextValue | null>(null);
@ -181,7 +188,12 @@ const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); 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 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" 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"
> >
@ -456,18 +468,11 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
const removeTriggerText = options?.removeTriggerText ?? true; const removeTriggerText = options?.removeTriggerText ?? true;
const kind: MentionKind = mention.kind ?? "doc"; const kind: MentionKind = mention.kind ?? "doc";
const document_type =
mention.document_type ??
(kind === "folder"
? FOLDER_MENTION_DOCUMENT_TYPE
: kind === "connector"
? mention.connector_type
: undefined);
const mentionNode: MentionElementNode = { const mentionNode: MentionElementNode = {
type: MENTION_TYPE, type: MENTION_TYPE,
id: mention.id, id: mention.id,
title: mention.title, title: mention.title,
document_type, document_type: mention.document_type,
kind, kind,
connector_type: mention.connector_type, connector_type: mention.connector_type,
account_name: mention.account_name, account_name: mention.account_name,
@ -526,17 +531,33 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
[insertMentionChip] [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 // descending path order so removing one entry can't invalidate
// later paths. Chips are deduped today, so this typically runs // later paths. Chips are deduped today, so this typically runs
// at most once. // at most once.
const removeDocumentChip = useCallback( const removeDocumentChip = useCallback(
(docId: number, docType?: string) => { (docId: number, docType?: string, kind?: MentionKind, connectorType?: string) => {
const match = (n: unknown) => { const match = (n: unknown) => {
if (!n || typeof n !== "object" || !("type" in n)) return false; if (!n || typeof n !== "object" || !("type" in n)) return false;
const node = n as MentionElementNode; const node = n as MentionElementNode;
if (node.type !== MENTION_TYPE) return false; if (node.type !== MENTION_TYPE) return false;
if (node.id !== docId) 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"); return (node.document_type ?? "UNKNOWN") === (docType ?? "UNKNOWN");
}; };
@ -554,9 +575,14 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
// Single removal call site for Backspace and the X button so the // Single removal call site for Backspace and the X button so the
// two can never diverge (e.g. one forgetting to notify the parent). // two can never diverge (e.g. one forgetting to notify the parent).
const removeChip = useCallback( const removeChip = useCallback(
(docId: number, docType: string | undefined) => { (
removeDocumentChip(docId, docType); docId: number,
onDocumentRemove?.(docId, docType); docType: string | undefined,
kind: MentionKind | undefined,
connectorType: string | undefined
) => {
removeDocumentChip(docId, docType, kind, connectorType);
onDocumentRemove?.(docId, docType, kind, connectorType);
}, },
[onDocumentRemove, removeDocumentChip] [onDocumentRemove, removeDocumentChip]
); );
@ -679,7 +705,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
if (!isMentionNode(prev)) return; if (!isMentionNode(prev)) return;
e.preventDefault(); 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] [editor.selection, getCurrentValue, onKeyDown, onSubmit, removeChip]
); );

View file

@ -36,7 +36,6 @@ import {
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom"; import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom"; import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { import {
FOLDER_MENTION_DOCUMENT_TYPE,
type MentionedDocumentInfo, type MentionedDocumentInfo,
mentionedDocumentsAtom, mentionedDocumentsAtom,
} from "@/atoms/chat/mentioned-documents.atom"; } from "@/atoms/chat/mentioned-documents.atom";
@ -544,14 +543,12 @@ const Composer: FC = () => {
} }
} }
return docs.map<MentionedDocumentInfo>((d) => { return docs.map<MentionedDocumentInfo>((d) => {
const documentType = d.document_type ?? "UNKNOWN";
if (d.kind === "connector") { if (d.kind === "connector") {
return { return {
id: d.id, id: d.id,
title: d.title, title: d.title,
document_type: documentType,
kind: "connector", kind: "connector",
connector_type: d.connector_type ?? documentType, connector_type: d.connector_type ?? "UNKNOWN",
account_name: d.account_name ?? d.title, account_name: d.account_name ?? d.title,
}; };
} }
@ -559,17 +556,13 @@ const Composer: FC = () => {
return { return {
id: d.id, id: d.id,
title: d.title, title: d.title,
document_type: FOLDER_MENTION_DOCUMENT_TYPE,
kind: "folder", kind: "folder",
}; };
} }
return { return {
id: d.id, id: d.id,
title: d.title, title: d.title,
// Atom requires a string; ``"UNKNOWN"`` matches the document_type: d.document_type ?? "UNKNOWN",
// sentinel ``getMentionDocKey`` and the editor's
// match predicates use.
document_type: documentType,
kind: "doc", kind: "doc",
}; };
}); });
@ -760,13 +753,14 @@ const Composer: FC = () => {
]); ]);
const handleDocumentRemove = useCallback( const handleDocumentRemove = useCallback(
(docId: number, docType?: string) => { (docId: number, docType?: string, kind?: "doc" | "folder" | "connector", connectorType?: string) => {
setMentionedDocuments((prev) => { setMentionedDocuments((prev) => {
if (!docType) { const removedKey = getMentionDocKey({
// Fallback when chip type is unavailable. id: docId,
return prev.filter((doc) => doc.id !== docId); document_type: docType,
} kind,
const removedKey = getMentionDocKey({ id: docId, document_type: docType }); connector_type: connectorType,
});
return prev.filter((doc) => getMentionDocKey(doc) !== removedKey); return prev.filter((doc) => getMentionDocKey(doc) !== removedKey);
}); });
}, },
@ -810,7 +804,12 @@ const Composer: FC = () => {
for (const [key, doc] of prevDocsMap) { for (const [key, doc] of prevDocsMap) {
if (!nextDocsMap.has(key)) { 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
);
} }
} }

View file

@ -104,7 +104,7 @@ const UserTextPart: FC = () => {
const icon = isFolder ? ( const icon = isFolder ? (
<FolderIcon className="size-3.5" /> <FolderIcon className="size-3.5" />
) : isConnector ? ( ) : isConnector ? (
getConnectorIcon(segment.doc.connector_type ?? segment.doc.document_type, "size-3.5") ?? ( getConnectorIcon(segment.doc.connector_type, "size-3.5") ?? (
<Plug className="size-3.5" /> <Plug className="size-3.5" />
) )
) : ( ) : (

View file

@ -190,7 +190,6 @@ export function FolderTreeView({
for (const f of folders) { for (const f of folders) {
const folderMentionKey = getMentionDocKey({ const folderMentionKey = getMentionDocKey({
id: f.id, id: f.id,
document_type: "FOLDER",
kind: "folder", kind: "folder",
}); });
states[f.id] = mentionedDocKeys.has(folderMentionKey) ? "all" : "none"; states[f.id] = mentionedDocKeys.has(folderMentionKey) ? "all" : "none";

View file

@ -20,10 +20,7 @@ import {
useState, useState,
} from "react"; } from "react";
import type * as React from "react"; import type * as React from "react";
import { import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom";
FOLDER_MENTION_DOCUMENT_TYPE,
type MentionedDocumentInfo,
} from "@/atoms/chat/mentioned-documents.atom";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { import {
@ -78,6 +75,10 @@ type ResourceNodeValue =
| { kind: "view"; view: BrowseView } | { kind: "view"; view: BrowseView }
| { kind: "mention"; mention: MentionedDocumentInfo }; | { kind: "mention"; mention: MentionedDocumentInfo };
function isConnectorActive(connector: SearchSourceConnector) {
return connector.is_active !== false;
}
function useDebounced<T>(value: T, delay = DEBOUNCE_MS) { function useDebounced<T>(value: T, delay = DEBOUNCE_MS) {
const [debounced, setDebounced] = useState(value); const [debounced, setDebounced] = useState(value);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
@ -115,22 +116,24 @@ function makeDocMention(doc: Pick<Document, "id" | "title" | "document_type">):
}; };
} }
function makeFolderMention(folder: { id: number; title: string }): MentionedDocumentInfo { function makeFolderMention(
folder: { id: number; title: string }
): Extract<MentionedDocumentInfo, { kind: "folder" }> {
return { return {
id: folder.id, id: folder.id,
title: folder.title, title: folder.title,
document_type: FOLDER_MENTION_DOCUMENT_TYPE,
kind: "folder", kind: "folder",
}; };
} }
function makeConnectorMention(connector: SearchSourceConnector): MentionedDocumentInfo { function makeConnectorMention(
connector: SearchSourceConnector
): Extract<MentionedDocumentInfo, { kind: "connector" }> {
const accountName = getConnectorDisplayName(connector.name); const accountName = getConnectorDisplayName(connector.name);
const connectorTitle = titleForConnectorType(connector.connector_type); const connectorTitle = titleForConnectorType(connector.connector_type);
return { return {
id: connector.id, id: connector.id,
title: `${connectorTitle}: ${accountName}`, title: `${connectorTitle}: ${accountName}`,
document_type: connector.connector_type,
kind: "connector", kind: "connector",
connector_type: connector.connector_type, connector_type: connector.connector_type,
account_name: accountName, account_name: accountName,
@ -140,8 +143,8 @@ function makeConnectorMention(connector: SearchSourceConnector): MentionedDocume
function mentionMatchesSearch(mention: MentionedDocumentInfo, searchLower: string) { function mentionMatchesSearch(mention: MentionedDocumentInfo, searchLower: string) {
return [ return [
mention.title, mention.title,
mention.document_type,
mention.kind, mention.kind,
mention.kind === "doc" ? mention.document_type : "",
mention.kind === "connector" ? mention.connector_type : "", mention.kind === "connector" ? mention.connector_type : "",
mention.kind === "connector" ? mention.account_name : "", mention.kind === "connector" ? mention.account_name : "",
].some((value) => value.toLowerCase().includes(searchLower)); ].some((value) => value.toLowerCase().includes(searchLower));
@ -171,6 +174,7 @@ export const DocumentMentionPicker = forwardRef<
const [zeroFolders] = useZeroQuery(queries.folders.bySpace({ searchSpaceId })); const [zeroFolders] = useZeroQuery(queries.folders.bySpace({ searchSpaceId }));
const { data: connectors = [], isLoading: isConnectorsLoading } = useAtomValue(connectorsAtom); const { data: connectors = [], isLoading: isConnectorsLoading } = useAtomValue(connectorsAtom);
const activeConnectors = useMemo(() => connectors.filter(isConnectorActive), [connectors]);
const paginationScopeKey = useMemo( const paginationScopeKey = useMemo(
() => `${searchSpaceId}:${debouncedSearch}`, () => `${searchSpaceId}:${debouncedSearch}`,
[searchSpaceId, debouncedSearch] [searchSpaceId, debouncedSearch]
@ -307,8 +311,8 @@ export const DocumentMentionPicker = forwardRef<
}, [zeroFolders, debouncedSearch, deferredSearch, isSingleCharSearch, hasSearch]); }, [zeroFolders, debouncedSearch, deferredSearch, isSingleCharSearch, hasSearch]);
const connectorMentions = useMemo( const connectorMentions = useMemo(
() => connectors.filter((c) => c.is_active).map(makeConnectorMention), () => activeConnectors.map(makeConnectorMention),
[connectors] [activeConnectors]
); );
const selectedKeys = useMemo( const selectedKeys = useMemo(
@ -345,16 +349,16 @@ export const DocumentMentionPicker = forwardRef<
{ {
id: "connectors", id: "connectors",
label: "Connectors", label: "Connectors",
subtitle: connectors.length subtitle: activeConnectors.length
? "Choose the exact account for tool use" ? "Choose the exact account for tool use"
: "No connected accounts yet", : "No connected accounts yet",
icon: <Plug className="size-4" />, icon: <Plug className="size-4" />,
type: "branch", type: "branch",
disabled: connectors.length === 0, disabled: activeConnectors.length === 0,
value: { kind: "view", view: { kind: "connectors" } }, value: { kind: "view", view: { kind: "connectors" } },
}, },
], ],
[connectors.length] [activeConnectors.length]
); );
const searchNodes = useMemo<ComposerSuggestionNode<ResourceNodeValue>[]>(() => { const searchNodes = useMemo<ComposerSuggestionNode<ResourceNodeValue>[]>(() => {
@ -385,7 +389,7 @@ export const DocumentMentionPicker = forwardRef<
id: getMentionDocKey(mention), id: getMentionDocKey(mention),
label: mention.title, label: mention.title,
subtitle: "Connector account", subtitle: "Connector account",
icon: getConnectorIcon(mention.document_type, "size-4") ?? <Plug className="size-4" />, icon: getConnectorIcon(mention.connector_type, "size-4") ?? <Plug className="size-4" />,
type: "item" as const, type: "item" as const,
disabled: selectedKeys.has(getMentionDocKey(mention)), disabled: selectedKeys.has(getMentionDocKey(mention)),
value: { kind: "mention" as const, mention }, value: { kind: "mention" as const, mention },
@ -404,7 +408,7 @@ export const DocumentMentionPicker = forwardRef<
const connectorTypeEntries = useMemo(() => { const connectorTypeEntries = useMemo(() => {
const byType = new Map<string, SearchSourceConnector[]>(); const byType = new Map<string, SearchSourceConnector[]>();
for (const connector of connectors.filter((c) => c.is_active)) { for (const connector of activeConnectors) {
const list = byType.get(connector.connector_type) ?? []; const list = byType.get(connector.connector_type) ?? [];
list.push(connector); list.push(connector);
byType.set(connector.connector_type, list); byType.set(connector.connector_type, list);
@ -412,7 +416,7 @@ export const DocumentMentionPicker = forwardRef<
return Array.from(byType.entries()).sort(([a], [b]) => return Array.from(byType.entries()).sort(([a], [b]) =>
titleForConnectorType(a).localeCompare(titleForConnectorType(b)) titleForConnectorType(a).localeCompare(titleForConnectorType(b))
); );
}, [connectors]); }, [activeConnectors]);
const browseNodes = useMemo<ComposerSuggestionNode<ResourceNodeValue>[]>(() => { const browseNodes = useMemo<ComposerSuggestionNode<ResourceNodeValue>[]>(() => {
if (view.kind === "root") return rootNodes; if (view.kind === "root") return rootNodes;
@ -469,8 +473,8 @@ export const DocumentMentionPicker = forwardRef<
}, },
})); }));
} }
return connectors return activeConnectors
.filter((connector) => connector.is_active && connector.connector_type === view.connectorType) .filter((connector) => connector.connector_type === view.connectorType)
.map((connector) => { .map((connector) => {
const mention = makeConnectorMention(connector); const mention = makeConnectorMention(connector);
return { return {
@ -484,7 +488,7 @@ export const DocumentMentionPicker = forwardRef<
}; };
}); });
}, [ }, [
connectors, activeConnectors,
connectorTypeEntries, connectorTypeEntries,
folderMentions, folderMentions,
rootNodes, rootNodes,

View file

@ -1,18 +1,20 @@
type MentionKeyInput = { type MentionKeyInput = {
id: number; id: number;
document_type?: string | null; document_type?: string | null;
connector_type?: string | null;
kind?: "doc" | "folder" | "connector"; kind?: "doc" | "folder" | "connector";
}; };
/** /**
* Build a stable dedup key for a mention chip. * Build a stable dedup key for a mention chip.
* *
* The ``kind:document_type:id`` shape prevents a document and a folder * Each mention kind keys off its real identity fields:
* with the same integer id from colliding in the chip array (folders * docs by document type, folders by folder id, and connectors by
* use the ``FOLDER`` sentinel ``document_type``; the ``kind`` prefix * connector type + account id.
* is the belt-and-braces guard).
*/ */
export function getMentionDocKey(doc: MentionKeyInput): string { export function getMentionDocKey(doc: MentionKeyInput): string {
const kind = doc.kind ?? "doc"; const kind = doc.kind ?? "doc";
return `${kind}:${doc.document_type ?? "UNKNOWN"}:${doc.id}`; if (kind === "folder") return `folder:${doc.id}`;
if (kind === "connector") return `connector:${doc.connector_type ?? "UNKNOWN"}:${doc.id}`;
return `doc:${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
} }

View file

@ -1,22 +1,17 @@
import { EnumConnectorName } from "@/contracts/enums/connector";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { import {
OAUTH_CONNECTORS,
COMPOSIO_CONNECTORS, COMPOSIO_CONNECTORS,
CRAWLERS, CRAWLERS,
OAUTH_CONNECTORS,
OTHER_CONNECTORS, OTHER_CONNECTORS,
} from "@/components/assistant-ui/connector-popup/constants/connector-constants"; } from "@/components/assistant-ui/connector-popup/constants/connector-constants";
import { EnumConnectorName } from "@/contracts/enums/connector";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
// ============================================================================= // =============================================================================
// Connector Telemetry Types & Registry // Connector Telemetry Types & Registry
// ============================================================================= // =============================================================================
export type ConnectorTelemetryGroup = export type ConnectorTelemetryGroup = "oauth" | "composio" | "crawler" | "other" | "unknown";
| "oauth"
| "composio"
| "crawler"
| "other"
| "unknown";
export interface ConnectorTelemetryMeta { export interface ConnectorTelemetryMeta {
connector_type: string; connector_type: string;
@ -31,10 +26,11 @@ export interface ConnectorTelemetryMeta {
* picked up here, so adding a new integration does NOT require touching * picked up here, so adding a new integration does NOT require touching
* `lib/posthog/events.ts` or per-connector tracking code. * `lib/posthog/events.ts` or per-connector tracking code.
*/ */
const CONNECTOR_TELEMETRY_REGISTRY: ReadonlyMap< let connectorTelemetryRegistry: ReadonlyMap<string, ConnectorTelemetryMeta> | undefined;
string,
ConnectorTelemetryMeta function getConnectorTelemetryRegistry(): ReadonlyMap<string, ConnectorTelemetryMeta> {
> = (() => { if (connectorTelemetryRegistry) return connectorTelemetryRegistry;
const map = new Map<string, ConnectorTelemetryMeta>(); const map = new Map<string, ConnectorTelemetryMeta>();
for (const c of OAUTH_CONNECTORS) { for (const c of OAUTH_CONNECTORS) {
@ -70,18 +66,17 @@ const CONNECTOR_TELEMETRY_REGISTRY: ReadonlyMap<
}); });
} }
return map; connectorTelemetryRegistry = map;
})(); return connectorTelemetryRegistry;
}
/** /**
* Returns telemetry metadata for a connector_type, or a minimal "unknown" * Returns telemetry metadata for a connector_type, or a minimal "unknown"
* record so tracking never no-ops for connectors that exist in the backend * record so tracking never no-ops for connectors that exist in the backend
* but were forgotten in the UI registry. * but were forgotten in the UI registry.
*/ */
export function getConnectorTelemetryMeta( export function getConnectorTelemetryMeta(connectorType: string): ConnectorTelemetryMeta {
connectorType: string, const hit = getConnectorTelemetryRegistry().get(connectorType);
): ConnectorTelemetryMeta {
const hit = CONNECTOR_TELEMETRY_REGISTRY.get(connectorType);
if (hit) return hit; if (hit) return hit;
return { return {
@ -101,34 +96,20 @@ export function getConnectorTelemetryMeta(
* These are used for connectors that were NOT created via MCP OAuth. * These are used for connectors that were NOT created via MCP OAuth.
*/ */
const LEGACY_REAUTH_ENDPOINTS: Partial<Record<string, string>> = { const LEGACY_REAUTH_ENDPOINTS: Partial<Record<string, string>> = {
[EnumConnectorName.LINEAR_CONNECTOR]: [EnumConnectorName.LINEAR_CONNECTOR]: "/api/v1/auth/linear/connector/reauth",
"/api/v1/auth/linear/connector/reauth", [EnumConnectorName.JIRA_CONNECTOR]: "/api/v1/auth/jira/connector/reauth",
[EnumConnectorName.JIRA_CONNECTOR]: [EnumConnectorName.NOTION_CONNECTOR]: "/api/v1/auth/notion/connector/reauth",
"/api/v1/auth/jira/connector/reauth", [EnumConnectorName.GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/google/drive/connector/reauth",
[EnumConnectorName.NOTION_CONNECTOR]: [EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: "/api/v1/auth/google/gmail/connector/reauth",
"/api/v1/auth/notion/connector/reauth", [EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/google/calendar/connector/reauth",
[EnumConnectorName.GOOGLE_DRIVE_CONNECTOR]: [EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
"/api/v1/auth/google/drive/connector/reauth", [EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: [EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
"/api/v1/auth/google/gmail/connector/reauth", [EnumConnectorName.ONEDRIVE_CONNECTOR]: "/api/v1/auth/onedrive/connector/reauth",
[EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: [EnumConnectorName.DROPBOX_CONNECTOR]: "/api/v1/auth/dropbox/connector/reauth",
"/api/v1/auth/google/calendar/connector/reauth", [EnumConnectorName.CONFLUENCE_CONNECTOR]: "/api/v1/auth/confluence/connector/reauth",
[EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR]: [EnumConnectorName.TEAMS_CONNECTOR]: "/api/v1/auth/teams/connector/reauth",
"/api/v1/auth/composio/connector/reauth", [EnumConnectorName.DISCORD_CONNECTOR]: "/api/v1/auth/discord/connector/reauth",
[EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]:
"/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]:
"/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.ONEDRIVE_CONNECTOR]:
"/api/v1/auth/onedrive/connector/reauth",
[EnumConnectorName.DROPBOX_CONNECTOR]:
"/api/v1/auth/dropbox/connector/reauth",
[EnumConnectorName.CONFLUENCE_CONNECTOR]:
"/api/v1/auth/confluence/connector/reauth",
[EnumConnectorName.TEAMS_CONNECTOR]:
"/api/v1/auth/teams/connector/reauth",
[EnumConnectorName.DISCORD_CONNECTOR]:
"/api/v1/auth/discord/connector/reauth",
}; };
/** /**
@ -138,9 +119,7 @@ const LEGACY_REAUTH_ENDPOINTS: Partial<Record<string, string>> = {
* the URL from the service key. Legacy OAuth connectors fall back to the * the URL from the service key. Legacy OAuth connectors fall back to the
* static ``LEGACY_REAUTH_ENDPOINTS`` map. * static ``LEGACY_REAUTH_ENDPOINTS`` map.
*/ */
export function getReauthEndpoint( export function getReauthEndpoint(connector: SearchSourceConnector): string | undefined {
connector: SearchSourceConnector,
): string | undefined {
const mcpService = connector.config?.mcp_service as string | undefined; const mcpService = connector.config?.mcp_service as string | undefined;
if (mcpService) { if (mcpService) {
return `/api/v1/auth/mcp/${mcpService}/connector/reauth`; return `/api/v1/auth/mcp/${mcpService}/connector/reauth`;