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
|
|
@ -64,6 +64,8 @@ class SurfSenseContextSchema:
|
||||||
search_space_id: int | None = None
|
search_space_id: int | None = None
|
||||||
mentioned_document_ids: list[int] = field(default_factory=list)
|
mentioned_document_ids: list[int] = field(default_factory=list)
|
||||||
mentioned_folder_ids: list[int] = field(default_factory=list)
|
mentioned_folder_ids: list[int] = field(default_factory=list)
|
||||||
|
mentioned_connector_ids: list[int] = field(default_factory=list)
|
||||||
|
mentioned_connectors: list[dict[str, object]] = field(default_factory=list)
|
||||||
file_operation_contract: FileOperationContractState | None = None
|
file_operation_contract: FileOperationContractState | None = None
|
||||||
turn_id: str | None = None
|
turn_id: str | None = None
|
||||||
request_id: str | None = None
|
request_id: str | None = None
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ async def resolve_mentions(
|
||||||
kind = chip.kind
|
kind = chip.kind
|
||||||
if kind == "folder":
|
if kind == "folder":
|
||||||
chip_folder_ids.append(chip.id)
|
chip_folder_ids.append(chip.id)
|
||||||
else:
|
elif kind == "doc":
|
||||||
chip_doc_ids.append(chip.id)
|
chip_doc_ids.append(chip.id)
|
||||||
chip_titles_by_id[(kind, chip.id)] = chip.title
|
chip_titles_by_id[(kind, chip.id)] = chip.title
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1771,6 +1771,11 @@ async def handle_new_chat(
|
||||||
if request.mentioned_documents
|
if request.mentioned_documents
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
mentioned_connectors_payload = (
|
||||||
|
[doc.model_dump() for doc in request.mentioned_connectors]
|
||||||
|
if request.mentioned_connectors
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
stream_new_chat(
|
stream_new_chat(
|
||||||
|
|
@ -1782,6 +1787,8 @@ async def handle_new_chat(
|
||||||
mentioned_document_ids=request.mentioned_document_ids,
|
mentioned_document_ids=request.mentioned_document_ids,
|
||||||
mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids,
|
mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids,
|
||||||
mentioned_folder_ids=request.mentioned_folder_ids,
|
mentioned_folder_ids=request.mentioned_folder_ids,
|
||||||
|
mentioned_connector_ids=request.mentioned_connector_ids,
|
||||||
|
mentioned_connectors=mentioned_connectors_payload,
|
||||||
mentioned_documents=mentioned_documents_payload,
|
mentioned_documents=mentioned_documents_payload,
|
||||||
needs_history_bootstrap=thread.needs_history_bootstrap,
|
needs_history_bootstrap=thread.needs_history_bootstrap,
|
||||||
thread_visibility=thread.visibility,
|
thread_visibility=thread.visibility,
|
||||||
|
|
@ -2258,6 +2265,11 @@ async def regenerate_response(
|
||||||
if request.mentioned_documents
|
if request.mentioned_documents
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
mentioned_connectors_payload = (
|
||||||
|
[doc.model_dump() for doc in request.mentioned_connectors]
|
||||||
|
if request.mentioned_connectors
|
||||||
|
else None
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
async for chunk in stream_new_chat(
|
async for chunk in stream_new_chat(
|
||||||
user_query=str(user_query_to_use),
|
user_query=str(user_query_to_use),
|
||||||
|
|
@ -2268,6 +2280,8 @@ async def regenerate_response(
|
||||||
mentioned_document_ids=request.mentioned_document_ids,
|
mentioned_document_ids=request.mentioned_document_ids,
|
||||||
mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids,
|
mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids,
|
||||||
mentioned_folder_ids=request.mentioned_folder_ids,
|
mentioned_folder_ids=request.mentioned_folder_ids,
|
||||||
|
mentioned_connector_ids=request.mentioned_connector_ids,
|
||||||
|
mentioned_connectors=mentioned_connectors_payload,
|
||||||
mentioned_documents=mentioned_documents_payload,
|
mentioned_documents=mentioned_documents_payload,
|
||||||
checkpoint_id=target_checkpoint_id,
|
checkpoint_id=target_checkpoint_id,
|
||||||
needs_history_bootstrap=thread.needs_history_bootstrap,
|
needs_history_bootstrap=thread.needs_history_bootstrap,
|
||||||
|
|
|
||||||
|
|
@ -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,18 +215,18 @@ 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"] = 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. Folders carry the sentinel "
|
"knowledge-base ``Folder`` row, and ``connector`` is a "
|
||||||
"``document_type='FOLDER'`` to keep the frontend dedup key "
|
"concrete connected account."
|
||||||
"``(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)
|
||||||
|
account_name: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
class NewChatRequest(BaseModel):
|
class NewChatRequest(BaseModel):
|
||||||
|
|
@ -266,6 +264,18 @@ class NewChatRequest(BaseModel):
|
||||||
"a mentioned-documents part."
|
"a mentioned-documents part."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
mentioned_connector_ids: list[int] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Optional concrete connector account IDs the user @-mentioned.",
|
||||||
|
)
|
||||||
|
mentioned_connectors: list[MentionedDocumentInfo] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Display/context metadata for selected connector accounts. "
|
||||||
|
"Kept separate from document/folder id arrays so tools can "
|
||||||
|
"prefer the exact account the user selected."
|
||||||
|
),
|
||||||
|
)
|
||||||
disabled_tools: list[str] | None = (
|
disabled_tools: list[str] | None = (
|
||||||
None # Optional list of tool names the user has disabled from the UI
|
None # Optional list of tool names the user has disabled from the UI
|
||||||
)
|
)
|
||||||
|
|
@ -335,6 +345,8 @@ class RegenerateRequest(BaseModel):
|
||||||
"new user message. None means no chip metadata."
|
"new user message. None means no chip metadata."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
mentioned_connector_ids: list[int] | None = None
|
||||||
|
mentioned_connectors: list[MentionedDocumentInfo] | None = None
|
||||||
disabled_tools: list[str] | None = None
|
disabled_tools: list[str] | None = None
|
||||||
filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud"
|
filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud"
|
||||||
client_platform: Literal["web", "desktop"] = "web"
|
client_platform: Literal["web", "desktop"] = "web"
|
||||||
|
|
|
||||||
|
|
@ -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,27 @@ 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") else "doc"
|
kind = kind_raw if kind_raw in ("doc", "folder", "connector") else "doc"
|
||||||
normalized.append(
|
if doc_id is None or title is None:
|
||||||
{
|
continue
|
||||||
"id": doc_id,
|
if kind == "doc" and document_type is None:
|
||||||
"title": str(title),
|
continue
|
||||||
"document_type": str(document_type),
|
item = {
|
||||||
"kind": kind,
|
"id": doc_id,
|
||||||
}
|
"title": str(title),
|
||||||
)
|
"kind": kind,
|
||||||
|
}
|
||||||
|
if document_type is not None:
|
||||||
|
item["document_type"] = str(document_type)
|
||||||
|
if kind == "connector":
|
||||||
|
connector_type = doc.get("connector_type")
|
||||||
|
if connector_type is None:
|
||||||
|
continue
|
||||||
|
account_name = doc.get("account_name") or title
|
||||||
|
item["connector_type"] = str(connector_type)
|
||||||
|
item["account_name"] = str(account_name)
|
||||||
|
normalized.append(item)
|
||||||
if normalized:
|
if normalized:
|
||||||
parts.append({"type": "mentioned-documents", "documents": normalized})
|
parts.append({"type": "mentioned-documents", "documents": normalized})
|
||||||
return parts
|
return parts
|
||||||
|
|
|
||||||
|
|
@ -839,6 +839,8 @@ async def stream_new_chat(
|
||||||
mentioned_document_ids: list[int] | None = None,
|
mentioned_document_ids: list[int] | None = None,
|
||||||
mentioned_surfsense_doc_ids: list[int] | None = None,
|
mentioned_surfsense_doc_ids: list[int] | None = None,
|
||||||
mentioned_folder_ids: list[int] | None = None,
|
mentioned_folder_ids: list[int] | None = None,
|
||||||
|
mentioned_connector_ids: list[int] | None = None,
|
||||||
|
mentioned_connectors: list[dict[str, Any]] | None = None,
|
||||||
mentioned_documents: list[dict[str, Any]] | None = None,
|
mentioned_documents: list[dict[str, Any]] | None = None,
|
||||||
checkpoint_id: str | None = None,
|
checkpoint_id: str | None = None,
|
||||||
needs_history_bootstrap: bool = False,
|
needs_history_bootstrap: bool = False,
|
||||||
|
|
@ -1385,6 +1387,33 @@ async def stream_new_chat(
|
||||||
format_mentioned_surfsense_docs_as_context(mentioned_surfsense_docs)
|
format_mentioned_surfsense_docs_as_context(mentioned_surfsense_docs)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if mentioned_connectors:
|
||||||
|
connector_lines = []
|
||||||
|
for connector in mentioned_connectors:
|
||||||
|
if not isinstance(connector, dict):
|
||||||
|
continue
|
||||||
|
connector_id = connector.get("id")
|
||||||
|
connector_type = connector.get("connector_type") or connector.get(
|
||||||
|
"document_type"
|
||||||
|
)
|
||||||
|
account_name = connector.get("account_name") or connector.get("title")
|
||||||
|
if connector_id is None or connector_type is None:
|
||||||
|
continue
|
||||||
|
connector_lines.append(
|
||||||
|
f' - connector_id={connector_id}, connector_type="{connector_type}", '
|
||||||
|
f'account_name="{account_name or ""}"'
|
||||||
|
)
|
||||||
|
if connector_lines:
|
||||||
|
context_parts.append(
|
||||||
|
"<mentioned_connectors>\n"
|
||||||
|
"The user selected these exact connector accounts with @. "
|
||||||
|
"These entries are selection metadata, not retrieved connector content. "
|
||||||
|
"When a connector-backed tool needs an account, use the matching "
|
||||||
|
"connector_id from this list if the tool supports connector_id:\n"
|
||||||
|
+ "\n".join(connector_lines)
|
||||||
|
+ "\n</mentioned_connectors>"
|
||||||
|
)
|
||||||
|
|
||||||
# Surface report IDs prominently so the LLM doesn't have to
|
# Surface report IDs prominently so the LLM doesn't have to
|
||||||
# retrieve them from old tool responses in conversation history.
|
# retrieve them from old tool responses in conversation history.
|
||||||
if recent_reports:
|
if recent_reports:
|
||||||
|
|
@ -1778,6 +1807,8 @@ async def stream_new_chat(
|
||||||
mentioned_folder_ids=list(
|
mentioned_folder_ids=list(
|
||||||
accepted_folder_ids or mentioned_folder_ids or []
|
accepted_folder_ids or mentioned_folder_ids or []
|
||||||
),
|
),
|
||||||
|
mentioned_connector_ids=list(mentioned_connector_ids or []),
|
||||||
|
mentioned_connectors=list(mentioned_connectors or []),
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
turn_id=stream_result.turn_id,
|
turn_id=stream_result.turn_id,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,11 +207,13 @@ 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")])
|
.union([z.literal("doc"), z.literal("folder"), z.literal("connector")])
|
||||||
.optional()
|
.optional()
|
||||||
.default("doc"),
|
.default("doc"),
|
||||||
|
connector_type: z.string().optional(),
|
||||||
|
account_name: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const MentionedDocumentsPartSchema = z.object({
|
const MentionedDocumentsPartSchema = z.object({
|
||||||
|
|
@ -227,7 +230,30 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
|
||||||
for (const part of content) {
|
for (const part of content) {
|
||||||
const result = MentionedDocumentsPartSchema.safeParse(part);
|
const result = MentionedDocumentsPartSchema.safeParse(part);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
return result.data.documents;
|
return result.data.documents.map<MentionedDocumentInfo>((doc) => {
|
||||||
|
if (doc.kind === "connector") {
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
title: doc.title,
|
||||||
|
kind: "connector",
|
||||||
|
connector_type: doc.connector_type ?? doc.document_type ?? "UNKNOWN",
|
||||||
|
account_name: doc.account_name ?? doc.title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (doc.kind === "folder") {
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
title: doc.title,
|
||||||
|
kind: "folder",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
title: doc.title,
|
||||||
|
document_type: doc.document_type ?? "UNKNOWN",
|
||||||
|
kind: "doc",
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -924,28 +950,22 @@ export default function NewChatPage() {
|
||||||
hasMentionedDocuments:
|
hasMentionedDocuments:
|
||||||
mentionedDocumentIds.surfsense_doc_ids.length > 0 ||
|
mentionedDocumentIds.surfsense_doc_ids.length > 0 ||
|
||||||
mentionedDocumentIds.document_ids.length > 0 ||
|
mentionedDocumentIds.document_ids.length > 0 ||
|
||||||
mentionedDocumentIds.folder_ids.length > 0,
|
mentionedDocumentIds.folder_ids.length > 0 ||
|
||||||
|
mentionedDocumentIds.connector_ids.length > 0,
|
||||||
messageLength: userQuery.length,
|
messageLength: userQuery.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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({
|
allMentionedDocs.push(doc);
|
||||||
id: doc.id,
|
|
||||||
title: doc.title,
|
|
||||||
document_type: doc.document_type,
|
|
||||||
kind: doc.kind,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allMentionedDocs.length > 0) {
|
if (allMentionedDocs.length > 0) {
|
||||||
|
|
@ -1008,9 +1028,10 @@ export default function NewChatPage() {
|
||||||
const hasDocumentIds = mentionedDocumentIds.document_ids.length > 0;
|
const hasDocumentIds = mentionedDocumentIds.document_ids.length > 0;
|
||||||
const hasSurfsenseDocIds = mentionedDocumentIds.surfsense_doc_ids.length > 0;
|
const hasSurfsenseDocIds = mentionedDocumentIds.surfsense_doc_ids.length > 0;
|
||||||
const hasFolderIds = mentionedDocumentIds.folder_ids.length > 0;
|
const hasFolderIds = mentionedDocumentIds.folder_ids.length > 0;
|
||||||
|
const hasConnectorIds = mentionedDocumentIds.connector_ids.length > 0;
|
||||||
|
|
||||||
// Clear mentioned documents after capturing them
|
// Clear mentioned documents after capturing them
|
||||||
if (hasDocumentIds || hasSurfsenseDocIds || hasFolderIds) {
|
if (hasDocumentIds || hasSurfsenseDocIds || hasFolderIds || hasConnectorIds) {
|
||||||
setMentionedDocuments([]);
|
setMentionedDocuments([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1036,20 +1057,16 @@ export default function NewChatPage() {
|
||||||
? mentionedDocumentIds.surfsense_doc_ids
|
? mentionedDocumentIds.surfsense_doc_ids
|
||||||
: undefined,
|
: undefined,
|
||||||
mentioned_folder_ids: hasFolderIds ? mentionedDocumentIds.folder_ids : undefined,
|
mentioned_folder_ids: hasFolderIds ? mentionedDocumentIds.folder_ids : undefined,
|
||||||
|
mentioned_connector_ids: hasConnectorIds
|
||||||
|
? mentionedDocumentIds.connector_ids
|
||||||
|
: undefined,
|
||||||
|
mentioned_connectors: hasConnectorIds ? mentionedDocumentIds.connectors : undefined,
|
||||||
// Full mention metadata (docs + folders, with
|
// Full mention metadata (docs + folders, with
|
||||||
// ``kind`` discriminator) so the BE can embed a
|
// ``kind`` discriminator) so the BE can embed a
|
||||||
// ``mentioned-documents`` ContentPart on the
|
// ``mentioned-documents`` ContentPart on the
|
||||||
// persisted user message (replaces the old FE-side
|
// persisted user message (replaces the old FE-side
|
||||||
// injection in ``persistUserTurn``).
|
// injection in ``persistUserTurn``).
|
||||||
mentioned_documents:
|
mentioned_documents: allMentionedDocs.length > 0 ? allMentionedDocs : undefined,
|
||||||
allMentionedDocs.length > 0
|
|
||||||
? allMentionedDocs.map((d) => ({
|
|
||||||
id: d.id,
|
|
||||||
title: d.title,
|
|
||||||
document_type: d.document_type,
|
|
||||||
kind: d.kind,
|
|
||||||
}))
|
|
||||||
: undefined,
|
|
||||||
disabled_tools: disabledTools.length > 0 ? disabledTools : undefined,
|
disabled_tools: disabledTools.length > 0 ? disabledTools : undefined,
|
||||||
...(userImages.length > 0 ? { user_images: userImages } : {}),
|
...(userImages.length > 0 ? { user_images: userImages } : {}),
|
||||||
}),
|
}),
|
||||||
|
|
@ -1945,6 +1962,7 @@ export default function NewChatPage() {
|
||||||
const regenerateFolderIds = sourceMentionedDocs
|
const regenerateFolderIds = sourceMentionedDocs
|
||||||
.filter((d) => d.kind === "folder")
|
.filter((d) => d.kind === "folder")
|
||||||
.map((d) => d.id);
|
.map((d) => d.id);
|
||||||
|
const regenerateConnectors = sourceMentionedDocs.filter((d) => d.kind === "connector");
|
||||||
|
|
||||||
const requestBody: Record<string, unknown> = {
|
const requestBody: Record<string, unknown> = {
|
||||||
search_space_id: searchSpaceId,
|
search_space_id: searchSpaceId,
|
||||||
|
|
@ -1957,19 +1975,16 @@ export default function NewChatPage() {
|
||||||
mentioned_surfsense_doc_ids:
|
mentioned_surfsense_doc_ids:
|
||||||
regenerateSurfsenseDocIds.length > 0 ? regenerateSurfsenseDocIds : undefined,
|
regenerateSurfsenseDocIds.length > 0 ? regenerateSurfsenseDocIds : undefined,
|
||||||
mentioned_folder_ids: regenerateFolderIds.length > 0 ? regenerateFolderIds : undefined,
|
mentioned_folder_ids: regenerateFolderIds.length > 0 ? regenerateFolderIds : undefined,
|
||||||
|
mentioned_connector_ids:
|
||||||
|
regenerateConnectors.length > 0 ? regenerateConnectors.map((d) => d.id) : undefined,
|
||||||
|
mentioned_connectors:
|
||||||
|
regenerateConnectors.length > 0 ? regenerateConnectors : undefined,
|
||||||
// Full mention metadata for the regenerate-specific
|
// Full mention metadata for the regenerate-specific
|
||||||
// source list. Only meaningful for edit (the BE only
|
// source list. Only meaningful for edit (the BE only
|
||||||
// re-persists a user row when ``user_query`` is set);
|
// re-persists a user row when ``user_query`` is set);
|
||||||
// reload reuses the original turn's mentioned_documents.
|
// reload reuses the original turn's mentioned_documents.
|
||||||
mentioned_documents:
|
mentioned_documents:
|
||||||
sourceMentionedDocs.length > 0
|
sourceMentionedDocs.length > 0 ? sourceMentionedDocs : undefined,
|
||||||
? sourceMentionedDocs.map((d) => ({
|
|
||||||
id: d.id,
|
|
||||||
title: d.title,
|
|
||||||
document_type: d.document_type,
|
|
||||||
kind: d.kind,
|
|
||||||
}))
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
requestBody.user_images = editExtras?.userImages ?? [];
|
requestBody.user_images = editExtras?.userImages ?? [];
|
||||||
|
|
|
||||||
|
|
@ -3,28 +3,32 @@
|
||||||
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.
|
||||||
*
|
*
|
||||||
* The ``kind`` discriminator identifies whether the chip is a
|
* Historical name is retained because this atom is already wired into
|
||||||
* knowledge-base document or a knowledge-base folder. Folders carry
|
* chat persistence and sidebar selection. The shape is now the selected
|
||||||
* the sentinel ``document_type === FOLDER_MENTION_DOCUMENT_TYPE`` so
|
* composer context, not only documents.
|
||||||
* the editor, picker, and persisted ``mentioned-documents`` content
|
|
||||||
* part all stay aligned with the backend Pydantic schema.
|
|
||||||
*/
|
*/
|
||||||
export interface MentionedDocumentInfo {
|
export type MentionedDocumentInfo =
|
||||||
id: number;
|
| {
|
||||||
title: string;
|
id: number;
|
||||||
document_type: string;
|
title: string;
|
||||||
kind: "doc" | "folder";
|
document_type: string;
|
||||||
}
|
kind: "doc";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
kind: "folder";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
kind: "connector";
|
||||||
|
connector_type: string;
|
||||||
|
account_name: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backwards-compatible doc-only chip shape for legacy callers that
|
* Backwards-compatible doc-only chip shape for legacy callers that
|
||||||
|
|
@ -38,13 +42,15 @@ 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
|
||||||
): MentionedDocumentInfo {
|
): MentionedDocumentInfo {
|
||||||
if ("kind" in input && (input.kind === "doc" || input.kind === "folder")) {
|
if (
|
||||||
|
"kind" in input &&
|
||||||
|
(input.kind === "doc" || input.kind === "folder" || input.kind === "connector")
|
||||||
|
) {
|
||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
@ -62,43 +68,53 @@ 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;
|
||||||
});
|
});
|
||||||
const docs = deduped.filter((m) => m.kind === "doc");
|
const docs = deduped.filter((m) => m.kind === "doc");
|
||||||
const folders = deduped.filter((m) => m.kind === "folder");
|
const folders = deduped.filter((m) => m.kind === "folder");
|
||||||
|
const connectors = deduped.filter((m) => m.kind === "connector");
|
||||||
return {
|
return {
|
||||||
surfsense_doc_ids: docs
|
surfsense_doc_ids: docs
|
||||||
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
||||||
.map((doc) => doc.id),
|
.map((doc) => doc.id),
|
||||||
document_ids: docs.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
|
document_ids: docs.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
|
||||||
folder_ids: folders.map((f) => f.id),
|
folder_ids: folders.map((f) => f.id),
|
||||||
|
connector_ids: connectors.map((c) => c.id),
|
||||||
|
connectors: connectors.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
title: c.title,
|
||||||
|
kind: c.kind,
|
||||||
|
connector_type: c.connector_type,
|
||||||
|
account_name: c.account_name,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,20 @@ export const COMPOSIO_CONNECTORS = [
|
||||||
},
|
},
|
||||||
] as const;
|
] 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)
|
// Composio Toolkits (available integrations via Composio)
|
||||||
export const COMPOSIO_TOOLKITS = [
|
export const COMPOSIO_TOOLKITS = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"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 { NodeEntry, TElement } from "platejs";
|
||||||
import type { PlateElementProps } from "platejs/react";
|
import type { PlateElementProps } from "platejs/react";
|
||||||
import {
|
import {
|
||||||
|
|
@ -20,31 +20,44 @@ 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 { 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";
|
||||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type MentionKind = "doc" | "folder";
|
export type MentionKind = "doc" | "folder" | "connector";
|
||||||
|
|
||||||
export interface MentionedDocument {
|
export interface MentionedDocument {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
document_type?: string;
|
document_type?: string;
|
||||||
kind: MentionKind;
|
kind: MentionKind;
|
||||||
|
connector_type?: string;
|
||||||
|
account_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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;
|
||||||
title: string;
|
title: string;
|
||||||
document_type?: string;
|
document_type?: string;
|
||||||
kind?: MentionKind;
|
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 {
|
export interface InlineMentionEditorRef {
|
||||||
|
|
@ -62,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,
|
||||||
|
|
@ -73,13 +91,13 @@ export interface InlineMentionEditorRef {
|
||||||
|
|
||||||
interface InlineMentionEditorProps {
|
interface InlineMentionEditorProps {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onMentionTrigger?: (query: string) => void;
|
onMentionTrigger?: (trigger: SuggestionTriggerInfo) => void;
|
||||||
onMentionClose?: () => void;
|
onMentionClose?: () => void;
|
||||||
onActionTrigger?: (query: string) => void;
|
onActionTrigger?: (trigger: SuggestionTriggerInfo) => void;
|
||||||
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;
|
||||||
|
|
@ -95,6 +113,8 @@ type MentionElementNode = {
|
||||||
document_type?: string;
|
document_type?: string;
|
||||||
/** Discriminator; defaults to ``"doc"`` for legacy nodes. */
|
/** Discriminator; defaults to ``"doc"`` for legacy nodes. */
|
||||||
kind?: MentionKind;
|
kind?: MentionKind;
|
||||||
|
connector_type?: string;
|
||||||
|
account_name?: string;
|
||||||
statusLabel?: string | null;
|
statusLabel?: string | null;
|
||||||
statusKind?: MentionStatusKind;
|
statusKind?: MentionStatusKind;
|
||||||
children: [{ text: "" }];
|
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.
|
* 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);
|
||||||
|
|
||||||
|
|
@ -134,6 +159,7 @@ const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({
|
||||||
: "text-amber-700";
|
: "text-amber-700";
|
||||||
|
|
||||||
const isFolder = element.kind === "folder";
|
const isFolder = element.kind === "folder";
|
||||||
|
const isConnector = element.kind === "connector";
|
||||||
const ctx = useContext(MentionEditorContext);
|
const ctx = useContext(MentionEditorContext);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -144,24 +170,35 @@ const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({
|
||||||
<span className="flex items-center justify-center transition-opacity group-hover:opacity-0">
|
<span className="flex items-center justify-center transition-opacity group-hover:opacity-0">
|
||||||
{isFolder ? (
|
{isFolder ? (
|
||||||
<FolderIcon className="h-3 w-3" />
|
<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")
|
getConnectorIcon(element.document_type ?? "UNKNOWN", "h-3 w-3")
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{ctx ? (
|
{ctx ? (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
aria-label={`Remove mention ${element.title}`}
|
aria-label={`Remove mention ${element.title}`}
|
||||||
title={`Remove ${element.title}`}
|
title={`Remove ${element.title}`}
|
||||||
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 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" />
|
<XIcon />
|
||||||
</button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -228,6 +265,8 @@ function getMentionedDocuments(value: ComposerValue): MentionedDocument[] {
|
||||||
title: node.title,
|
title: node.title,
|
||||||
document_type: node.document_type,
|
document_type: node.document_type,
|
||||||
kind,
|
kind,
|
||||||
|
connector_type: node.connector_type,
|
||||||
|
account_name: node.account_name,
|
||||||
};
|
};
|
||||||
map.set(getMentionDocKey(doc), doc);
|
map.set(getMentionDocKey(doc), doc);
|
||||||
}
|
}
|
||||||
|
|
@ -299,6 +338,36 @@ function scanActiveTrigger(text: string, cursor: number) {
|
||||||
return { triggerChar, query };
|
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>(
|
export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMentionEditorProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
|
|
@ -360,14 +429,19 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const triggerInfo: SuggestionTriggerInfo = {
|
||||||
|
query: trigger.query,
|
||||||
|
anchorRect: getSelectionAnchorRect(editableRef.current),
|
||||||
|
};
|
||||||
|
|
||||||
if (trigger.triggerChar === "@") {
|
if (trigger.triggerChar === "@") {
|
||||||
onMentionTrigger?.(trigger.query);
|
|
||||||
onActionClose?.();
|
onActionClose?.();
|
||||||
|
onMentionTrigger?.(triggerInfo);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onActionTrigger?.(trigger.query);
|
|
||||||
onMentionClose?.();
|
onMentionClose?.();
|
||||||
|
onActionTrigger?.(triggerInfo);
|
||||||
},
|
},
|
||||||
[editor.selection, onActionClose, onActionTrigger, onChange, onMentionClose, onMentionTrigger]
|
[editor.selection, onActionClose, onActionTrigger, onChange, onMentionClose, onMentionTrigger]
|
||||||
);
|
);
|
||||||
|
|
@ -394,14 +468,14 @@ 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 : 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,
|
||||||
|
account_name: mention.account_name,
|
||||||
children: [{ text: "" }],
|
children: [{ text: "" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -457,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");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -485,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]
|
||||||
);
|
);
|
||||||
|
|
@ -610,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]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { MouseEventHandler, ReactNode } from "react";
|
import type { MouseEventHandler, ReactNode } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -60,13 +61,15 @@ export function MentionChip({
|
||||||
const isInteractive = Boolean(onClick) && !disabled;
|
const isInteractive = Boolean(onClick) && !disabled;
|
||||||
|
|
||||||
const chip = (
|
const chip = (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={ariaLabel ?? label}
|
aria-label={ariaLabel ?? label}
|
||||||
className={cn(
|
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",
|
isInteractive ? "cursor-pointer" : "cursor-default",
|
||||||
disabled && "opacity-60",
|
disabled && "opacity-60",
|
||||||
className
|
className
|
||||||
|
|
@ -74,7 +77,7 @@ export function MentionChip({
|
||||||
>
|
>
|
||||||
<span className="inline-flex shrink-0 text-muted-foreground">{icon}</span>
|
<span className="inline-flex shrink-0 text-muted-foreground">{icon}</span>
|
||||||
<span className="max-w-[120px] truncate leading-none">{label}</span>
|
<span className="max-w-[120px] truncate leading-none">{label}</span>
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!tooltip) return chip;
|
if (!tooltip) return chip;
|
||||||
|
|
|
||||||
|
|
@ -62,13 +62,17 @@ import {
|
||||||
InlineMentionEditor,
|
InlineMentionEditor,
|
||||||
type InlineMentionEditorRef,
|
type InlineMentionEditorRef,
|
||||||
type MentionedDocument,
|
type MentionedDocument,
|
||||||
|
type SuggestionAnchorRect,
|
||||||
|
type SuggestionTriggerInfo,
|
||||||
} from "@/components/assistant-ui/inline-mention-editor";
|
} from "@/components/assistant-ui/inline-mention-editor";
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
import { UserMessage } from "@/components/assistant-ui/user-message";
|
import { UserMessage } from "@/components/assistant-ui/user-message";
|
||||||
|
import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer-suggestion-popup";
|
||||||
import {
|
import {
|
||||||
DocumentMentionPicker,
|
DocumentMentionPicker,
|
||||||
|
promoteRecentMention,
|
||||||
type DocumentMentionPickerRef,
|
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 { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
|
||||||
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -90,6 +94,7 @@ import {
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Popover, PopoverAnchor } from "@/components/ui/popover";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
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";
|
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 = () => {
|
export const Thread: FC = () => {
|
||||||
return <ThreadContent />;
|
return <ThreadContent />;
|
||||||
};
|
};
|
||||||
|
|
@ -409,6 +442,8 @@ const Composer: FC = () => {
|
||||||
const [showPromptPicker, setShowPromptPicker] = useState(false);
|
const [showPromptPicker, setShowPromptPicker] = useState(false);
|
||||||
const [mentionQuery, setMentionQuery] = useState("");
|
const [mentionQuery, setMentionQuery] = useState("");
|
||||||
const [actionQuery, setActionQuery] = useState("");
|
const [actionQuery, setActionQuery] = useState("");
|
||||||
|
const [suggestionAnchorPoint, setSuggestionAnchorPoint] =
|
||||||
|
useState<ComposerSuggestionAnchorPoint | null>(null);
|
||||||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||||
const prevMentionedDocsRef = useRef<Map<string, MentionedDocumentInfo>>(new Map());
|
const prevMentionedDocsRef = useRef<Map<string, MentionedDocumentInfo>>(new Map());
|
||||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||||
|
|
@ -489,6 +524,7 @@ const Composer: FC = () => {
|
||||||
lastSeenSlideoutTickRef.current = slideoutOpenedTick;
|
lastSeenSlideoutTickRef.current = slideoutOpenedTick;
|
||||||
setShowDocumentPopover(false);
|
setShowDocumentPopover(false);
|
||||||
setMentionQuery("");
|
setMentionQuery("");
|
||||||
|
setSuggestionAnchorPoint(null);
|
||||||
}, [slideoutOpenedTick]);
|
}, [slideoutOpenedTick]);
|
||||||
|
|
||||||
// Sync editor text into assistant-ui's composer and mirror the chip
|
// Sync editor text into assistant-ui's composer and mirror the chip
|
||||||
|
|
@ -507,44 +543,96 @@ const Composer: FC = () => {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return docs.map<MentionedDocumentInfo>((d) => ({
|
return docs.map<MentionedDocumentInfo>((d) => {
|
||||||
id: d.id,
|
if (d.kind === "connector") {
|
||||||
title: d.title,
|
return {
|
||||||
// Atom requires a string; ``"UNKNOWN"`` matches the
|
id: d.id,
|
||||||
// sentinel ``getMentionDocKey`` and the editor's
|
title: d.title,
|
||||||
// match predicates use.
|
kind: "connector",
|
||||||
document_type: d.document_type ?? "UNKNOWN",
|
connector_type: d.connector_type ?? "UNKNOWN",
|
||||||
kind: d.kind,
|
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]
|
[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);
|
setShowDocumentPopover(true);
|
||||||
setMentionQuery(query);
|
setMentionQuery(trigger.query);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleMentionClose = useCallback(() => {
|
const handleMentionClose = useCallback(() => {
|
||||||
if (showDocumentPopover) {
|
if (showDocumentPopover) {
|
||||||
setShowDocumentPopover(false);
|
setShowDocumentPopover(false);
|
||||||
setMentionQuery("");
|
setMentionQuery("");
|
||||||
|
setSuggestionAnchorPoint(null);
|
||||||
}
|
}
|
||||||
}, [showDocumentPopover]);
|
}, [showDocumentPopover]);
|
||||||
|
|
||||||
const handleActionTrigger = useCallback((query: string) => {
|
const handleDocumentPopoverOpenChange = useCallback((open: boolean) => {
|
||||||
setShowPromptPicker(true);
|
setShowDocumentPopover(open);
|
||||||
setActionQuery(query);
|
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(() => {
|
const handleActionClose = useCallback(() => {
|
||||||
if (showPromptPicker) {
|
if (showPromptPicker) {
|
||||||
setShowPromptPicker(false);
|
setShowPromptPicker(false);
|
||||||
setActionQuery("");
|
setActionQuery("");
|
||||||
|
setSuggestionAnchorPoint(null);
|
||||||
}
|
}
|
||||||
}, [showPromptPicker]);
|
}, [showPromptPicker]);
|
||||||
|
|
||||||
|
const handlePromptPickerOpenChange = useCallback((open: boolean) => {
|
||||||
|
setShowPromptPicker(open);
|
||||||
|
if (!open) {
|
||||||
|
setActionQuery("");
|
||||||
|
setSuggestionAnchorPoint(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleActionSelect = useCallback(
|
const handleActionSelect = useCallback(
|
||||||
(action: { name: string; prompt: string; mode: "transform" | "explore" }) => {
|
(action: { name: string; prompt: string; mode: "transform" | "explore" }) => {
|
||||||
let userText = editorRef.current?.getText() ?? "";
|
let userText = editorRef.current?.getText() ?? "";
|
||||||
|
|
@ -561,6 +649,7 @@ const Composer: FC = () => {
|
||||||
aui.composer().setText(finalPrompt);
|
aui.composer().setText(finalPrompt);
|
||||||
setShowPromptPicker(false);
|
setShowPromptPicker(false);
|
||||||
setActionQuery("");
|
setActionQuery("");
|
||||||
|
setSuggestionAnchorPoint(null);
|
||||||
},
|
},
|
||||||
[actionQuery, aui]
|
[actionQuery, aui]
|
||||||
);
|
);
|
||||||
|
|
@ -576,6 +665,7 @@ const Composer: FC = () => {
|
||||||
aui.composer().setText(finalPrompt);
|
aui.composer().setText(finalPrompt);
|
||||||
setShowPromptPicker(false);
|
setShowPromptPicker(false);
|
||||||
setActionQuery("");
|
setActionQuery("");
|
||||||
|
setSuggestionAnchorPoint(null);
|
||||||
setClipboardInitialText(undefined);
|
setClipboardInitialText(undefined);
|
||||||
},
|
},
|
||||||
[clipboardInitialText, electronAPI, aui]
|
[clipboardInitialText, electronAPI, aui]
|
||||||
|
|
@ -604,6 +694,7 @@ const Composer: FC = () => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setShowPromptPicker(false);
|
setShowPromptPicker(false);
|
||||||
setActionQuery("");
|
setActionQuery("");
|
||||||
|
setSuggestionAnchorPoint(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -625,8 +716,12 @@ const Composer: FC = () => {
|
||||||
}
|
}
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (documentPickerRef.current?.goBack()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setShowDocumentPopover(false);
|
setShowDocumentPopover(false);
|
||||||
setMentionQuery("");
|
setMentionQuery("");
|
||||||
|
setSuggestionAnchorPoint(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -659,13 +754,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);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -673,6 +769,7 @@ const Composer: FC = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDocumentsMention = useCallback((mentions: MentionedDocumentInfo[]) => {
|
const handleDocumentsMention = useCallback((mentions: MentionedDocumentInfo[]) => {
|
||||||
|
const parsedSearchSpaceId = Number(search_space_id);
|
||||||
const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? [];
|
const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? [];
|
||||||
const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc)));
|
const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc)));
|
||||||
|
|
||||||
|
|
@ -680,6 +777,9 @@ const Composer: FC = () => {
|
||||||
const key = getMentionDocKey(mention);
|
const key = getMentionDocKey(mention);
|
||||||
if (editorDocKeys.has(key)) continue;
|
if (editorDocKeys.has(key)) continue;
|
||||||
editorRef.current?.insertMentionChip(mention);
|
editorRef.current?.insertMentionChip(mention);
|
||||||
|
if (Number.isFinite(parsedSearchSpaceId)) {
|
||||||
|
promoteRecentMention(parsedSearchSpaceId, mention);
|
||||||
|
}
|
||||||
// Track within the loop so a duplicate-in-batch can't double-insert.
|
// Track within the loop so a duplicate-in-batch can't double-insert.
|
||||||
editorDocKeys.add(key);
|
editorDocKeys.add(key);
|
||||||
}
|
}
|
||||||
|
|
@ -687,7 +787,8 @@ const Composer: FC = () => {
|
||||||
// Atom is reconciled by ``handleEditorChange`` via the editor's
|
// Atom is reconciled by ``handleEditorChange`` via the editor's
|
||||||
// onChange — no second write path here.
|
// onChange — no second write path here.
|
||||||
setMentionQuery("");
|
setMentionQuery("");
|
||||||
}, []);
|
setSuggestionAnchorPoint(null);
|
||||||
|
}, [search_space_id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const editor = editorRef.current;
|
const editor = editorRef.current;
|
||||||
|
|
@ -708,7 +809,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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -723,39 +829,46 @@ const Composer: FC = () => {
|
||||||
currentUserId={currentUser?.id ?? null}
|
currentUserId={currentUser?.id ?? null}
|
||||||
members={members ?? []}
|
members={members ?? []}
|
||||||
/>
|
/>
|
||||||
{showDocumentPopover && (
|
<Popover open={showDocumentPopover} onOpenChange={handleDocumentPopoverOpenChange}>
|
||||||
<div className="absolute bottom-full left-0 z-[9999] mb-2">
|
{suggestionAnchorPoint ? (
|
||||||
<DocumentMentionPicker
|
<>
|
||||||
ref={documentPickerRef}
|
<ComposerSuggestionAnchor point={suggestionAnchorPoint} />
|
||||||
searchSpaceId={Number(search_space_id)}
|
<ComposerSuggestionPopoverContent side="top">
|
||||||
onSelectionChange={handleDocumentsMention}
|
<DocumentMentionPicker
|
||||||
onDone={() => {
|
ref={documentPickerRef}
|
||||||
setShowDocumentPopover(false);
|
searchSpaceId={Number(search_space_id)}
|
||||||
setMentionQuery("");
|
onSelectionChange={handleDocumentsMention}
|
||||||
}}
|
onDone={() => {
|
||||||
initialSelectedDocuments={mentionedDocuments}
|
setShowDocumentPopover(false);
|
||||||
externalSearch={mentionQuery}
|
setMentionQuery("");
|
||||||
/>
|
setSuggestionAnchorPoint(null);
|
||||||
</div>
|
}}
|
||||||
)}
|
initialSelectedDocuments={mentionedDocuments}
|
||||||
{showPromptPicker && (
|
externalSearch={mentionQuery}
|
||||||
<div
|
/>
|
||||||
className={cn(
|
</ComposerSuggestionPopoverContent>
|
||||||
"absolute left-0 z-[9999]",
|
</>
|
||||||
clipboardInitialText ? "top-full mt-2" : "bottom-full mb-2"
|
) : null}
|
||||||
)}
|
</Popover>
|
||||||
>
|
<Popover open={showPromptPicker} onOpenChange={handlePromptPickerOpenChange}>
|
||||||
<PromptPicker
|
{suggestionAnchorPoint ? (
|
||||||
ref={promptPickerRef}
|
<>
|
||||||
onSelect={clipboardInitialText ? handleQuickAskSelect : handleActionSelect}
|
<ComposerSuggestionAnchor point={suggestionAnchorPoint} />
|
||||||
onDone={() => {
|
<ComposerSuggestionPopoverContent side={clipboardInitialText ? "bottom" : "top"}>
|
||||||
setShowPromptPicker(false);
|
<PromptPicker
|
||||||
setActionQuery("");
|
ref={promptPickerRef}
|
||||||
}}
|
onSelect={clipboardInitialText ? handleQuickAskSelect : handleActionSelect}
|
||||||
externalSearch={actionQuery}
|
onDone={() => {
|
||||||
/>
|
setShowPromptPicker(false);
|
||||||
</div>
|
setActionQuery("");
|
||||||
)}
|
setSuggestionAnchorPoint(null);
|
||||||
|
}}
|
||||||
|
externalSearch={actionQuery}
|
||||||
|
/>
|
||||||
|
</ComposerSuggestionPopoverContent>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Popover>
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -782,7 +895,7 @@ const Composer: FC = () => {
|
||||||
onDocumentRemove={handleDocumentRemove}
|
onDocumentRemove={handleDocumentRemove}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className="min-h-[24px]"
|
className="min-h-[24px] **:data-slate-placeholder:font-normal"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
|
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
|
||||||
|
|
@ -964,7 +1077,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
||||||
<Switch
|
<Switch
|
||||||
checked={isWebSearchEnabled}
|
checked={isWebSearchEnabled}
|
||||||
tabIndex={-1}
|
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>
|
</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">
|
<div className="flex w-full items-center gap-3 px-4 py-2 hover:bg-accent hover:text-accent-foreground transition-colors">
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<Button
|
||||||
type="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 ? (
|
{iconInfo ? (
|
||||||
<Image
|
<Image
|
||||||
|
|
@ -1061,7 +1175,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<Switch
|
<Switch
|
||||||
checked={!allDisabled}
|
checked={!allDisabled}
|
||||||
|
|
@ -1203,7 +1317,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
||||||
<Switch
|
<Switch
|
||||||
checked={isWebSearchEnabled}
|
checked={isWebSearchEnabled}
|
||||||
tabIndex={-1}
|
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>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1253,7 +1367,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
||||||
<Switch
|
<Switch
|
||||||
checked={!isDisabled}
|
checked={!isDisabled}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="pointer-events-none shrink-0 scale-[0.6]"
|
className="pointer-events-none shrink-0 origin-right scale-[0.6]"
|
||||||
/>
|
/>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
|
|
@ -1305,7 +1419,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
onCheckedChange={() => toggleToolGroup(toolNames)}
|
onCheckedChange={() => toggleToolGroup(toolNames)}
|
||||||
className="shrink-0 scale-[0.6]"
|
className="mr-2 shrink-0 origin-right scale-[0.6]"
|
||||||
/>
|
/>
|
||||||
</DropdownMenuSubTrigger>
|
</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
|
|
@ -1334,7 +1448,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
||||||
<Switch
|
<Switch
|
||||||
checked={!isDisabled}
|
checked={!isDisabled}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="pointer-events-none shrink-0 scale-[0.6]"
|
className="pointer-events-none shrink-0 origin-right scale-[0.6]"
|
||||||
/>
|
/>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
|
|
@ -1374,7 +1488,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
||||||
<Switch
|
<Switch
|
||||||
checked={!isDisabled}
|
checked={!isDisabled}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="pointer-events-none shrink-0 scale-[0.6]"
|
className="pointer-events-none shrink-0 origin-right scale-[0.6]"
|
||||||
/>
|
/>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
useMessagePartText,
|
useMessagePartText,
|
||||||
} from "@assistant-ui/react";
|
} from "@assistant-ui/react";
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
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 Image from "next/image";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { type FC, useCallback, useState } from "react";
|
import { type FC, useCallback, useState } from "react";
|
||||||
|
|
@ -100,8 +100,13 @@ const UserTextPart: FC = () => {
|
||||||
return <span key={`txt-${segment.start}`}>{segment.value}</span>;
|
return <span key={`txt-${segment.start}`}>{segment.value}</span>;
|
||||||
}
|
}
|
||||||
const isFolder = segment.doc.kind === "folder";
|
const isFolder = segment.doc.kind === "folder";
|
||||||
|
const isConnector = segment.doc.kind === "connector";
|
||||||
const icon = isFolder ? (
|
const icon = isFolder ? (
|
||||||
<FolderIcon className="size-3.5" />
|
<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")
|
getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "size-3.5")
|
||||||
);
|
);
|
||||||
|
|
@ -110,8 +115,16 @@ const UserTextPart: FC = () => {
|
||||||
key={`mention-${getMentionDocKey(segment.doc)}-${segment.start}`}
|
key={`mention-${getMentionDocKey(segment.doc)}-${segment.start}`}
|
||||||
icon={icon}
|
icon={icon}
|
||||||
label={segment.doc.title}
|
label={segment.doc.title}
|
||||||
tooltip={isFolder ? `Folder: ${segment.doc.title}` : segment.doc.title}
|
tooltip={
|
||||||
onClick={isFolder ? undefined : () => handleOpenDoc(segment.doc.id, segment.doc.title)}
|
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"
|
className="mx-0.5"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
|
import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
|
||||||
import { trackLoginAttempt } from "@/lib/posthog/events";
|
import { trackLoginAttempt } from "@/lib/posthog/events";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -74,8 +75,9 @@ export const SignInButton = ({ variant = "desktop" }: SignInButtonProps) => {
|
||||||
|
|
||||||
if (isGoogleAuth) {
|
if (isGoogleAuth) {
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
onClick={handleGoogleLogin}
|
onClick={handleGoogleLogin}
|
||||||
disabled={isRedirecting}
|
disabled={isRedirecting}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -85,7 +87,7 @@ export const SignInButton = ({ variant = "desktop" }: SignInButtonProps) => {
|
||||||
>
|
>
|
||||||
<GoogleLogo className="h-4 w-4" />
|
<GoogleLogo className="h-4 w-4" />
|
||||||
<span>Sign In</span>
|
<span>Sign In</span>
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -1063,6 +1063,7 @@ function AuthenticatedDocumentsSidebarBase({
|
||||||
const treeDocMap = new Map(treeDocuments.map((d) => [d.id, d]));
|
const treeDocMap = new Map(treeDocuments.map((d) => [d.id, d]));
|
||||||
return sidebarDocs
|
return sidebarDocs
|
||||||
.filter((doc) => {
|
.filter((doc) => {
|
||||||
|
if (doc.kind !== "doc") return false;
|
||||||
const fullDoc = treeDocMap.get(doc.id);
|
const fullDoc = treeDocMap.get(doc.id);
|
||||||
if (!fullDoc) return false;
|
if (!fullDoc) return false;
|
||||||
const state = fullDoc.status?.state ?? "ready";
|
const state = fullDoc.status?.state ?? "ready";
|
||||||
|
|
@ -1124,7 +1125,7 @@ function AuthenticatedDocumentsSidebarBase({
|
||||||
try {
|
try {
|
||||||
await deleteDocumentMutation({ id });
|
await deleteDocumentMutation({ id });
|
||||||
toast.success(t("delete_success") || "Document deleted");
|
toast.success(t("delete_success") || "Document deleted");
|
||||||
setSidebarDocs((prev) => prev.filter((d) => d.id !== id));
|
setSidebarDocs((prev) => prev.filter((d) => d.kind !== "doc" || d.id !== id));
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error deleting document:", e);
|
console.error("Error deleting document:", e);
|
||||||
|
|
@ -1953,7 +1954,7 @@ function AnonymousDocumentsSidebar({
|
||||||
onEditDocument={() => gate("edit documents")}
|
onEditDocument={() => gate("edit documents")}
|
||||||
onDeleteDocument={async () => {
|
onDeleteDocument={async () => {
|
||||||
handleRemoveDoc();
|
handleRemoveDoc();
|
||||||
setSidebarDocs((prev) => prev.filter((d) => d.id !== -1));
|
setSidebarDocs((prev) => prev.filter((d) => d.kind !== "doc" || d.id !== -1));
|
||||||
return true;
|
return true;
|
||||||
}}
|
}}
|
||||||
onMoveDocument={() => gate("organize documents")}
|
onMoveDocument={() => gate("organize documents")}
|
||||||
|
|
|
||||||
190
surfsense_web/components/new-chat/composer-suggestion-popup.tsx
Normal file
190
surfsense_web/components/new-chat/composer-suggestion-popup.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { PopoverContent } from "@/components/ui/popover";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function ComposerSuggestionPopoverContent({
|
||||||
|
className,
|
||||||
|
align = "start",
|
||||||
|
sideOffset = 6,
|
||||||
|
collisionPadding = 12,
|
||||||
|
onOpenAutoFocus,
|
||||||
|
onCloseAutoFocus,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverContent>) {
|
||||||
|
return (
|
||||||
|
<PopoverContent
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
collisionPadding={collisionPadding}
|
||||||
|
onOpenAutoFocus={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onOpenAutoFocus?.(event);
|
||||||
|
}}
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onCloseAutoFocus?.(event);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"w-[232px] select-none overflow-hidden rounded-md border border-popover-border bg-popover p-0 text-popover-foreground shadow-md sm:w-[264px]",
|
||||||
|
"data-[state=open]:!animate-none data-[state=closed]:!animate-none data-[state=open]:!duration-0 data-[state=closed]:!duration-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ ...style, animation: "none" }}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ComposerSuggestionList = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("max-h-[144px] overflow-y-auto sm:max-h-[200px]", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ComposerSuggestionList.displayName = "ComposerSuggestionList";
|
||||||
|
|
||||||
|
function ComposerSuggestionGroup({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("px-1.5 py-1.5", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComposerSuggestionGroupHeading({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("px-2 py-1 text-xs font-semibold text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComposerSuggestionHeader({
|
||||||
|
className,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement> & { icon?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-2 py-1 text-xs font-semibold text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon ? <span className="shrink-0 text-current [&_svg]:size-3.5">{icon}</span> : null}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ComposerSuggestionItem = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
Omit<React.ComponentProps<typeof Button>, "variant"> & {
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
selected?: boolean;
|
||||||
|
muted?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, children, icon, selected, muted, disabled, ...props }, ref) => (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"h-auto w-full justify-start gap-1.5 rounded-md px-2 py-1 text-left text-xs font-normal transition-colors",
|
||||||
|
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
|
||||||
|
muted && !selected && "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||||
|
selected && "bg-accent text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon ? <span className="shrink-0 text-current [&_svg]:size-3.5">{icon}</span> : null}
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
));
|
||||||
|
ComposerSuggestionItem.displayName = "ComposerSuggestionItem";
|
||||||
|
|
||||||
|
function ComposerSuggestionSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<div className={cn("my-0.5 px-2.5", className)}>
|
||||||
|
<Separator className="bg-popover-border" {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComposerSuggestionMessage({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
variant = "muted",
|
||||||
|
}: React.HTMLAttributes<HTMLParagraphElement> & { variant?: "muted" | "destructive" }) {
|
||||||
|
return (
|
||||||
|
<div className="px-1.5 py-1">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 text-xs",
|
||||||
|
variant === "destructive" ? "text-destructive" : "text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComposerSuggestionSkeleton({
|
||||||
|
rows = 5,
|
||||||
|
mobileRows = 3,
|
||||||
|
}: {
|
||||||
|
rows?: number;
|
||||||
|
mobileRows?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-1.5 py-1">
|
||||||
|
<div className="px-2 py-1">
|
||||||
|
<Skeleton className="h-3.5 w-20" />
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: rows }, (_, index) => `skeleton-row-${index}`).map((id, index) => (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left",
|
||||||
|
index >= mobileRows && "hidden sm:flex"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="shrink-0">
|
||||||
|
<Skeleton className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 text-xs">
|
||||||
|
<Skeleton className="h-4" style={{ width: `${60 + ((index * 7) % 30)}%` }} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ComposerSuggestionPopoverContent,
|
||||||
|
ComposerSuggestionList,
|
||||||
|
ComposerSuggestionGroup,
|
||||||
|
ComposerSuggestionGroupHeading,
|
||||||
|
ComposerSuggestionHeader,
|
||||||
|
ComposerSuggestionItem,
|
||||||
|
ComposerSuggestionSeparator,
|
||||||
|
ComposerSuggestionMessage,
|
||||||
|
ComposerSuggestionSkeleton,
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -15,9 +15,15 @@ import {
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
|
import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
|
||||||
import { Button } from "@/components/ui/button";
|
import {
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
ComposerSuggestionGroup,
|
||||||
import { cn } from "@/lib/utils";
|
ComposerSuggestionGroupHeading,
|
||||||
|
ComposerSuggestionItem,
|
||||||
|
ComposerSuggestionList,
|
||||||
|
ComposerSuggestionMessage,
|
||||||
|
ComposerSuggestionSeparator,
|
||||||
|
ComposerSuggestionSkeleton,
|
||||||
|
} from "@/components/new-chat/composer-suggestion-popup";
|
||||||
|
|
||||||
export interface PromptPickerRef {
|
export interface PromptPickerRef {
|
||||||
selectHighlighted: () => void;
|
selectHighlighted: () => void;
|
||||||
|
|
@ -119,91 +125,48 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shadow-2xl rounded-lg overflow-hidden bg-popover text-popover-foreground flex flex-col w-[280px] sm:w-[320px] select-none">
|
<ComposerSuggestionList ref={scrollContainerRef}>
|
||||||
<div ref={scrollContainerRef} className="max-h-[180px] sm:max-h-[280px] overflow-y-auto">
|
{isLoading ? (
|
||||||
{isLoading ? (
|
<ComposerSuggestionSkeleton rows={8} mobileRows={8} />
|
||||||
<div className="py-1 px-2">
|
) : isError ? (
|
||||||
<div className="px-3 py-2">
|
<ComposerSuggestionMessage variant="destructive">Failed to load prompts</ComposerSuggestionMessage>
|
||||||
<Skeleton className="h-[16px] w-24" />
|
) : filtered.length === 0 ? (
|
||||||
</div>
|
<ComposerSuggestionMessage>No matching prompts</ComposerSuggestionMessage>
|
||||||
{["a", "b", "c", "d", "e"].map((id, i) => (
|
) : (
|
||||||
<div
|
<ComposerSuggestionGroup>
|
||||||
key={id}
|
<ComposerSuggestionGroupHeading>Saved Prompts</ComposerSuggestionGroupHeading>
|
||||||
className={cn(
|
{filtered.map((action, index) => (
|
||||||
"w-full flex items-center gap-2 px-3 py-2 text-left rounded-md",
|
<ComposerSuggestionItem
|
||||||
i >= 3 && "hidden sm:flex"
|
key={action.id}
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="shrink-0">
|
|
||||||
<Skeleton className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<span className="flex-1 text-sm">
|
|
||||||
<Skeleton className="h-[20px]" style={{ width: `${60 + ((i * 7) % 30)}%` }} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : isError ? (
|
|
||||||
<div className="py-1 px-2">
|
|
||||||
<p className="px-3 py-2 text-xs text-destructive">Failed to load prompts</p>
|
|
||||||
</div>
|
|
||||||
) : filtered.length === 0 ? (
|
|
||||||
<div className="py-1 px-2">
|
|
||||||
<p className="px-3 py-2 text-xs text-muted-foreground">No matching prompts</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="py-1 px-2">
|
|
||||||
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">
|
|
||||||
Saved Prompts
|
|
||||||
</div>
|
|
||||||
{filtered.map((action, index) => (
|
|
||||||
<Button
|
|
||||||
key={action.id}
|
|
||||||
ref={(el) => {
|
|
||||||
if (el) itemRefs.current.set(index, el);
|
|
||||||
else itemRefs.current.delete(index);
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleSelect(index)}
|
|
||||||
onMouseEnter={() => setHighlightedIndex(index)}
|
|
||||||
className={cn(
|
|
||||||
"h-auto w-full justify-start gap-2 rounded-md px-3 py-2 text-left text-sm font-normal transition-colors",
|
|
||||||
index === highlightedIndex && "bg-accent text-accent-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="shrink-0 text-muted-foreground">
|
|
||||||
<WandSparkles className="size-4" />
|
|
||||||
</span>
|
|
||||||
<span className="flex-1 text-sm truncate">{action.name}</span>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="mx-2 my-1 border-t border-popover-border" />
|
|
||||||
<Button
|
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
if (el) itemRefs.current.set(createPromptIndex, el);
|
if (el) itemRefs.current.set(index, el);
|
||||||
else itemRefs.current.delete(createPromptIndex);
|
else itemRefs.current.delete(index);
|
||||||
}}
|
}}
|
||||||
type="button"
|
icon={<WandSparkles className="size-3.5" />}
|
||||||
variant="ghost"
|
selected={index === highlightedIndex}
|
||||||
onClick={() => handleSelect(createPromptIndex)}
|
onClick={() => handleSelect(index)}
|
||||||
onMouseEnter={() => setHighlightedIndex(createPromptIndex)}
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
className={cn(
|
|
||||||
"h-auto w-full justify-start gap-2 rounded-md px-3 py-2 text-left text-sm font-normal text-muted-foreground transition-colors",
|
|
||||||
highlightedIndex === createPromptIndex
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "hover:text-accent-foreground hover:bg-accent"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<span className="shrink-0">
|
<span className="flex-1 truncate text-xs">{action.name}</span>
|
||||||
<Plus className="size-4" />
|
</ComposerSuggestionItem>
|
||||||
</span>
|
))}
|
||||||
<span>Create prompt</span>
|
|
||||||
</Button>
|
<ComposerSuggestionSeparator />
|
||||||
</div>
|
<ComposerSuggestionItem
|
||||||
)}
|
ref={(el) => {
|
||||||
</div>
|
if (el) itemRefs.current.set(createPromptIndex, el);
|
||||||
</div>
|
else itemRefs.current.delete(createPromptIndex);
|
||||||
|
}}
|
||||||
|
icon={<Plus className="size-3.5" />}
|
||||||
|
muted
|
||||||
|
selected={highlightedIndex === createPromptIndex}
|
||||||
|
onClick={() => handleSelect(createPromptIndex)}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(createPromptIndex)}
|
||||||
|
>
|
||||||
|
<span>Create prompt</span>
|
||||||
|
</ComposerSuggestionItem>
|
||||||
|
</ComposerSuggestionGroup>
|
||||||
|
)}
|
||||||
|
</ComposerSuggestionList>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type * as React from "react";
|
||||||
|
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export type ComposerSuggestionNode<TValue> = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
subtitle?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
keywords?: string[];
|
||||||
|
type: "branch" | "item" | "action";
|
||||||
|
value?: TValue;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ComposerSuggestionNavigatorRef = {
|
||||||
|
selectHighlighted: () => void;
|
||||||
|
moveUp: () => void;
|
||||||
|
moveDown: () => void;
|
||||||
|
goBack: () => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ComposerSuggestionNavigatorOptions<TValue> = {
|
||||||
|
nodes: ComposerSuggestionNode<TValue>[];
|
||||||
|
onSelect: (node: ComposerSuggestionNode<TValue>) => void;
|
||||||
|
onBack?: () => boolean;
|
||||||
|
ref?: React.Ref<ComposerSuggestionNavigatorRef>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useComposerSuggestionNavigator<TValue>({
|
||||||
|
nodes,
|
||||||
|
onSelect,
|
||||||
|
onBack,
|
||||||
|
ref,
|
||||||
|
}: ComposerSuggestionNavigatorOptions<TValue>) {
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||||
|
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const shouldScrollRef = useRef(false);
|
||||||
|
const nodesKey = useMemo(() => nodes.map((node) => node.id).join("\u0000"), [nodes]);
|
||||||
|
const previousNodesKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Reset keyboard focus when the caller swaps the visible node set.
|
||||||
|
useEffect(() => {
|
||||||
|
if (previousNodesKeyRef.current === nodesKey) return;
|
||||||
|
previousNodesKeyRef.current = nodesKey;
|
||||||
|
setHighlightedIndex(0);
|
||||||
|
itemRefs.current.clear();
|
||||||
|
}, [nodesKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldScrollRef.current) return;
|
||||||
|
shouldScrollRef.current = false;
|
||||||
|
|
||||||
|
const rafId = requestAnimationFrame(() => {
|
||||||
|
const item = itemRefs.current.get(highlightedIndex);
|
||||||
|
const container = scrollContainerRef.current;
|
||||||
|
if (!item || !container) return;
|
||||||
|
|
||||||
|
const itemRect = item.getBoundingClientRect();
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) {
|
||||||
|
item.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => cancelAnimationFrame(rafId);
|
||||||
|
}, [highlightedIndex]);
|
||||||
|
|
||||||
|
const moveUp = useCallback(() => {
|
||||||
|
if (nodes.length === 0) return;
|
||||||
|
shouldScrollRef.current = true;
|
||||||
|
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : nodes.length - 1));
|
||||||
|
}, [nodes.length]);
|
||||||
|
|
||||||
|
const moveDown = useCallback(() => {
|
||||||
|
if (nodes.length === 0) return;
|
||||||
|
shouldScrollRef.current = true;
|
||||||
|
setHighlightedIndex((prev) => (prev < nodes.length - 1 ? prev + 1 : 0));
|
||||||
|
}, [nodes.length]);
|
||||||
|
|
||||||
|
const selectHighlighted = useCallback(() => {
|
||||||
|
const node = nodes[highlightedIndex];
|
||||||
|
if (!node || node.disabled) return;
|
||||||
|
onSelect(node);
|
||||||
|
}, [highlightedIndex, nodes, onSelect]);
|
||||||
|
|
||||||
|
const goBack = useCallback(() => onBack?.() ?? false, [onBack]);
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
selectHighlighted,
|
||||||
|
moveUp,
|
||||||
|
moveDown,
|
||||||
|
goBack,
|
||||||
|
}),
|
||||||
|
[goBack, moveDown, moveUp, selectHighlighted]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getItemRef = useCallback(
|
||||||
|
(index: number) => (el: HTMLButtonElement | null) => {
|
||||||
|
if (el) itemRefs.current.set(index, el);
|
||||||
|
else itemRefs.current.delete(index);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
highlightedIndex,
|
||||||
|
setHighlightedIndex,
|
||||||
|
scrollContainerRef,
|
||||||
|
getItemRef,
|
||||||
|
moveUp,
|
||||||
|
moveDown,
|
||||||
|
selectHighlighted,
|
||||||
|
goBack,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,20 @@
|
||||||
type MentionKeyInput = {
|
type MentionKeyInput = {
|
||||||
id: number;
|
id: number;
|
||||||
document_type?: string | null;
|
document_type?: string | null;
|
||||||
kind?: "doc" | "folder";
|
connector_type?: string | null;
|
||||||
|
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}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,17 @@
|
||||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
|
||||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
|
||||||
import {
|
import {
|
||||||
COMPOSIO_CONNECTORS,
|
COMPOSIO_CONNECTORS,
|
||||||
CRAWLERS,
|
CRAWLERS,
|
||||||
OAUTH_CONNECTORS,
|
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`;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue