mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 09:12:40 +02:00
feat: improved document, folder mentions rendering
Some checks are pending
Build and Push Docker Images / tag_release (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Blocked by required conditions
Some checks are pending
Build and Push Docker Images / tag_release (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Blocked by required conditions
This commit is contained in:
parent
28a02a9143
commit
c8374e6c5b
59 changed files with 1725 additions and 361 deletions
|
|
@ -199,12 +199,16 @@ function pairBundleToolCallIds(
|
|||
}
|
||||
|
||||
/**
|
||||
* Zod schema for mentioned document info (for type-safe parsing)
|
||||
* Zod schema for mentioned document info (for type-safe parsing).
|
||||
*
|
||||
* ``kind`` defaults to ``"doc"`` so messages persisted before folder
|
||||
* mentions existed deserialise unchanged.
|
||||
*/
|
||||
const MentionedDocumentInfoSchema = z.object({
|
||||
id: z.number(),
|
||||
title: z.string(),
|
||||
document_type: z.string(),
|
||||
kind: z.union([z.literal("doc"), z.literal("folder")]).optional().default("doc"),
|
||||
});
|
||||
|
||||
const MentionedDocumentsPartSchema = z.object({
|
||||
|
|
@ -913,18 +917,29 @@ export default function NewChatPage() {
|
|||
hasAttachments: userImages.length > 0,
|
||||
hasMentionedDocuments:
|
||||
mentionedDocumentIds.surfsense_doc_ids.length > 0 ||
|
||||
mentionedDocumentIds.document_ids.length > 0,
|
||||
mentionedDocumentIds.document_ids.length > 0 ||
|
||||
mentionedDocumentIds.folder_ids.length > 0,
|
||||
messageLength: userQuery.length,
|
||||
});
|
||||
|
||||
// Collect unique mentioned docs for display & persistence
|
||||
// Collect unique mention chips for display & persistence.
|
||||
// Dedup key is ``kind:document_type:id`` so a folder and a
|
||||
// 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
|
||||
// can render the correct chip type on reload.
|
||||
const allMentionedDocs: MentionedDocumentInfo[] = [];
|
||||
const seenDocKeys = new Set<string>();
|
||||
for (const doc of mentionedDocuments) {
|
||||
const key = `${doc.document_type}:${doc.id}`;
|
||||
const key = `${doc.kind}:${doc.document_type}:${doc.id}`;
|
||||
if (seenDocKeys.has(key)) continue;
|
||||
seenDocKeys.add(key);
|
||||
allMentionedDocs.push({ id: doc.id, title: doc.title, document_type: doc.document_type });
|
||||
allMentionedDocs.push({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
document_type: doc.document_type,
|
||||
kind: doc.kind,
|
||||
});
|
||||
}
|
||||
|
||||
if (allMentionedDocs.length > 0) {
|
||||
|
|
@ -986,9 +1001,10 @@ export default function NewChatPage() {
|
|||
// 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;
|
||||
const hasFolderIds = mentionedDocumentIds.folder_ids.length > 0;
|
||||
|
||||
// Clear mentioned documents after capturing them
|
||||
if (hasDocumentIds || hasSurfsenseDocIds) {
|
||||
if (hasDocumentIds || hasSurfsenseDocIds || hasFolderIds) {
|
||||
setMentionedDocuments([]);
|
||||
}
|
||||
|
||||
|
|
@ -1013,7 +1029,11 @@ export default function NewChatPage() {
|
|||
mentioned_surfsense_doc_ids: hasSurfsenseDocIds
|
||||
? mentionedDocumentIds.surfsense_doc_ids
|
||||
: undefined,
|
||||
// Full mention metadata so the BE can embed a
|
||||
mentioned_folder_ids: hasFolderIds
|
||||
? mentionedDocumentIds.folder_ids
|
||||
: undefined,
|
||||
// Full mention metadata (docs + folders, with
|
||||
// ``kind`` discriminator) so the BE can embed a
|
||||
// ``mentioned-documents`` ContentPart on the
|
||||
// persisted user message (replaces the old FE-side
|
||||
// injection in ``persistUserTurn``).
|
||||
|
|
@ -1023,6 +1043,7 @@ export default function NewChatPage() {
|
|||
id: d.id,
|
||||
title: d.title,
|
||||
document_type: d.document_type,
|
||||
kind: d.kind,
|
||||
}))
|
||||
: undefined,
|
||||
disabled_tools: disabledTools.length > 0 ? disabledTools : undefined,
|
||||
|
|
@ -1855,6 +1876,23 @@ export default function NewChatPage() {
|
|||
const selection = await getAgentFilesystemSelection(searchSpaceId, {
|
||||
localFilesystemEnabled,
|
||||
});
|
||||
// Partition the source mentions back into doc/surfsense_doc/folder
|
||||
// id buckets so the regenerate route can pass them to
|
||||
// ``stream_new_chat`` and the priority middleware sees the
|
||||
// same ``[USER-MENTIONED]`` priority entries the original
|
||||
// turn did. Without this partition the regenerate flow
|
||||
// silently dropped the agent's mention awareness — same
|
||||
// architectural bug we fixed on the new-chat path.
|
||||
const regenerateSurfsenseDocIds = sourceMentionedDocs
|
||||
.filter((d) => d.kind === "doc" && d.document_type === "SURFSENSE_DOCS")
|
||||
.map((d) => d.id);
|
||||
const regenerateDocIds = sourceMentionedDocs
|
||||
.filter((d) => d.kind === "doc" && d.document_type !== "SURFSENSE_DOCS")
|
||||
.map((d) => d.id);
|
||||
const regenerateFolderIds = sourceMentionedDocs
|
||||
.filter((d) => d.kind === "folder")
|
||||
.map((d) => d.id);
|
||||
|
||||
const requestBody: Record<string, unknown> = {
|
||||
search_space_id: searchSpaceId,
|
||||
user_query: newUserQuery,
|
||||
|
|
@ -1862,6 +1900,12 @@ export default function NewChatPage() {
|
|||
filesystem_mode: selection.filesystem_mode,
|
||||
client_platform: selection.client_platform,
|
||||
local_filesystem_mounts: selection.local_filesystem_mounts,
|
||||
mentioned_document_ids:
|
||||
regenerateDocIds.length > 0 ? regenerateDocIds : undefined,
|
||||
mentioned_surfsense_doc_ids:
|
||||
regenerateSurfsenseDocIds.length > 0 ? regenerateSurfsenseDocIds : undefined,
|
||||
mentioned_folder_ids:
|
||||
regenerateFolderIds.length > 0 ? regenerateFolderIds : undefined,
|
||||
// Full mention metadata for the regenerate-specific
|
||||
// source list. Only meaningful for edit (the BE only
|
||||
// re-persists a user row when ``user_query`` is set);
|
||||
|
|
@ -1872,6 +1916,7 @@ export default function NewChatPage() {
|
|||
id: d.id,
|
||||
title: d.title,
|
||||
document_type: d.document_type,
|
||||
kind: d.kind,
|
||||
}))
|
||||
: undefined,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,45 +4,108 @@ import { atom } from "jotai";
|
|||
import type { Document } from "@/contracts/types/document.types";
|
||||
|
||||
/**
|
||||
* Atom to store the full document objects mentioned via @-mention chips
|
||||
* in the current chat composer. This persists across component remounts.
|
||||
* 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 mentionedDocumentsAtom = atom<Pick<Document, "id" | "title" | "document_type">[]>([]);
|
||||
export const FOLDER_MENTION_DOCUMENT_TYPE = "FOLDER";
|
||||
|
||||
/**
|
||||
* Derived read-only atom that maps deduplicated mentioned docs
|
||||
* into backend payload fields.
|
||||
*/
|
||||
export const mentionedDocumentIdsAtom = atom((get) => {
|
||||
const allDocs = get(mentionedDocumentsAtom);
|
||||
const seen = new Set<string>();
|
||||
const deduped = allDocs.filter((d) => {
|
||||
const key = `${d.document_type}:${d.id}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
return {
|
||||
surfsense_doc_ids: deduped
|
||||
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
||||
.map((doc) => doc.id),
|
||||
document_ids: deduped
|
||||
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
|
||||
.map((doc) => doc.id),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Simplified document info for display purposes
|
||||
* Display metadata for a single ``@``-mention chip.
|
||||
*
|
||||
* The ``kind`` discriminator identifies whether the chip is a
|
||||
* knowledge-base document or a knowledge-base folder. Folders carry
|
||||
* the sentinel ``document_type === FOLDER_MENTION_DOCUMENT_TYPE`` so
|
||||
* the editor, picker, and persisted ``mentioned-documents`` content
|
||||
* part all stay aligned with the backend Pydantic schema.
|
||||
*/
|
||||
export interface MentionedDocumentInfo {
|
||||
id: number;
|
||||
title: string;
|
||||
document_type: string;
|
||||
kind: "doc" | "folder";
|
||||
}
|
||||
|
||||
/**
|
||||
* Atom to store mentioned documents per message ID.
|
||||
* Backwards-compatible doc-only chip shape for legacy callers that
|
||||
* haven't migrated to the discriminated union yet. Keep narrow so
|
||||
* accidental new callers fail typecheck and route through the
|
||||
* discriminated type instead.
|
||||
*/
|
||||
type LegacyDocMention = Pick<Document, "id" | "title" | "document_type">;
|
||||
|
||||
/**
|
||||
* Normalize an arbitrary chip-like input into the discriminated
|
||||
* ``MentionedDocumentInfo`` shape. Existing call sites that only have
|
||||
* ``{id, title, document_type}`` flow through here so they don't have
|
||||
* to thread ``kind`` everywhere — the helper defaults to ``"doc"`` and
|
||||
* rewrites the document type for folders.
|
||||
*/
|
||||
export function toMentionedDocumentInfo(
|
||||
input: LegacyDocMention | MentionedDocumentInfo
|
||||
): MentionedDocumentInfo {
|
||||
if ("kind" in input && (input.kind === "doc" || input.kind === "folder")) {
|
||||
return input;
|
||||
}
|
||||
return {
|
||||
id: input.id,
|
||||
title: input.title,
|
||||
document_type: input.document_type,
|
||||
kind: "doc",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a folder-mention chip from a folder row (id + name).
|
||||
*/
|
||||
export function makeFolderMention(input: { id: number; name: string }): MentionedDocumentInfo {
|
||||
return {
|
||||
id: input.id,
|
||||
title: input.name,
|
||||
document_type: FOLDER_MENTION_DOCUMENT_TYPE,
|
||||
kind: "folder",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Atom to store the full mention objects (documents + folders) attached
|
||||
* via @-mention chips in the current chat composer. Persists across
|
||||
* component remounts.
|
||||
*/
|
||||
export const mentionedDocumentsAtom = atom<MentionedDocumentInfo[]>([]);
|
||||
|
||||
/**
|
||||
* Derived read-only atom that maps deduplicated mention chips into
|
||||
* backend payload fields. Doc chips split by ``document_type`` exactly
|
||||
* like before; folder chips are projected into a separate
|
||||
* ``folder_ids`` bucket so the route can forward
|
||||
* ``mentioned_folder_ids`` to the agent without the priority middleware
|
||||
* conflating them with hybrid-search ids.
|
||||
*/
|
||||
export const mentionedDocumentIdsAtom = atom((get) => {
|
||||
const allMentions = get(mentionedDocumentsAtom);
|
||||
const seen = new Set<string>();
|
||||
const deduped = allMentions.filter((m) => {
|
||||
const key = `${m.kind}:${m.document_type}:${m.id}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
const docs = deduped.filter((m) => m.kind === "doc");
|
||||
const folders = deduped.filter((m) => m.kind === "folder");
|
||||
return {
|
||||
surfsense_doc_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),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Atom to store mentioned chips per message ID.
|
||||
* This allows displaying which documents were mentioned with each user message.
|
||||
*/
|
||||
export const messageDocumentsMapAtom = atom<Record<string, MentionedDocumentInfo[]>>({});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Folder as FolderIcon } from "lucide-react";
|
||||
import type { PlateElementProps } from "platejs/react";
|
||||
import {
|
||||
createPlatePlugin,
|
||||
|
|
@ -9,23 +10,51 @@ import {
|
|||
usePlateEditor,
|
||||
} from "platejs/react";
|
||||
import { type FC, forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from "react";
|
||||
import { FOLDER_MENTION_DOCUMENT_TYPE } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { Document } from "@/contracts/types/document.types";
|
||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type MentionKind = "doc" | "folder";
|
||||
|
||||
export interface MentionedDocument {
|
||||
id: number;
|
||||
title: string;
|
||||
document_type?: string;
|
||||
kind: MentionKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input shape for inserting a chip. ``kind`` defaults to ``"doc"``
|
||||
* when omitted so legacy callers don't have to thread the
|
||||
* discriminator. Folder callers pass ``kind: "folder"`` and the
|
||||
* folder ``id`` and ``title``; ``document_type`` defaults to
|
||||
* ``FOLDER_MENTION_DOCUMENT_TYPE`` inside ``insertMentionChip`` so the
|
||||
* dedup key (`kind:document_type:id`) never collides with a doc chip
|
||||
* that happens to share an id.
|
||||
*/
|
||||
export type MentionChipInput = {
|
||||
id: number;
|
||||
title: string;
|
||||
document_type?: string;
|
||||
kind?: MentionKind;
|
||||
};
|
||||
|
||||
export interface InlineMentionEditorRef {
|
||||
focus: () => void;
|
||||
clear: () => void;
|
||||
setText: (text: string) => void;
|
||||
getText: () => string;
|
||||
getMentionedDocuments: () => MentionedDocument[];
|
||||
insertMentionChip: (
|
||||
mention: MentionChipInput,
|
||||
options?: { removeTriggerText?: boolean }
|
||||
) => void;
|
||||
/**
|
||||
* @deprecated Use ``insertMentionChip``. Kept for one transition
|
||||
* cycle so we don't break ad-hoc callers; prefer the new name.
|
||||
*/
|
||||
insertDocumentChip: (
|
||||
doc: Pick<Document, "id" | "title" | "document_type">,
|
||||
options?: { removeTriggerText?: boolean }
|
||||
|
|
@ -61,6 +90,13 @@ type MentionElementNode = {
|
|||
id: number;
|
||||
title: string;
|
||||
document_type?: string;
|
||||
/**
|
||||
* Discriminator added so a folder chip and a doc chip with the
|
||||
* same id round-trip cleanly through ``getMentionedDocuments``
|
||||
* and the persisted ``mentioned-documents`` content part.
|
||||
* Defaults to ``"doc"`` for nodes that predate this field.
|
||||
*/
|
||||
kind?: MentionKind;
|
||||
statusLabel?: string | null;
|
||||
statusKind?: MentionStatusKind;
|
||||
children: [{ text: "" }];
|
||||
|
|
@ -90,11 +126,17 @@ const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({
|
|||
? "text-emerald-700"
|
||||
: "text-amber-700";
|
||||
|
||||
const isFolder = element.kind === "folder";
|
||||
|
||||
return (
|
||||
<span {...attributes} className="inline-flex align-middle">
|
||||
<span contentEditable={false} className={`${MENTION_CHIP_CLASSNAME} cursor-default`}>
|
||||
<span className={MENTION_CHIP_ICON_CLASSNAME}>
|
||||
{getConnectorIcon(element.document_type ?? "UNKNOWN", "h-3 w-3")}
|
||||
{isFolder ? (
|
||||
<FolderIcon className="h-3 w-3" />
|
||||
) : (
|
||||
getConnectorIcon(element.document_type ?? "UNKNOWN", "h-3 w-3")
|
||||
)}
|
||||
</span>
|
||||
<span className={MENTION_CHIP_TITLE_CLASSNAME} title={element.title}>
|
||||
{element.title}
|
||||
|
|
@ -153,10 +195,12 @@ function getMentionedDocuments(value: ComposerValue): MentionedDocument[] {
|
|||
for (const block of value) {
|
||||
for (const node of block.children) {
|
||||
if (!isMentionNode(node)) continue;
|
||||
const kind: MentionKind = node.kind ?? "doc";
|
||||
const doc: MentionedDocument = {
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
document_type: node.document_type,
|
||||
kind,
|
||||
};
|
||||
map.set(getMentionDocKey(doc), doc);
|
||||
}
|
||||
|
|
@ -311,21 +355,23 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
[editor, emitState]
|
||||
);
|
||||
|
||||
const insertDocumentChip = useCallback(
|
||||
(
|
||||
doc: Pick<Document, "id" | "title" | "document_type">,
|
||||
options?: { removeTriggerText?: boolean }
|
||||
) => {
|
||||
if (typeof doc.id !== "number" || typeof doc.title !== "string") return;
|
||||
const insertMentionChip = useCallback(
|
||||
(mention: MentionChipInput, options?: { removeTriggerText?: boolean }) => {
|
||||
if (typeof mention.id !== "number" || typeof mention.title !== "string") return;
|
||||
|
||||
const removeTriggerText = options?.removeTriggerText ?? true;
|
||||
const current = getCurrentValue();
|
||||
const selection = editor.selection;
|
||||
const kind: MentionKind = mention.kind ?? "doc";
|
||||
const document_type =
|
||||
mention.document_type ??
|
||||
(kind === "folder" ? FOLDER_MENTION_DOCUMENT_TYPE : undefined);
|
||||
const mentionNode: MentionElementNode = {
|
||||
type: MENTION_TYPE,
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
document_type: doc.document_type,
|
||||
id: mention.id,
|
||||
title: mention.title,
|
||||
document_type,
|
||||
kind,
|
||||
children: [{ text: "" }],
|
||||
};
|
||||
|
||||
|
|
@ -385,6 +431,19 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
[editor.selection, focusAtEnd, getCurrentValue, setValue]
|
||||
);
|
||||
|
||||
// Backwards-compatible shim — pre-folder callers pass a doc-only
|
||||
// payload; we route them through ``insertMentionChip`` with
|
||||
// ``kind: "doc"``.
|
||||
const insertDocumentChip = useCallback(
|
||||
(
|
||||
doc: Pick<Document, "id" | "title" | "document_type">,
|
||||
options?: { removeTriggerText?: boolean }
|
||||
) => {
|
||||
insertMentionChip({ ...doc, kind: "doc" }, options);
|
||||
},
|
||||
[insertMentionChip]
|
||||
);
|
||||
|
||||
const removeDocumentChip = useCallback(
|
||||
(docId: number, docType?: string) => {
|
||||
const current = getCurrentValue();
|
||||
|
|
@ -460,6 +519,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
setText,
|
||||
getText,
|
||||
getMentionedDocuments: getMentionedDocs,
|
||||
insertMentionChip,
|
||||
insertDocumentChip,
|
||||
removeDocumentChip,
|
||||
setDocumentChipStatus,
|
||||
|
|
@ -468,6 +528,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
clear,
|
||||
getMentionedDocs,
|
||||
getText,
|
||||
insertMentionChip,
|
||||
insertDocumentChip,
|
||||
removeDocumentChip,
|
||||
setDocumentChipStatus,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
useIsMarkdownCodeBlock,
|
||||
} from "@assistant-ui/react-markdown";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { ExternalLinkIcon, FileIcon, Folder as FolderIcon } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
|
|
@ -18,6 +18,7 @@ import remarkGfm from "remark-gfm";
|
|||
import remarkMath from "remark-math";
|
||||
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { ImagePreview, ImageRoot, ImageZoom } from "@/components/assistant-ui/image";
|
||||
import { MentionChip } from "@/components/assistant-ui/mention-chip";
|
||||
import "katex/dist/katex.min.css";
|
||||
import { toast } from "sonner";
|
||||
import { processChildrenWithCitations } from "@/components/citations/citation-renderer";
|
||||
|
|
@ -33,6 +34,7 @@ import {
|
|||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { type CitationUrlMap, preprocessCitationMarkdown } from "@/lib/citations/citation-parser";
|
||||
import { getVirtualPathDisplay } from "@/lib/chat/virtual-path-display";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function MarkdownCodeBlockSkeleton() {
|
||||
|
|
@ -219,59 +221,71 @@ function FilePathLink({ path, className }: { path: string; className?: string })
|
|||
? parsedSearchSpaceId
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-pointer font-mono text-[0.9em] font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80",
|
||||
className
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void (async () => {
|
||||
if (electronAPI) {
|
||||
let resolvedLocalPath = path;
|
||||
if (electronAPI.getAgentFilesystemMounts) {
|
||||
try {
|
||||
const mounts = (await electronAPI.getAgentFilesystemMounts(
|
||||
resolvedSearchSpaceId
|
||||
)) as AgentFilesystemMount[];
|
||||
resolvedLocalPath = normalizeLocalVirtualPathForEditor(path, mounts);
|
||||
} catch {
|
||||
// Fall back to the raw path if mount lookup fails.
|
||||
}
|
||||
}
|
||||
openEditorPanel({
|
||||
kind: "local_file",
|
||||
localFilePath: resolvedLocalPath,
|
||||
title: resolvedLocalPath.split("/").pop() || resolvedLocalPath,
|
||||
searchSpaceId: resolvedSearchSpaceId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { displayName, isFolder } = getVirtualPathDisplay(path);
|
||||
const icon = isFolder ? (
|
||||
<FolderIcon className="size-3.5" />
|
||||
) : (
|
||||
<FileIcon className="size-3.5" />
|
||||
);
|
||||
|
||||
if (!resolvedSearchSpaceId || !path.startsWith("/documents/")) return;
|
||||
try {
|
||||
const doc = await documentsApiService.getDocumentByVirtualPath({
|
||||
search_space_id: resolvedSearchSpaceId,
|
||||
virtual_path: path,
|
||||
});
|
||||
openEditorPanel({
|
||||
kind: "document",
|
||||
documentId: doc.id,
|
||||
searchSpaceId: resolvedSearchSpaceId,
|
||||
title: doc.title,
|
||||
});
|
||||
} catch {
|
||||
toast.error("Document not found in knowledge base.");
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void (async () => {
|
||||
if (electronAPI) {
|
||||
let resolvedLocalPath = path;
|
||||
if (electronAPI.getAgentFilesystemMounts) {
|
||||
try {
|
||||
const mounts = (await electronAPI.getAgentFilesystemMounts(
|
||||
resolvedSearchSpaceId
|
||||
)) as AgentFilesystemMount[];
|
||||
resolvedLocalPath = normalizeLocalVirtualPathForEditor(path, mounts);
|
||||
} catch {
|
||||
// Fall back to the raw path if mount lookup fails.
|
||||
}
|
||||
}
|
||||
})();
|
||||
}}
|
||||
title="Open in editor panel"
|
||||
>
|
||||
{path}
|
||||
</button>
|
||||
openEditorPanel({
|
||||
kind: "local_file",
|
||||
localFilePath: resolvedLocalPath,
|
||||
title: resolvedLocalPath.split("/").pop() || resolvedLocalPath,
|
||||
searchSpaceId: resolvedSearchSpaceId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resolvedSearchSpaceId || !path.startsWith("/documents/")) return;
|
||||
try {
|
||||
const doc = await documentsApiService.getDocumentByVirtualPath({
|
||||
search_space_id: resolvedSearchSpaceId,
|
||||
virtual_path: path,
|
||||
});
|
||||
openEditorPanel({
|
||||
kind: "document",
|
||||
documentId: doc.id,
|
||||
searchSpaceId: resolvedSearchSpaceId,
|
||||
title: doc.title,
|
||||
});
|
||||
} catch {
|
||||
toast.error("Document not found in knowledge base.");
|
||||
}
|
||||
})();
|
||||
},
|
||||
[electronAPI, openEditorPanel, path, resolvedSearchSpaceId]
|
||||
);
|
||||
|
||||
// Folders cannot open in the editor panel — keep them as visual chips.
|
||||
const onClick = isFolder ? undefined : handleClick;
|
||||
|
||||
return (
|
||||
<MentionChip
|
||||
icon={icon}
|
||||
label={displayName || path}
|
||||
tooltip={path}
|
||||
onClick={onClick}
|
||||
ariaLabel={isFolder ? `Folder ${displayName}` : `Open ${displayName}`}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
92
surfsense_web/components/assistant-ui/mention-chip.tsx
Normal file
92
surfsense_web/components/assistant-ui/mention-chip.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import type { MouseEventHandler, ReactNode } from "react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* A single, minimal chip-button used in two places:
|
||||
*
|
||||
* 1. User-message mention chips (rendered for every `@`-mention the user
|
||||
* inserted in the composer).
|
||||
* 2. AI-answer file/folder paths (rendered when the assistant emits
|
||||
* `/documents/.../file.xml` or `/<mount>/.../file.ext`).
|
||||
*
|
||||
* Both contexts want the same visual language: a compact, button-styled
|
||||
* chip with an icon, a truncated label, and an optional tooltip. Sharing
|
||||
* one component keeps the chat surface visually coherent and means a UX
|
||||
* tweak (radius, hover, icon size) lands in both places at once.
|
||||
*
|
||||
* Styling rules (per shadcn skill):
|
||||
* - Semantic tokens only (`border`, `bg-background`, `bg-accent`,
|
||||
* `text-foreground`, `text-muted-foreground`). No raw colors.
|
||||
* - Layout via `gap-*`, never `space-x-*`.
|
||||
* - `cn()` for conditional classes.
|
||||
* - No manual `z-index` — the tooltip handles its own stacking.
|
||||
*/
|
||||
export interface MentionChipProps {
|
||||
/**
|
||||
* Visual prefix. Keep this small (e.g. `size-3.5`); the chip controls
|
||||
* its own height and oversized icons will push the label out of place.
|
||||
*/
|
||||
icon: ReactNode;
|
||||
/** Label shown inside the chip; truncated with `…` past the max width. */
|
||||
label: string;
|
||||
/**
|
||||
* Full title or path shown on hover. Omit to suppress the tooltip
|
||||
* entirely (e.g. when the label already conveys the full identity).
|
||||
*/
|
||||
tooltip?: ReactNode;
|
||||
/**
|
||||
* When provided, the chip behaves like a button (focusable, hover
|
||||
* effect, pointer cursor). Omit for a purely decorative chip.
|
||||
*/
|
||||
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
/** Optional override for the accessible name; defaults to `label`. */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function MentionChip({
|
||||
icon,
|
||||
label,
|
||||
tooltip,
|
||||
onClick,
|
||||
disabled,
|
||||
className,
|
||||
ariaLabel,
|
||||
}: MentionChipProps) {
|
||||
const isInteractive = Boolean(onClick) && !disabled;
|
||||
|
||||
const chip = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel ?? label}
|
||||
className={cn(
|
||||
"inline-flex max-w-[220px] items-center gap-1.5 rounded-md border bg-background px-2 py-0.5 align-middle text-xs font-medium text-foreground leading-5 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
isInteractive
|
||||
? "cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
||||
: "cursor-default",
|
||||
disabled && "opacity-60",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex shrink-0 text-muted-foreground">{icon}</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
if (!tooltip) return chip;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{chip}</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs break-all">
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -36,7 +36,10 @@ import {
|
|||
} from "@/atoms/agent-tools/agent-tools.atoms";
|
||||
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import {
|
||||
type MentionedDocumentInfo,
|
||||
mentionedDocumentsAtom,
|
||||
} from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
|
||||
import {
|
||||
clearPremiumAlertForThreadAtom,
|
||||
|
|
@ -87,7 +90,6 @@ import {
|
|||
getToolDisplayName,
|
||||
getToolIcon,
|
||||
} from "@/contracts/enums/toolIcons";
|
||||
import type { Document } from "@/contracts/types/document.types";
|
||||
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
||||
import { useCommentsSync } from "@/hooks/use-comments-sync";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
|
|
@ -377,9 +379,7 @@ const Composer: FC = () => {
|
|||
const [mentionQuery, setMentionQuery] = useState("");
|
||||
const [actionQuery, setActionQuery] = useState("");
|
||||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||
const prevMentionedDocsRef = useRef<
|
||||
Map<string, Pick<Document, "id" | "title" | "document_type">>
|
||||
>(new Map());
|
||||
const prevMentionedDocsRef = useRef<Map<string, MentionedDocumentInfo>>(new Map());
|
||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||
const promptPickerRef = useRef<PromptPickerRef>(null);
|
||||
const { search_space_id, chat_id } = useParams();
|
||||
|
|
@ -622,20 +622,20 @@ const Composer: FC = () => {
|
|||
);
|
||||
|
||||
const handleDocumentsMention = useCallback(
|
||||
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
|
||||
(mentions: MentionedDocumentInfo[]) => {
|
||||
const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? [];
|
||||
const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc)));
|
||||
|
||||
for (const doc of documents) {
|
||||
const key = getMentionDocKey(doc);
|
||||
for (const mention of mentions) {
|
||||
const key = getMentionDocKey(mention);
|
||||
if (editorDocKeys.has(key)) continue;
|
||||
editorRef.current?.insertDocumentChip(doc);
|
||||
editorRef.current?.insertMentionChip(mention);
|
||||
}
|
||||
|
||||
setMentionedDocuments((prev) => {
|
||||
const existingKeySet = new Set(prev.map((d) => getMentionDocKey(d)));
|
||||
const uniqueNewDocs = documents.filter((doc) => !existingKeySet.has(getMentionDocKey(doc)));
|
||||
return [...prev, ...uniqueNewDocs];
|
||||
const uniqueNew = mentions.filter((m) => !existingKeySet.has(getMentionDocKey(m)));
|
||||
return [...prev, ...uniqueNew];
|
||||
});
|
||||
|
||||
setMentionQuery("");
|
||||
|
|
@ -657,7 +657,7 @@ const Composer: FC = () => {
|
|||
|
||||
for (const [key, doc] of nextDocsMap) {
|
||||
if (prevDocsMap.has(key) || editorKeys.has(key)) continue;
|
||||
editor.insertDocumentChip(doc, { removeTriggerText: false });
|
||||
editor.insertMentionChip(doc, { removeTriggerText: false });
|
||||
}
|
||||
|
||||
for (const [key, doc] of prevDocsMap) {
|
||||
|
|
|
|||
|
|
@ -5,12 +5,16 @@ import {
|
|||
useAuiState,
|
||||
useMessagePartText,
|
||||
} from "@assistant-ui/react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { CheckIcon, CopyIcon, Pencil } from "lucide-react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { CheckIcon, CopyIcon, Folder as FolderIcon, Pencil } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { type FC, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { MentionChip } from "@/components/assistant-ui/mention-chip";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||
|
|
@ -61,27 +65,61 @@ const UserTextPart: FC = () => {
|
|||
const text = (part as { text?: string }).text ?? "";
|
||||
const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom);
|
||||
const mentionedDocs = (messageId ? messageDocumentsMap[messageId] : undefined) ?? [];
|
||||
const openEditorPanel = useSetAtom(openEditorPanelAtom);
|
||||
const params = useParams();
|
||||
const searchSpaceIdParam = params?.search_space_id;
|
||||
const parsedSearchSpaceId = Array.isArray(searchSpaceIdParam)
|
||||
? Number(searchSpaceIdParam[0])
|
||||
: Number(searchSpaceIdParam);
|
||||
const resolvedSearchSpaceId = Number.isFinite(parsedSearchSpaceId)
|
||||
? parsedSearchSpaceId
|
||||
: undefined;
|
||||
|
||||
const handleOpenDoc = useCallback(
|
||||
(docId: number, title: string) => {
|
||||
if (!resolvedSearchSpaceId) {
|
||||
toast.error("Cannot open document outside a search space.");
|
||||
return;
|
||||
}
|
||||
openEditorPanel({
|
||||
kind: "document",
|
||||
documentId: docId,
|
||||
searchSpaceId: resolvedSearchSpaceId,
|
||||
title,
|
||||
});
|
||||
},
|
||||
[openEditorPanel, resolvedSearchSpaceId]
|
||||
);
|
||||
|
||||
const segments = parseMentionSegments(text, mentionedDocs);
|
||||
|
||||
return (
|
||||
<p style={{ whiteSpace: "pre-line" }} className="break-words">
|
||||
{segments.map((segment) =>
|
||||
segment.type === "text" ? (
|
||||
<span key={`txt-${segment.start}`}>{segment.value}</span>
|
||||
<p style={{ whiteSpace: "pre-line" }} className="wrap-break-word">
|
||||
{segments.map((segment) => {
|
||||
if (segment.type === "text") {
|
||||
return <span key={`txt-${segment.start}`}>{segment.value}</span>;
|
||||
}
|
||||
const isFolder = segment.doc.kind === "folder";
|
||||
const icon = isFolder ? (
|
||||
<FolderIcon className="size-3.5" />
|
||||
) : (
|
||||
<span
|
||||
getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "size-3.5")
|
||||
);
|
||||
return (
|
||||
<MentionChip
|
||||
key={`mention-${getMentionDocKey(segment.doc)}-${segment.start}`}
|
||||
className="inline-flex items-center gap-1 mx-0.5 px-1 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary/60 select-none align-middle leading-none"
|
||||
title={segment.doc.title}
|
||||
>
|
||||
<span className="flex items-center text-muted-foreground">
|
||||
{getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "h-3 w-3")}
|
||||
</span>
|
||||
<span className="max-w-[120px] truncate">{segment.doc.title}</span>
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
icon={icon}
|
||||
label={segment.doc.title}
|
||||
tooltip={isFolder ? `Folder: ${segment.doc.title}` : segment.doc.title}
|
||||
onClick={
|
||||
isFolder
|
||||
? undefined
|
||||
: () => handleOpenDoc(segment.doc.id, segment.doc.title)
|
||||
}
|
||||
className="mx-0.5"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -176,34 +176,25 @@ export function FolderTreeView({
|
|||
}, [folders, docsByFolder, foldersByParent, effectiveActiveTypes, searchQuery]);
|
||||
|
||||
const folderSelectionStates = useMemo(() => {
|
||||
// One folder = one chip. The checkbox now reflects whether the
|
||||
// folder itself is mentioned, not whether every nested doc is —
|
||||
// that reverses the old subtree-fanout semantics in
|
||||
// ``DocumentsSidebar.handleToggleFolderSelect``. We keep the
|
||||
// ``"all" | "some" | "none"`` tri-state on the type so the
|
||||
// existing ``FolderNode`` UI (which renders an indeterminate
|
||||
// glyph for ``"some"``) stays compatible, but only ``"all"``
|
||||
// and ``"none"`` are used in practice.
|
||||
const states: Record<number, FolderSelectionState> = {};
|
||||
const isSelectable = (d: DocumentNodeDoc) =>
|
||||
d.status?.state !== "pending" && d.status?.state !== "processing";
|
||||
|
||||
function compute(folderId: number): { selected: number; total: number } {
|
||||
const directDocs = (docsByFolder[folderId] ?? []).filter(isSelectable);
|
||||
let selected = directDocs.filter((d) => mentionedDocKeys.has(getMentionDocKey(d))).length;
|
||||
let total = directDocs.length;
|
||||
|
||||
for (const child of foldersByParent[folderId] ?? []) {
|
||||
const sub = compute(child.id);
|
||||
selected += sub.selected;
|
||||
total += sub.total;
|
||||
}
|
||||
|
||||
if (total === 0) states[folderId] = "none";
|
||||
else if (selected === total) states[folderId] = "all";
|
||||
else if (selected > 0) states[folderId] = "some";
|
||||
else states[folderId] = "none";
|
||||
|
||||
return { selected, total };
|
||||
}
|
||||
|
||||
for (const f of folders) {
|
||||
if (states[f.id] === undefined) compute(f.id);
|
||||
const folderMentionKey = getMentionDocKey({
|
||||
id: f.id,
|
||||
document_type: "FOLDER",
|
||||
kind: "folder",
|
||||
});
|
||||
states[f.id] = mentionedDocKeys.has(folderMentionKey) ? "all" : "none";
|
||||
}
|
||||
return states;
|
||||
}, [folders, docsByFolder, foldersByParent, mentionedDocKeys]);
|
||||
}, [folders, mentionedDocKeys]);
|
||||
|
||||
const folderMap = useMemo(() => {
|
||||
const map: Record<number, FolderDisplay> = {};
|
||||
|
|
|
|||
|
|
@ -24,7 +24,10 @@ import type React from "react";
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||
import { mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import {
|
||||
makeFolderMention,
|
||||
mentionedDocumentsAtom,
|
||||
} from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||
|
|
@ -881,7 +884,7 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
|
||||
const handleToggleChatMention = useCallback(
|
||||
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
|
||||
const key = getMentionDocKey(doc);
|
||||
const key = getMentionDocKey({ ...doc, kind: "doc" });
|
||||
if (isMentioned) {
|
||||
setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== key));
|
||||
} else {
|
||||
|
|
@ -889,7 +892,12 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
if (prev.some((d) => getMentionDocKey(d) === key)) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{ id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum },
|
||||
{
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
document_type: doc.document_type as DocumentTypeEnum,
|
||||
kind: "doc",
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
|
@ -899,40 +907,29 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
|
||||
const handleToggleFolderSelect = useCallback(
|
||||
(folderId: number, selectAll: boolean) => {
|
||||
function collectSubtreeDocs(parentId: number): DocumentNodeDoc[] {
|
||||
const directDocs = (treeDocuments ?? []).filter(
|
||||
(d) =>
|
||||
d.folderId === parentId &&
|
||||
d.status?.state !== "pending" &&
|
||||
d.status?.state !== "processing" &&
|
||||
d.status?.state !== "failed"
|
||||
);
|
||||
const childFolders = foldersByParent[String(parentId)] ?? [];
|
||||
const descendantDocs = childFolders.flatMap((cf) => collectSubtreeDocs(cf.id));
|
||||
return [...directDocs, ...descendantDocs];
|
||||
}
|
||||
|
||||
const subtreeDocs = collectSubtreeDocs(folderId);
|
||||
if (subtreeDocs.length === 0) return;
|
||||
// One folder click = one folder-mention chip. The agent
|
||||
// resolves the chip to its virtual path
|
||||
// (``/documents/MyFolder/``) and walks it itself with
|
||||
// ``ls`` / ``find_documents``. We deliberately don't
|
||||
// fan out to per-doc chips anymore — the previous
|
||||
// behaviour created N chips for one click and dropped
|
||||
// nested folders entirely once selected, which the
|
||||
// agent had no way to recover.
|
||||
const folder = treeFolders.find((f) => f.id === folderId);
|
||||
if (!folder) return;
|
||||
const chip = makeFolderMention({ id: folder.id, name: folder.name });
|
||||
const chipKey = getMentionDocKey(chip);
|
||||
|
||||
if (selectAll) {
|
||||
setSidebarDocs((prev) => {
|
||||
const existingDocKeys = new Set(prev.map((d) => getMentionDocKey(d)));
|
||||
const newDocs = subtreeDocs
|
||||
.filter((d) => !existingDocKeys.has(getMentionDocKey(d)))
|
||||
.map((d) => ({
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
document_type: d.document_type as DocumentTypeEnum,
|
||||
}));
|
||||
return newDocs.length > 0 ? [...prev, ...newDocs] : prev;
|
||||
const exists = prev.some((d) => getMentionDocKey(d) === chipKey);
|
||||
return exists ? prev : [...prev, chip];
|
||||
});
|
||||
} else {
|
||||
const keysToRemove = new Set(subtreeDocs.map((d) => getMentionDocKey(d)));
|
||||
setSidebarDocs((prev) => prev.filter((d) => !keysToRemove.has(getMentionDocKey(d))));
|
||||
setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== chipKey));
|
||||
}
|
||||
},
|
||||
[treeDocuments, foldersByParent, setSidebarDocs]
|
||||
[treeFolders, setSidebarDocs]
|
||||
);
|
||||
|
||||
const searchFilteredDocuments = useMemo(() => {
|
||||
|
|
@ -1604,7 +1601,7 @@ function AnonymousDocumentsSidebar({
|
|||
|
||||
const handleToggleChatMention = useCallback(
|
||||
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
|
||||
const key = getMentionDocKey(doc);
|
||||
const key = getMentionDocKey({ ...doc, kind: "doc" });
|
||||
if (isMentioned) {
|
||||
setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== key));
|
||||
} else {
|
||||
|
|
@ -1612,7 +1609,12 @@ function AnonymousDocumentsSidebar({
|
|||
if (prev.some((d) => getMentionDocKey(d) === key)) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{ id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum },
|
||||
{
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
document_type: doc.document_type as DocumentTypeEnum,
|
||||
kind: "doc",
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery as useZeroQuery } from "@rocicorp/zero/react";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { Folder as FolderIcon } from "lucide-react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
|
|
@ -11,11 +13,17 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
FOLDER_MENTION_DOCUMENT_TYPE,
|
||||
type MentionedDocumentInfo,
|
||||
} from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { Document, SearchDocumentTitlesResponse } from "@/contracts/types/document.types";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { queries } from "@/zero/queries";
|
||||
|
||||
export interface DocumentMentionPickerRef {
|
||||
selectHighlighted: () => void;
|
||||
|
|
@ -25,9 +33,9 @@ export interface DocumentMentionPickerRef {
|
|||
|
||||
interface DocumentMentionPickerProps {
|
||||
searchSpaceId: number;
|
||||
onSelectionChange: (documents: Pick<Document, "id" | "title" | "document_type">[]) => void;
|
||||
onSelectionChange: (mentions: MentionedDocumentInfo[]) => void;
|
||||
onDone: () => void;
|
||||
initialSelectedDocuments?: Pick<Document, "id" | "title" | "document_type">[];
|
||||
initialSelectedDocuments?: MentionedDocumentInfo[];
|
||||
externalSearch?: string;
|
||||
}
|
||||
|
||||
|
|
@ -89,6 +97,11 @@ export const DocumentMentionPicker = forwardRef<
|
|||
const [hasMore, setHasMore] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
|
||||
// Folders for this search space — pulled from Zero so the picker
|
||||
// stays consistent with the documents sidebar (same source of
|
||||
// truth, automatic updates on rename/delete).
|
||||
const [zeroFolders] = useZeroQuery(queries.folders.bySpace({ searchSpaceId }));
|
||||
|
||||
/**
|
||||
* Search Strategy:
|
||||
* - Single character (length === 1): Client-side filtering for instant results
|
||||
|
|
@ -267,21 +280,49 @@ export const DocumentMentionPicker = forwardRef<
|
|||
[actualDocuments]
|
||||
);
|
||||
|
||||
// Track selected documents with composite key (document_type:id) to prevent cross-type ID collisions
|
||||
// Folder mention candidates filtered by the current search term.
|
||||
// Single-char and server-search both use the same client filter
|
||||
// — folder counts in a workspace are tiny compared to docs, so we
|
||||
// don't need a paged endpoint. Empty search shows all folders.
|
||||
const folderMentions: MentionedDocumentInfo[] = useMemo(() => {
|
||||
const all = (zeroFolders ?? []).map((f) => ({
|
||||
id: f.id,
|
||||
title: f.name,
|
||||
document_type: FOLDER_MENTION_DOCUMENT_TYPE,
|
||||
kind: "folder" as const,
|
||||
}));
|
||||
if (!shouldSearch) return all;
|
||||
const needle = (isSingleCharSearch ? deferredSearch : debouncedSearch).trim().toLowerCase();
|
||||
if (!needle) return all;
|
||||
return all.filter((f) => f.title.toLowerCase().includes(needle));
|
||||
}, [zeroFolders, debouncedSearch, deferredSearch, isSingleCharSearch, shouldSearch]);
|
||||
|
||||
// Doc-shape entries reuse their ``document_type`` discriminator;
|
||||
// folder entries lift the existing kind-aware key so the same
|
||||
// matchers used by the chip atom apply unchanged.
|
||||
const selectedKeys = useMemo(
|
||||
() => new Set(initialSelectedDocuments.map((d) => `${d.document_type}:${d.id}`)),
|
||||
() =>
|
||||
new Set(initialSelectedDocuments.map((d) => getMentionDocKey(d))),
|
||||
[initialSelectedDocuments]
|
||||
);
|
||||
|
||||
// Exclude already-selected documents from keyboard navigation
|
||||
const selectableDocuments = useMemo(
|
||||
() => actualDocuments.filter((doc) => !selectedKeys.has(`${doc.document_type}:${doc.id}`)),
|
||||
[actualDocuments, selectedKeys]
|
||||
);
|
||||
// Combined navigation order: SurfSense docs -> User docs -> Folders.
|
||||
// Mirrors the on-screen ordering so keyboard arrows match what the
|
||||
// user sees.
|
||||
const selectableMentions = useMemo<MentionedDocumentInfo[]>(() => {
|
||||
const docs: MentionedDocumentInfo[] = actualDocuments.map((doc) => ({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
document_type: doc.document_type,
|
||||
kind: "doc" as const,
|
||||
}));
|
||||
const ordered = [...docs, ...folderMentions];
|
||||
return ordered.filter((m) => !selectedKeys.has(getMentionDocKey(m)));
|
||||
}, [actualDocuments, folderMentions, selectedKeys]);
|
||||
|
||||
const handleSelectDocument = useCallback(
|
||||
(doc: Pick<Document, "id" | "title" | "document_type">) => {
|
||||
onSelectionChange([...initialSelectedDocuments, doc]);
|
||||
const handleSelectMention = useCallback(
|
||||
(mention: MentionedDocumentInfo) => {
|
||||
onSelectionChange([...initialSelectedDocuments, mention]);
|
||||
onDone();
|
||||
},
|
||||
[initialSelectedDocuments, onSelectionChange, onDone]
|
||||
|
|
@ -338,42 +379,42 @@ export const DocumentMentionPicker = forwardRef<
|
|||
ref,
|
||||
() => ({
|
||||
selectHighlighted: () => {
|
||||
if (selectableDocuments[highlightedIndex]) {
|
||||
handleSelectDocument(selectableDocuments[highlightedIndex]);
|
||||
if (selectableMentions[highlightedIndex]) {
|
||||
handleSelectMention(selectableMentions[highlightedIndex]);
|
||||
}
|
||||
},
|
||||
moveUp: () => {
|
||||
shouldScrollRef.current = true;
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableDocuments.length - 1));
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableMentions.length - 1));
|
||||
},
|
||||
moveDown: () => {
|
||||
shouldScrollRef.current = true;
|
||||
setHighlightedIndex((prev) => (prev < selectableDocuments.length - 1 ? prev + 1 : 0));
|
||||
setHighlightedIndex((prev) => (prev < selectableMentions.length - 1 ? prev + 1 : 0));
|
||||
},
|
||||
}),
|
||||
[selectableDocuments, highlightedIndex, handleSelectDocument]
|
||||
[selectableMentions, highlightedIndex, handleSelectMention]
|
||||
);
|
||||
|
||||
// Keyboard navigation handler for arrow keys, Enter, and Escape
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (selectableDocuments.length === 0) return;
|
||||
if (selectableMentions.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
shouldScrollRef.current = true;
|
||||
setHighlightedIndex((prev) => (prev < selectableDocuments.length - 1 ? prev + 1 : 0));
|
||||
setHighlightedIndex((prev) => (prev < selectableMentions.length - 1 ? prev + 1 : 0));
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
shouldScrollRef.current = true;
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableDocuments.length - 1));
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableMentions.length - 1));
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (selectableDocuments[highlightedIndex]) {
|
||||
handleSelectDocument(selectableDocuments[highlightedIndex]);
|
||||
if (selectableMentions[highlightedIndex]) {
|
||||
handleSelectMention(selectableMentions[highlightedIndex]);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
|
|
@ -382,7 +423,7 @@ export const DocumentMentionPicker = forwardRef<
|
|||
break;
|
||||
}
|
||||
},
|
||||
[selectableDocuments, highlightedIndex, handleSelectDocument, onDone]
|
||||
[selectableMentions, highlightedIndex, handleSelectMention, onDone]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -420,7 +461,7 @@ export const DocumentMentionPicker = forwardRef<
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : actualDocuments.length > 0 ? (
|
||||
) : actualDocuments.length > 0 || folderMentions.length > 0 ? (
|
||||
<div className="py-1 px-2">
|
||||
{/* SurfSense Documentation */}
|
||||
{surfsenseDocsList.length > 0 && (
|
||||
|
|
@ -429,10 +470,16 @@ export const DocumentMentionPicker = forwardRef<
|
|||
SurfSense Docs
|
||||
</div>
|
||||
{surfsenseDocsList.map((doc) => {
|
||||
const docKey = `${doc.document_type}:${doc.id}`;
|
||||
const mention: MentionedDocumentInfo = {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
document_type: doc.document_type,
|
||||
kind: "doc",
|
||||
};
|
||||
const docKey = getMentionDocKey(mention);
|
||||
const isAlreadySelected = selectedKeys.has(docKey);
|
||||
const selectableIndex = selectableDocuments.findIndex(
|
||||
(d) => d.document_type === doc.document_type && d.id === doc.id
|
||||
const selectableIndex = selectableMentions.findIndex(
|
||||
(m) => getMentionDocKey(m) === docKey
|
||||
);
|
||||
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
|
||||
|
||||
|
|
@ -445,7 +492,7 @@ export const DocumentMentionPicker = forwardRef<
|
|||
}
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => !isAlreadySelected && handleSelectDocument(doc)}
|
||||
onClick={() => !isAlreadySelected && handleSelectMention(mention)}
|
||||
onMouseEnter={() => {
|
||||
if (!isAlreadySelected && selectableIndex >= 0) {
|
||||
setHighlightedIndex(selectableIndex);
|
||||
|
|
@ -480,10 +527,16 @@ export const DocumentMentionPicker = forwardRef<
|
|||
Your Documents
|
||||
</div>
|
||||
{userDocsList.map((doc) => {
|
||||
const docKey = `${doc.document_type}:${doc.id}`;
|
||||
const mention: MentionedDocumentInfo = {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
document_type: doc.document_type,
|
||||
kind: "doc",
|
||||
};
|
||||
const docKey = getMentionDocKey(mention);
|
||||
const isAlreadySelected = selectedKeys.has(docKey);
|
||||
const selectableIndex = selectableDocuments.findIndex(
|
||||
(d) => d.document_type === doc.document_type && d.id === doc.id
|
||||
const selectableIndex = selectableMentions.findIndex(
|
||||
(m) => getMentionDocKey(m) === docKey
|
||||
);
|
||||
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
|
||||
|
||||
|
|
@ -496,7 +549,7 @@ export const DocumentMentionPicker = forwardRef<
|
|||
}
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => !isAlreadySelected && handleSelectDocument(doc)}
|
||||
onClick={() => !isAlreadySelected && handleSelectMention(mention)}
|
||||
onMouseEnter={() => {
|
||||
if (!isAlreadySelected && selectableIndex >= 0) {
|
||||
setHighlightedIndex(selectableIndex);
|
||||
|
|
@ -521,6 +574,60 @@ export const DocumentMentionPicker = forwardRef<
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* Folders — single source of truth is Zero (same store
|
||||
that powers the documents sidebar). Selecting a
|
||||
folder inserts a folder chip whose path the agent
|
||||
can walk with ``ls`` / ``find_documents``. */}
|
||||
{folderMentions.length > 0 && (
|
||||
<>
|
||||
{(surfsenseDocsList.length > 0 || userDocsList.length > 0) && (
|
||||
<div className="mx-2 my-4 border-t border-border dark:border-white/5" />
|
||||
)}
|
||||
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">
|
||||
Folders
|
||||
</div>
|
||||
{folderMentions.map((folder) => {
|
||||
const folderKey = getMentionDocKey(folder);
|
||||
const isAlreadySelected = selectedKeys.has(folderKey);
|
||||
const selectableIndex = selectableMentions.findIndex(
|
||||
(m) => getMentionDocKey(m) === folderKey
|
||||
);
|
||||
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={folderKey}
|
||||
ref={(el) => {
|
||||
if (el && selectableIndex >= 0) {
|
||||
itemRefs.current.set(selectableIndex, el);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => !isAlreadySelected && handleSelectMention(folder)}
|
||||
onMouseEnter={() => {
|
||||
if (!isAlreadySelected && selectableIndex >= 0) {
|
||||
setHighlightedIndex(selectableIndex);
|
||||
}
|
||||
}}
|
||||
disabled={isAlreadySelected}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors rounded-md",
|
||||
isAlreadySelected ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
|
||||
isHighlighted && "bg-accent"
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground text-sm">
|
||||
<FolderIcon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="flex-1 text-sm truncate" title={folder.title}>
|
||||
{folder.title}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Pagination loading indicator */}
|
||||
{isLoadingMore && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
type MentionKeyInput = {
|
||||
id: number;
|
||||
document_type?: string | null;
|
||||
kind?: "doc" | "folder";
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a stable dedup key for a mention chip.
|
||||
*
|
||||
* The ``kind:document_type:id`` shape prevents a document and a folder
|
||||
* with the same integer id from colliding in the chip array (folders
|
||||
* use the ``FOLDER`` sentinel ``document_type``; the ``kind`` prefix
|
||||
* is the belt-and-braces guard).
|
||||
*/
|
||||
export function getMentionDocKey(doc: MentionKeyInput): string {
|
||||
return `${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
|
||||
const kind = doc.kind ?? "doc";
|
||||
return `${kind}:${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
|
||||
}
|
||||
|
|
|
|||
55
surfsense_web/lib/chat/virtual-path-display.ts
Normal file
55
surfsense_web/lib/chat/virtual-path-display.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* Pure helpers for turning agent-facing virtual paths into human-friendly
|
||||
* chip labels.
|
||||
*
|
||||
* Why this lives in `lib` and not in the UI component:
|
||||
* - Pure function = trivial to unit-test (no React, no DOM).
|
||||
* - Used in two render sites today (the user-message chip and the AI-answer
|
||||
* `MentionChip`) and likely more (history search, share-card previews, etc).
|
||||
* Centralising the rules keeps the agent's path encoding and the UI's
|
||||
* decoding from drifting apart.
|
||||
*
|
||||
* The agent emits paths under `/documents/...` with two encoding rules
|
||||
* applied by `surfsense_backend/app/agents/new_chat/path_resolver.py`:
|
||||
*
|
||||
* 1. Every basename ends with `.xml` (so the LLM treats KB documents as XML
|
||||
* files). Display layer strips this — users think of the underlying
|
||||
* filename, not the LLM's wrapper.
|
||||
* 2. Title collisions get a ` (<doc_id>).xml` disambiguation suffix.
|
||||
* Display layer strips the parenthesised id since it's an implementation
|
||||
* detail, not user-facing identity.
|
||||
*/
|
||||
|
||||
const XML_EXTENSION_RE = /\.xml$/i;
|
||||
const DOC_ID_DISAMBIG_RE = /\s\(\d+\)$/;
|
||||
|
||||
export interface VirtualPathDisplay {
|
||||
/** Human-friendly leaf name with `.xml` and ` (<doc_id>)` suffixes stripped. */
|
||||
displayName: string;
|
||||
/** Whether the path points to a folder (trailing slash) rather than a file. */
|
||||
isFolder: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a virtual path into the label that should appear in chip UI.
|
||||
*
|
||||
* Folder detection uses the trailing-slash convention the agent already
|
||||
* follows in `<priority_documents>` and `KnowledgeTreeMiddleware`. Falls
|
||||
* back to the raw path if nothing else can be extracted (defensive — the
|
||||
* caller will at least show *something*).
|
||||
*/
|
||||
export function getVirtualPathDisplay(path: string): VirtualPathDisplay {
|
||||
const trimmed = (path ?? "").trim();
|
||||
if (!trimmed) return { displayName: "", isFolder: false };
|
||||
|
||||
const isFolder = trimmed.endsWith("/");
|
||||
const normalized = trimmed.replace(/\/+$/, "");
|
||||
const segments = normalized.split("/").filter(Boolean);
|
||||
const leaf = segments.at(-1);
|
||||
if (!leaf) return { displayName: trimmed, isFolder };
|
||||
|
||||
const withoutXml = leaf.replace(XML_EXTENSION_RE, "");
|
||||
const displayName = withoutXml.replace(DOC_ID_DISAMBIG_RE, "");
|
||||
|
||||
return { displayName: displayName || leaf, isFolder };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue