mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
feat: add SurfSense docs mention support in chat
This commit is contained in:
parent
1b5f29afcc
commit
cd3677b5fa
11 changed files with 226 additions and 141 deletions
|
|
@ -76,17 +76,42 @@ def format_mentioned_surfsense_docs_as_context(
|
|||
if not documents:
|
||||
return ""
|
||||
|
||||
import json
|
||||
|
||||
context_parts = ["<mentioned_surfsense_docs>"]
|
||||
context_parts.append(
|
||||
"The user has explicitly mentioned the following SurfSense documentation pages. "
|
||||
"These are official documentation about how to use SurfSense and should be used to answer questions about the application."
|
||||
)
|
||||
for i, doc in enumerate(documents, 1):
|
||||
context_parts.append(
|
||||
f"<surfsense_doc index='{i}' id='doc-{doc.id}' title='{doc.title}' source='{doc.source}'>"
|
||||
)
|
||||
context_parts.append(f"<![CDATA[{doc.content}]]>")
|
||||
context_parts.append("</surfsense_doc>")
|
||||
|
||||
for doc in documents:
|
||||
metadata_json = json.dumps({"source": doc.source}, ensure_ascii=False)
|
||||
|
||||
context_parts.append("<document>")
|
||||
context_parts.append("<document_metadata>")
|
||||
context_parts.append(f" <document_id>doc-{doc.id}</document_id>")
|
||||
context_parts.append(" <document_type>SURFSENSE_DOCS</document_type>")
|
||||
context_parts.append(f" <title><![CDATA[{doc.title}]]></title>")
|
||||
context_parts.append(f" <url><![CDATA[{doc.source}]]></url>")
|
||||
context_parts.append(f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>")
|
||||
context_parts.append("</document_metadata>")
|
||||
context_parts.append("")
|
||||
context_parts.append("<document_content>")
|
||||
|
||||
if hasattr(doc, 'chunks') and doc.chunks:
|
||||
for chunk in doc.chunks:
|
||||
context_parts.append(
|
||||
f" <chunk id='doc-{chunk.id}'><![CDATA[{chunk.content}]]></chunk>"
|
||||
)
|
||||
else:
|
||||
context_parts.append(
|
||||
f" <chunk id='doc-0'><![CDATA[{doc.content}]]></chunk>"
|
||||
)
|
||||
|
||||
context_parts.append("</document_content>")
|
||||
context_parts.append("</document>")
|
||||
context_parts.append("")
|
||||
|
||||
context_parts.append("</mentioned_surfsense_docs>")
|
||||
|
||||
return "\n".join(context_parts)
|
||||
|
|
@ -236,8 +261,11 @@ async def stream_new_chat(
|
|||
# Fetch mentioned SurfSense docs if any
|
||||
mentioned_surfsense_docs: list[SurfsenseDocsDocument] = []
|
||||
if mentioned_surfsense_doc_ids:
|
||||
from sqlalchemy.orm import selectinload
|
||||
result = await session.execute(
|
||||
select(SurfsenseDocsDocument).filter(
|
||||
select(SurfsenseDocsDocument)
|
||||
.options(selectinload(SurfsenseDocsDocument.chunks))
|
||||
.filter(
|
||||
SurfsenseDocsDocument.id.in_(mentioned_surfsense_doc_ids),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -265,7 +265,10 @@ export default function NewChatPage() {
|
|||
setMessages([]);
|
||||
setThreadId(null);
|
||||
setMessageThinkingSteps(new Map());
|
||||
setMentionedDocumentIds([]);
|
||||
setMentionedDocumentIds({
|
||||
surfsense_doc_ids: [],
|
||||
document_ids: [],
|
||||
});
|
||||
setMentionedDocuments([]);
|
||||
setMessageDocumentsMap({});
|
||||
clearPlanOwnerRegistry(); // Reset plan ownership for new chat
|
||||
|
|
@ -429,7 +432,7 @@ export default function NewChatPage() {
|
|||
// Track message sent
|
||||
trackChatMessageSent(searchSpaceId, currentThreadId, {
|
||||
hasAttachments: messageAttachments.length > 0,
|
||||
hasMentionedDocuments: mentionedDocumentIds.length > 0,
|
||||
hasMentionedDocuments: mentionedDocumentIds.surfsense_doc_ids.length > 0 || mentionedDocumentIds.document_ids.length > 0,
|
||||
messageLength: userQuery.length,
|
||||
});
|
||||
|
||||
|
|
@ -627,12 +630,16 @@ export default function NewChatPage() {
|
|||
// Extract attachment content to send with the request
|
||||
const attachments = extractAttachmentContent(messageAttachments);
|
||||
|
||||
// Get mentioned document IDs for context
|
||||
const documentIds = mentionedDocumentIds.length > 0 ? [...mentionedDocumentIds] : undefined;
|
||||
// Get mentioned document IDs for context (separate fields for backend)
|
||||
const hasDocumentIds = mentionedDocumentIds.document_ids.length > 0;
|
||||
const hasSurfsenseDocIds = mentionedDocumentIds.surfsense_doc_ids.length > 0;
|
||||
|
||||
// Clear mentioned documents after capturing them
|
||||
if (mentionedDocumentIds.length > 0) {
|
||||
setMentionedDocumentIds([]);
|
||||
if (hasDocumentIds || hasSurfsenseDocIds) {
|
||||
setMentionedDocumentIds({
|
||||
surfsense_doc_ids: [],
|
||||
document_ids: [],
|
||||
});
|
||||
setMentionedDocuments([]);
|
||||
}
|
||||
|
||||
|
|
@ -648,7 +655,8 @@ export default function NewChatPage() {
|
|||
search_space_id: searchSpaceId,
|
||||
messages: messageHistory,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
mentioned_document_ids: documentIds,
|
||||
mentioned_document_ids: hasDocumentIds ? mentionedDocumentIds.document_ids : undefined,
|
||||
mentioned_surfsense_doc_ids: hasSurfsenseDocIds ? mentionedDocumentIds.surfsense_doc_ids : undefined,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,19 +1,25 @@
|
|||
"use client";
|
||||
|
||||
import { atom } from "jotai";
|
||||
import type { Document } from "@/contracts/types/document.types";
|
||||
import type { Document, SurfsenseDocsDocument } from "@/contracts/types/document.types";
|
||||
|
||||
/**
|
||||
* Atom to store the IDs of documents mentioned in the current chat composer.
|
||||
* This is used to pass document context to the backend when sending a message.
|
||||
*/
|
||||
export const mentionedDocumentIdsAtom = atom<number[]>([]);
|
||||
export const mentionedDocumentIdsAtom = atom<{
|
||||
surfsense_doc_ids: number[];
|
||||
document_ids: number[];
|
||||
}>({
|
||||
surfsense_doc_ids: [],
|
||||
document_ids: [],
|
||||
});
|
||||
|
||||
/**
|
||||
* Atom to store the full document objects mentioned in the current chat composer.
|
||||
* This persists across component remounts.
|
||||
*/
|
||||
export const mentionedDocumentsAtom = atom<Document[]>([]);
|
||||
export const mentionedDocumentsAtom = atom<(Pick<Document, "id" | "title" | "document_type">)[]>([]);
|
||||
|
||||
/**
|
||||
* Simplified document info for display purposes
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { atom } from "jotai";
|
||||
import type { SurfsenseDocsDocument } from "@/contracts/types/document.types";
|
||||
|
||||
/**
|
||||
* Atom to store the IDs of SurfSense docs mentioned in the current chat composer.
|
||||
* This is used to pass documentation context to the backend when sending a message.
|
||||
*/
|
||||
export const mentionedSurfsenseDocIdsAtom = atom<number[]>([]);
|
||||
|
||||
/**
|
||||
* Atom to store the full SurfSense doc objects mentioned in the current chat composer.
|
||||
* This persists across component remounts.
|
||||
*/
|
||||
export const mentionedSurfsenseDocsAtom = atom<SurfsenseDocsDocument[]>([]);
|
||||
|
||||
/**
|
||||
* Simplified SurfSense doc info for display purposes
|
||||
*/
|
||||
export interface MentionedSurfsenseDocInfo {
|
||||
id: number;
|
||||
title: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atom to store mentioned SurfSense docs per message ID.
|
||||
* This allows displaying which docs were mentioned with each user message.
|
||||
*/
|
||||
export const messageSurfsenseDocsMapAtom = atom<Record<string, MentionedSurfsenseDocInfo[]>>({});
|
||||
|
||||
|
|
@ -53,7 +53,10 @@ export const Composer: FC = () => {
|
|||
|
||||
// Sync mentioned document IDs to atom for use in chat request
|
||||
useEffect(() => {
|
||||
setMentionedDocumentIds(mentionedDocuments.map((doc) => doc.id));
|
||||
setMentionedDocumentIds({
|
||||
surfsense_doc_ids: mentionedDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id),
|
||||
document_ids: mentionedDocuments.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
|
||||
});
|
||||
}, [mentionedDocuments, setMentionedDocumentIds]);
|
||||
|
||||
// Handle text change from inline editor - sync with assistant-ui composer
|
||||
|
|
@ -119,7 +122,10 @@ export const Composer: FC = () => {
|
|||
// Clear the editor after sending
|
||||
editorRef.current?.clear();
|
||||
setMentionedDocuments([]);
|
||||
setMentionedDocumentIds([]);
|
||||
setMentionedDocumentIds({
|
||||
surfsense_doc_ids: [],
|
||||
document_ids: [],
|
||||
});
|
||||
}
|
||||
}, [
|
||||
showDocumentPopover,
|
||||
|
|
@ -129,41 +135,48 @@ export const Composer: FC = () => {
|
|||
setMentionedDocumentIds,
|
||||
]);
|
||||
|
||||
// Handle document removal from inline editor
|
||||
const handleDocumentRemove = useCallback(
|
||||
(docId: number) => {
|
||||
(docId: number, docType?: string) => {
|
||||
setMentionedDocuments((prev) => {
|
||||
const updated = prev.filter((doc) => doc.id !== docId);
|
||||
// Immediately sync document IDs to avoid race conditions
|
||||
setMentionedDocumentIds(updated.map((doc) => doc.id));
|
||||
const updated = prev.filter(
|
||||
(doc) => !(doc.id === docId && doc.document_type === docType)
|
||||
);
|
||||
setMentionedDocumentIds({
|
||||
surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id),
|
||||
document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[setMentionedDocuments, setMentionedDocumentIds]
|
||||
);
|
||||
|
||||
// Handle document selection from picker
|
||||
const handleDocumentsMention = useCallback(
|
||||
(documents: Document[]) => {
|
||||
// Insert chips into the inline editor for each new document
|
||||
const existingIds = new Set(mentionedDocuments.map((d) => d.id));
|
||||
const newDocs = documents.filter((doc) => !existingIds.has(doc.id));
|
||||
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
|
||||
const existingKeys = new Set(
|
||||
mentionedDocuments.map((d) => `${d.document_type}:${d.id}`)
|
||||
);
|
||||
const newDocs = documents.filter(
|
||||
(doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`)
|
||||
);
|
||||
|
||||
for (const doc of newDocs) {
|
||||
editorRef.current?.insertDocumentChip(doc);
|
||||
}
|
||||
|
||||
// Update mentioned documents state
|
||||
setMentionedDocuments((prev) => {
|
||||
const existingIdSet = new Set(prev.map((d) => d.id));
|
||||
const uniqueNewDocs = documents.filter((doc) => !existingIdSet.has(doc.id));
|
||||
const existingKeySet = new Set(prev.map((d) => `${d.document_type}:${d.id}`));
|
||||
const uniqueNewDocs = documents.filter(
|
||||
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
|
||||
);
|
||||
const updated = [...prev, ...uniqueNewDocs];
|
||||
// Immediately sync document IDs to avoid race conditions
|
||||
setMentionedDocumentIds(updated.map((doc) => doc.id));
|
||||
setMentionedDocumentIds({
|
||||
surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id),
|
||||
document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Reset mention query but keep popover open for more selections
|
||||
setMentionQuery("");
|
||||
},
|
||||
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export interface InlineMentionEditorRef {
|
|||
clear: () => void;
|
||||
getText: () => string;
|
||||
getMentionedDocuments: () => MentionedDocument[];
|
||||
insertDocumentChip: (doc: Document) => void;
|
||||
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
|
||||
}
|
||||
|
||||
interface InlineMentionEditorProps {
|
||||
|
|
@ -34,7 +34,7 @@ interface InlineMentionEditorProps {
|
|||
onMentionClose?: () => void;
|
||||
onSubmit?: () => void;
|
||||
onChange?: (text: string, docs: MentionedDocument[]) => void;
|
||||
onDocumentRemove?: (docId: number) => void;
|
||||
onDocumentRemove?: (docId: number, docType?: string) => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
|
|
@ -44,6 +44,7 @@ interface InlineMentionEditorProps {
|
|||
// Unique data attribute to identify chip elements
|
||||
const CHIP_DATA_ATTR = "data-mention-chip";
|
||||
const CHIP_ID_ATTR = "data-mention-id";
|
||||
const CHIP_DOCTYPE_ATTR = "data-mention-doctype";
|
||||
|
||||
/**
|
||||
* Type guard to check if a node is a chip element
|
||||
|
|
@ -66,6 +67,13 @@ function getChipId(element: Element): number | null {
|
|||
return Number.isNaN(id) ? null : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chip document type from element attribute
|
||||
*/
|
||||
function getChipDocType(element: Element): string {
|
||||
return element.getAttribute(CHIP_DOCTYPE_ATTR) ?? "UNKNOWN";
|
||||
}
|
||||
|
||||
export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMentionEditorProps>(
|
||||
(
|
||||
{
|
||||
|
|
@ -84,15 +92,15 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
) => {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [mentionedDocs, setMentionedDocs] = useState<Map<number, MentionedDocument>>(
|
||||
() => new Map(initialDocuments.map((d) => [d.id, d]))
|
||||
const [mentionedDocs, setMentionedDocs] = useState<Map<string, MentionedDocument>>(
|
||||
() => new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d]))
|
||||
);
|
||||
const isComposingRef = useRef(false);
|
||||
|
||||
// Sync initial documents
|
||||
useEffect(() => {
|
||||
if (initialDocuments.length > 0) {
|
||||
setMentionedDocs(new Map(initialDocuments.map((d) => [d.id, d])));
|
||||
setMentionedDocs(new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d])));
|
||||
}
|
||||
}, [initialDocuments]);
|
||||
|
||||
|
|
@ -153,6 +161,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
const chip = document.createElement("span");
|
||||
chip.setAttribute(CHIP_DATA_ATTR, "true");
|
||||
chip.setAttribute(CHIP_ID_ATTR, String(doc.id));
|
||||
chip.setAttribute(CHIP_DOCTYPE_ATTR, doc.document_type ?? "UNKNOWN");
|
||||
chip.contentEditable = "false";
|
||||
chip.className =
|
||||
"inline-flex items-center gap-0.5 mx-0.5 pl-1 pr-0.5 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary border border-primary/10 select-none";
|
||||
|
|
@ -175,13 +184,14 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
chip.remove();
|
||||
const docKey = `${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
|
||||
setMentionedDocs((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(doc.id);
|
||||
next.delete(docKey);
|
||||
return next;
|
||||
});
|
||||
// Notify parent that a document was removed
|
||||
onDocumentRemove?.(doc.id);
|
||||
onDocumentRemove?.(doc.id, doc.document_type);
|
||||
focusAtEnd();
|
||||
};
|
||||
|
||||
|
|
@ -195,7 +205,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
|
||||
// Insert a document chip at the current cursor position
|
||||
const insertDocumentChip = useCallback(
|
||||
(doc: Document) => {
|
||||
(doc: Pick<Document, "id" | "title" | "document_type">) => {
|
||||
if (!editorRef.current) return;
|
||||
|
||||
// Validate required fields for type safety
|
||||
|
|
@ -210,8 +220,9 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
document_type: doc.document_type,
|
||||
};
|
||||
|
||||
// Add to mentioned docs map
|
||||
setMentionedDocs((prev) => new Map(prev).set(doc.id, mentionDoc));
|
||||
// Add to mentioned docs map using unique key
|
||||
const docKey = `${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
|
||||
setMentionedDocs((prev) => new Map(prev).set(docKey, mentionDoc));
|
||||
|
||||
// Find and remove the @query text
|
||||
const selection = window.getSelection();
|
||||
|
|
@ -413,15 +424,17 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
if (isChipElement(prevSibling)) {
|
||||
e.preventDefault();
|
||||
const chipId = getChipId(prevSibling);
|
||||
const chipDocType = getChipDocType(prevSibling);
|
||||
if (chipId !== null) {
|
||||
prevSibling.remove();
|
||||
const chipKey = `${chipDocType}:${chipId}`;
|
||||
setMentionedDocs((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(chipId);
|
||||
next.delete(chipKey);
|
||||
return next;
|
||||
});
|
||||
// Notify parent that a document was removed
|
||||
onDocumentRemove?.(chipId);
|
||||
onDocumentRemove?.(chipId, chipDocType);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -448,15 +461,17 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
if (isChipElement(prevChild)) {
|
||||
e.preventDefault();
|
||||
const chipId = getChipId(prevChild);
|
||||
const chipDocType = getChipDocType(prevChild);
|
||||
if (chipId !== null) {
|
||||
prevChild.remove();
|
||||
const chipKey = `${chipDocType}:${chipId}`;
|
||||
setMentionedDocs((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(chipId);
|
||||
next.delete(chipKey);
|
||||
return next;
|
||||
});
|
||||
// Notify parent that a document was removed
|
||||
onDocumentRemove?.(chipId);
|
||||
onDocumentRemove?.(chipId, chipDocType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,7 +229,10 @@ const Composer: FC = () => {
|
|||
|
||||
// Sync mentioned document IDs to atom for use in chat request
|
||||
useEffect(() => {
|
||||
setMentionedDocumentIds(mentionedDocuments.map((doc) => doc.id));
|
||||
setMentionedDocumentIds({
|
||||
surfsense_doc_ids: mentionedDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id),
|
||||
document_ids: mentionedDocuments.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
|
||||
});
|
||||
}, [mentionedDocuments, setMentionedDocumentIds]);
|
||||
|
||||
// Handle text change from inline editor - sync with assistant-ui composer
|
||||
|
|
@ -295,7 +298,10 @@ const Composer: FC = () => {
|
|||
// Clear the editor after sending
|
||||
editorRef.current?.clear();
|
||||
setMentionedDocuments([]);
|
||||
setMentionedDocumentIds([]);
|
||||
setMentionedDocumentIds({
|
||||
surfsense_doc_ids: [],
|
||||
document_ids: [],
|
||||
});
|
||||
}
|
||||
}, [
|
||||
showDocumentPopover,
|
||||
|
|
@ -305,41 +311,48 @@ const Composer: FC = () => {
|
|||
setMentionedDocumentIds,
|
||||
]);
|
||||
|
||||
// Handle document removal from inline editor
|
||||
const handleDocumentRemove = useCallback(
|
||||
(docId: number) => {
|
||||
(docId: number, docType?: string) => {
|
||||
setMentionedDocuments((prev) => {
|
||||
const updated = prev.filter((doc) => doc.id !== docId);
|
||||
// Immediately sync document IDs to avoid race conditions
|
||||
setMentionedDocumentIds(updated.map((doc) => doc.id));
|
||||
const updated = prev.filter(
|
||||
(doc) => !(doc.id === docId && doc.document_type === docType)
|
||||
);
|
||||
setMentionedDocumentIds({
|
||||
surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id),
|
||||
document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[setMentionedDocuments, setMentionedDocumentIds]
|
||||
);
|
||||
|
||||
// Handle document selection from picker
|
||||
const handleDocumentsMention = useCallback(
|
||||
(documents: Document[]) => {
|
||||
// Insert chips into the inline editor for each new document
|
||||
const existingIds = new Set(mentionedDocuments.map((d) => d.id));
|
||||
const newDocs = documents.filter((doc) => !existingIds.has(doc.id));
|
||||
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
|
||||
const existingKeys = new Set(
|
||||
mentionedDocuments.map((d) => `${d.document_type}:${d.id}`)
|
||||
);
|
||||
const newDocs = documents.filter(
|
||||
(doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`)
|
||||
);
|
||||
|
||||
for (const doc of newDocs) {
|
||||
editorRef.current?.insertDocumentChip(doc);
|
||||
}
|
||||
|
||||
// Update mentioned documents state
|
||||
setMentionedDocuments((prev) => {
|
||||
const existingIdSet = new Set(prev.map((d) => d.id));
|
||||
const uniqueNewDocs = documents.filter((doc) => !existingIdSet.has(doc.id));
|
||||
const existingKeySet = new Set(prev.map((d) => `${d.document_type}:${d.id}`));
|
||||
const uniqueNewDocs = documents.filter(
|
||||
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
|
||||
);
|
||||
const updated = [...prev, ...uniqueNewDocs];
|
||||
// Immediately sync document IDs to avoid race conditions
|
||||
setMentionedDocumentIds(updated.map((doc) => doc.id));
|
||||
setMentionedDocumentIds({
|
||||
surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id),
|
||||
document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Reset mention query but keep popover open for more selections
|
||||
setMentionQuery("");
|
||||
},
|
||||
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
|
||||
|
|
@ -640,7 +653,7 @@ const UserMessage: FC = () => {
|
|||
{/* Mentioned documents as chips */}
|
||||
{mentionedDocs?.map((doc) => (
|
||||
<span
|
||||
key={doc.id}
|
||||
key={`${doc.document_type}:${doc.id}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20"
|
||||
title={doc.title}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export const UserMessage: FC = () => {
|
|||
{/* Mentioned documents as chips */}
|
||||
{mentionedDocs?.map((doc) => (
|
||||
<span
|
||||
key={doc.id}
|
||||
key={`${doc.document_type}:${doc.id}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20"
|
||||
title={doc.title}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -25,9 +25,9 @@ export interface DocumentMentionPickerRef {
|
|||
|
||||
interface DocumentMentionPickerProps {
|
||||
searchSpaceId: number;
|
||||
onSelectionChange: (documents: Document[]) => void;
|
||||
onSelectionChange: (documents: Pick<Document, "id" | "title" | "document_type">[]) => void;
|
||||
onDone: () => void;
|
||||
initialSelectedDocuments?: Document[];
|
||||
initialSelectedDocuments?: Pick<Document, "id" | "title" | "document_type">[];
|
||||
externalSearch?: string;
|
||||
}
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ export const DocumentMentionPicker = forwardRef<
|
|||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// State for pagination
|
||||
const [accumulatedDocuments, setAccumulatedDocuments] = useState<Document[]>([]);
|
||||
const [accumulatedDocuments, setAccumulatedDocuments] = useState<Pick<Document, "id" | "title" | "document_type">[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
|
|
@ -90,6 +90,13 @@ export const DocumentMentionPicker = forwardRef<
|
|||
};
|
||||
}, [debouncedSearch, searchSpaceId]);
|
||||
|
||||
const surfsenseDocsQueryParams = useMemo(() => {
|
||||
return {
|
||||
page: 0,
|
||||
page_size: PAGE_SIZE,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Use query for fetching first page of documents
|
||||
const { data: documents, isLoading: isDocumentsLoading } = useQuery({
|
||||
queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams),
|
||||
|
|
@ -106,22 +113,45 @@ export const DocumentMentionPicker = forwardRef<
|
|||
enabled: !!searchSpaceId && !!debouncedSearch.trim() && currentPage === 0,
|
||||
});
|
||||
|
||||
// Update accumulated documents when first page loads
|
||||
// Use query for fetching first page of SurfSense docs
|
||||
const { data: surfsenseDocs, isLoading: isSurfsenseDocsLoading } = useQuery({
|
||||
queryKey: ["surfsense-docs-mention", surfsenseDocsQueryParams],
|
||||
queryFn: () => documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }),
|
||||
staleTime: 3 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Update accumulated documents when first page loads - combine both sources
|
||||
useEffect(() => {
|
||||
if (currentPage === 0) {
|
||||
const combinedDocs: Pick<Document, "id" | "title" | "document_type">[] = [];
|
||||
|
||||
// Add SurfSense docs first (they appear at top)
|
||||
if (surfsenseDocs?.items) {
|
||||
for (const doc of surfsenseDocs.items) {
|
||||
combinedDocs.push({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
document_type: "SURFSENSE_DOCS",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add regular documents
|
||||
if (debouncedSearch.trim()) {
|
||||
if (searchedDocuments) {
|
||||
setAccumulatedDocuments(searchedDocuments.items);
|
||||
if (searchedDocuments?.items) {
|
||||
combinedDocs.push(...searchedDocuments.items);
|
||||
setHasMore(searchedDocuments.has_more);
|
||||
}
|
||||
} else {
|
||||
if (documents) {
|
||||
setAccumulatedDocuments(documents.items);
|
||||
if (documents?.items) {
|
||||
combinedDocs.push(...documents.items);
|
||||
setHasMore(documents.has_more);
|
||||
}
|
||||
}
|
||||
|
||||
setAccumulatedDocuments(combinedDocs);
|
||||
}
|
||||
}, [documents, searchedDocuments, debouncedSearch, currentPage]);
|
||||
}, [documents, searchedDocuments, surfsenseDocs, debouncedSearch, currentPage]);
|
||||
|
||||
// Function to load next page
|
||||
const loadNextPage = useCallback(async () => {
|
||||
|
|
@ -175,22 +205,22 @@ export const DocumentMentionPicker = forwardRef<
|
|||
|
||||
const actualDocuments = accumulatedDocuments;
|
||||
const actualLoading =
|
||||
(debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) && currentPage === 0;
|
||||
((debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) || isSurfsenseDocsLoading) && currentPage === 0;
|
||||
|
||||
// Track already selected document IDs
|
||||
const selectedIds = useMemo(
|
||||
() => new Set(initialSelectedDocuments.map((d) => d.id)),
|
||||
// Track already selected documents using unique key (document_type:id) to avoid ID collisions
|
||||
const selectedKeys = useMemo(
|
||||
() => new Set(initialSelectedDocuments.map((d) => `${d.document_type}:${d.id}`)),
|
||||
[initialSelectedDocuments]
|
||||
);
|
||||
|
||||
// Filter out already selected documents for navigation
|
||||
const selectableDocuments = useMemo(
|
||||
() => actualDocuments.filter((doc) => !selectedIds.has(doc.id)),
|
||||
[actualDocuments, selectedIds]
|
||||
() => actualDocuments.filter((doc) => !selectedKeys.has(`${doc.document_type}:${doc.id}`)),
|
||||
[actualDocuments, selectedKeys]
|
||||
);
|
||||
|
||||
const handleSelectDocument = useCallback(
|
||||
(doc: Document) => {
|
||||
(doc: Pick<Document, "id" | "title" | "document_type">) => {
|
||||
onSelectionChange([...initialSelectedDocuments, doc]);
|
||||
onDone();
|
||||
},
|
||||
|
|
@ -287,13 +317,16 @@ export const DocumentMentionPicker = forwardRef<
|
|||
) : (
|
||||
<div className="py-1">
|
||||
{actualDocuments.map((doc) => {
|
||||
const isAlreadySelected = selectedIds.has(doc.id);
|
||||
const selectableIndex = selectableDocuments.findIndex((d) => d.id === doc.id);
|
||||
const docKey = `${doc.document_type}:${doc.id}`;
|
||||
const isAlreadySelected = selectedKeys.has(docKey);
|
||||
const selectableIndex = selectableDocuments.findIndex(
|
||||
(d) => d.document_type === doc.document_type && d.id === doc.id
|
||||
);
|
||||
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={doc.id}
|
||||
key={docKey}
|
||||
ref={(el) => {
|
||||
if (el && selectableIndex >= 0) {
|
||||
itemRefs.current.set(selectableIndex, el);
|
||||
|
|
|
|||
|
|
@ -188,16 +188,11 @@ export const getSurfsenseDocsByChunkResponse = surfsenseDocsDocumentWithChunks;
|
|||
* List Surfsense docs
|
||||
*/
|
||||
export const getSurfsenseDocsRequest = z.object({
|
||||
page: z.number().optional(),
|
||||
page_size: z.number().optional(),
|
||||
title: z.string().optional(),
|
||||
queryParams: paginationQueryParams
|
||||
});
|
||||
|
||||
export const getSurfsenseDocsResponse = z.object({
|
||||
items: z.array(surfsenseDocsDocument.extend({
|
||||
created_at: z.string().nullable().optional(),
|
||||
updated_at: z.string().nullable().optional(),
|
||||
})),
|
||||
items: z.array(surfsenseDocsDocument),
|
||||
total: z.number(),
|
||||
page: z.number(),
|
||||
page_size: z.number(),
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
updateDocumentResponse,
|
||||
uploadDocumentRequest,
|
||||
uploadDocumentResponse,
|
||||
getSurfsenseDocsRequest,
|
||||
} from "@/contracts/types/document.types";
|
||||
import { ValidationError } from "../error";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
|
@ -226,23 +227,28 @@ class DocumentsApiService {
|
|||
/**
|
||||
* List all Surfsense documentation documents
|
||||
*/
|
||||
getSurfsenseDocs = async (request: GetSurfsenseDocsRequest = {}) => {
|
||||
const queryParams = new URLSearchParams();
|
||||
getSurfsenseDocs = async (request: GetSurfsenseDocsRequest) => {
|
||||
const parsedRequest = getSurfsenseDocsRequest.safeParse(request);
|
||||
|
||||
if (request.page !== undefined) {
|
||||
queryParams.set("page", String(request.page));
|
||||
}
|
||||
if (request.page_size !== undefined) {
|
||||
queryParams.set("page_size", String(request.page_size));
|
||||
}
|
||||
if (request.title) {
|
||||
queryParams.set("title", request.title);
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
|
||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
const url = queryString
|
||||
? `/api/v1/surfsense-docs?${queryString}`
|
||||
: "/api/v1/surfsense-docs";
|
||||
// Transform query params to be string values
|
||||
const transformedQueryParams = parsedRequest.data.queryParams
|
||||
? Object.fromEntries(
|
||||
Object.entries(parsedRequest.data.queryParams).map(([k, v]) => [k, String(v)])
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const queryParams = transformedQueryParams
|
||||
? new URLSearchParams(transformedQueryParams).toString()
|
||||
: "";
|
||||
|
||||
const url = `/api/v1/surfsense-docs?${queryParams}`;
|
||||
|
||||
return baseApiService.get(url, getSurfsenseDocsResponse);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue