Merge pull request #690 from CREDO23/implement-surfsense-docs-mentions

Implement surfsense docs mentions & UI Enhancements
This commit is contained in:
Rohan Verma 2026-01-13 00:55:37 -08:00 committed by GitHub
commit c0ec7447a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 704 additions and 486 deletions

View file

@ -923,6 +923,7 @@ async def handle_new_chat(
llm_config_id=llm_config_id, llm_config_id=llm_config_id,
attachments=request.attachments, attachments=request.attachments,
mentioned_document_ids=request.mentioned_document_ids, mentioned_document_ids=request.mentioned_document_ids,
mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids,
), ),
media_type="text/event-stream", media_type="text/event-stream",
headers={ headers={

View file

@ -7,7 +7,7 @@ on a [citation:doc-XXX] link.
""" """
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@ -17,8 +17,10 @@ from app.db import (
User, User,
get_async_session, get_async_session,
) )
from app.schemas import PaginatedResponse
from app.schemas.surfsense_docs import ( from app.schemas.surfsense_docs import (
SurfsenseDocsChunkRead, SurfsenseDocsChunkRead,
SurfsenseDocsDocumentRead,
SurfsenseDocsDocumentWithChunksRead, SurfsenseDocsDocumentWithChunksRead,
) )
from app.users import current_active_user from app.users import current_active_user
@ -87,3 +89,81 @@ async def get_surfsense_doc_by_chunk_id(
status_code=500, status_code=500,
detail=f"Failed to retrieve Surfsense documentation: {e!s}", detail=f"Failed to retrieve Surfsense documentation: {e!s}",
) from e ) from e
@router.get(
"/surfsense-docs",
response_model=PaginatedResponse[SurfsenseDocsDocumentRead],
)
async def list_surfsense_docs(
page: int = 0,
page_size: int = 50,
title: str | None = None,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
List all Surfsense documentation documents.
Args:
page: Zero-based page index.
page_size: Number of items per page (default: 50).
title: Optional title filter (case-insensitive substring match).
session: Database session (injected).
user: Current authenticated user (injected).
Returns:
PaginatedResponse[SurfsenseDocsDocumentRead]: Paginated list of Surfsense docs.
"""
try:
# Base query
query = select(SurfsenseDocsDocument)
count_query = select(func.count()).select_from(SurfsenseDocsDocument)
# Filter by title if provided
if title and title.strip():
query = query.filter(SurfsenseDocsDocument.title.ilike(f"%{title}%"))
count_query = count_query.filter(
SurfsenseDocsDocument.title.ilike(f"%{title}%")
)
# Get total count
total_result = await session.execute(count_query)
total = total_result.scalar() or 0
# Calculate offset
offset = page * page_size
# Get paginated results
result = await session.execute(
query.order_by(SurfsenseDocsDocument.title).offset(offset).limit(page_size)
)
docs = result.scalars().all()
# Convert to response format
items = [
SurfsenseDocsDocumentRead(
id=doc.id,
title=doc.title,
source=doc.source,
content=doc.content,
created_at=doc.created_at,
updated_at=doc.updated_at,
)
for doc in docs
]
has_more = (offset + len(items)) < total
return PaginatedResponse(
items=items,
total=total,
page=page,
page_size=page_size,
has_more=has_more,
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to list Surfsense documentation: {e!s}",
) from e

View file

@ -177,3 +177,6 @@ class NewChatRequest(BaseModel):
mentioned_document_ids: list[int] | None = ( mentioned_document_ids: list[int] | None = (
None # Optional document IDs mentioned with @ in the chat None # Optional document IDs mentioned with @ in the chat
) )
mentioned_surfsense_doc_ids: list[int] | None = (
None # Optional SurfSense documentation IDs mentioned with @ in the chat
)

View file

@ -2,6 +2,8 @@
Schemas for Surfsense documentation. Schemas for Surfsense documentation.
""" """
from datetime import datetime
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@ -14,6 +16,19 @@ class SurfsenseDocsChunkRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class SurfsenseDocsDocumentRead(BaseModel):
"""Schema for a Surfsense docs document (without chunks)."""
id: int
title: str
source: str
content: str
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
class SurfsenseDocsDocumentWithChunksRead(BaseModel): class SurfsenseDocsDocumentWithChunksRead(BaseModel):
"""Schema for a Surfsense docs document with its chunks.""" """Schema for a Surfsense docs document with its chunks."""

View file

@ -25,7 +25,7 @@ from app.agents.new_chat.llm_config import (
load_agent_config, load_agent_config,
load_llm_config_from_yaml, load_llm_config_from_yaml,
) )
from app.db import Document from app.db import Document, SurfsenseDocsDocument
from app.schemas.new_chat import ChatAttachment from app.schemas.new_chat import ChatAttachment
from app.services.connector_service import ConnectorService from app.services.connector_service import ConnectorService
from app.services.new_streaming_service import VercelStreamingService from app.services.new_streaming_service import VercelStreamingService
@ -69,6 +69,55 @@ def format_mentioned_documents_as_context(documents: list[Document]) -> str:
return "\n".join(context_parts) return "\n".join(context_parts)
def format_mentioned_surfsense_docs_as_context(
documents: list[SurfsenseDocsDocument],
) -> str:
"""Format mentioned SurfSense documentation as context for the agent."""
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. "
"Use [citation:CHUNK_ID] format for citations (e.g., [citation:doc-123])."
)
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)
def extract_todos_from_deepagents(command_output) -> dict: def extract_todos_from_deepagents(command_output) -> dict:
""" """
Extract todos from deepagents' TodoListMiddleware Command output. Extract todos from deepagents' TodoListMiddleware Command output.
@ -101,6 +150,7 @@ async def stream_new_chat(
llm_config_id: int = -1, llm_config_id: int = -1,
attachments: list[ChatAttachment] | None = None, attachments: list[ChatAttachment] | None = None,
mentioned_document_ids: list[int] | None = None, mentioned_document_ids: list[int] | None = None,
mentioned_surfsense_doc_ids: list[int] | None = None,
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
""" """
Stream chat responses from the new SurfSense deep agent. Stream chat responses from the new SurfSense deep agent.
@ -118,6 +168,7 @@ async def stream_new_chat(
messages: Optional chat history from frontend (list of ChatMessage) messages: Optional chat history from frontend (list of ChatMessage)
attachments: Optional attachments with extracted content attachments: Optional attachments with extracted content
mentioned_document_ids: Optional list of document IDs mentioned with @ in the chat mentioned_document_ids: Optional list of document IDs mentioned with @ in the chat
mentioned_surfsense_doc_ids: Optional list of SurfSense doc IDs mentioned with @ in the chat
Yields: Yields:
str: SSE formatted response strings str: SSE formatted response strings
@ -208,7 +259,20 @@ async def stream_new_chat(
) )
mentioned_documents = list(result.scalars().all()) mentioned_documents = list(result.scalars().all())
# Format the user query with context (attachments + mentioned documents) # 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)
.options(selectinload(SurfsenseDocsDocument.chunks))
.filter(
SurfsenseDocsDocument.id.in_(mentioned_surfsense_doc_ids),
)
)
mentioned_surfsense_docs = list(result.scalars().all())
# Format the user query with context (attachments + mentioned documents + surfsense docs)
final_query = user_query final_query = user_query
context_parts = [] context_parts = []
@ -220,6 +284,11 @@ async def stream_new_chat(
format_mentioned_documents_as_context(mentioned_documents) format_mentioned_documents_as_context(mentioned_documents)
) )
if mentioned_surfsense_docs:
context_parts.append(
format_mentioned_surfsense_docs_as_context(mentioned_surfsense_docs)
)
if context_parts: if context_parts:
context = "\n\n".join(context_parts) context = "\n\n".join(context_parts)
final_query = f"{context}\n\n<user_query>{user_query}</user_query>" final_query = f"{context}\n\n<user_query>{user_query}</user_query>"
@ -296,13 +365,13 @@ async def stream_new_chat(
last_active_step_id = analyze_step_id last_active_step_id = analyze_step_id
# Determine step title and action verb based on context # Determine step title and action verb based on context
if attachments and mentioned_documents: if attachments and (mentioned_documents or mentioned_surfsense_docs):
last_active_step_title = "Analyzing your content" last_active_step_title = "Analyzing your content"
action_verb = "Reading" action_verb = "Reading"
elif attachments: elif attachments:
last_active_step_title = "Reading your content" last_active_step_title = "Reading your content"
action_verb = "Reading" action_verb = "Reading"
elif mentioned_documents: elif mentioned_documents or mentioned_surfsense_docs:
last_active_step_title = "Analyzing referenced content" last_active_step_title = "Analyzing referenced content"
action_verb = "Analyzing" action_verb = "Analyzing"
else: else:
@ -342,6 +411,19 @@ async def stream_new_chat(
else: else:
processing_parts.append(f"[{len(doc_names)} documents]") processing_parts.append(f"[{len(doc_names)} documents]")
# Add mentioned SurfSense docs inline
if mentioned_surfsense_docs:
doc_names = []
for doc in mentioned_surfsense_docs:
title = doc.title
if len(title) > 30:
title = title[:27] + "..."
doc_names.append(title)
if len(doc_names) == 1:
processing_parts.append(f"[📖 {doc_names[0]}]")
else:
processing_parts.append(f"[📖 {len(doc_names)} docs]")
last_active_step_items = [f"{action_verb}: {' '.join(processing_parts)}"] last_active_step_items = [f"{action_verb}: {' '.join(processing_parts)}"]
yield streaming_service.format_thinking_step( yield streaming_service.format_thinking_step(

View file

@ -47,7 +47,7 @@ export function DocumentsFilters({
columnVisibility, columnVisibility,
onToggleColumn, onToggleColumn,
}: { }: {
typeCounts: Record<DocumentTypeEnum, number>; typeCounts: Partial<Record<DocumentTypeEnum, number>>;
selectedIds: Set<number>; selectedIds: Set<number>;
onSearch: (v: string) => void; onSearch: (v: string) => void;
searchValue: string; searchValue: string;

View file

@ -79,17 +79,25 @@ export function DocumentsTableShell({
[documents, sortKey, sortDesc] [documents, sortKey, sortDesc]
); );
const allSelectedOnPage = sorted.length > 0 && sorted.every((d) => selectedIds.has(d.id)); // Filter out SURFSENSE_DOCS for selection purposes
const someSelectedOnPage = sorted.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage; const selectableDocs = React.useMemo(
() => sorted.filter((d) => d.document_type !== "SURFSENSE_DOCS"),
[sorted]
);
const allSelectedOnPage =
selectableDocs.length > 0 && selectableDocs.every((d) => selectedIds.has(d.id));
const someSelectedOnPage =
selectableDocs.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage;
const toggleAll = (checked: boolean) => { const toggleAll = (checked: boolean) => {
const next = new Set(selectedIds); const next = new Set(selectedIds);
if (checked) if (checked)
sorted.forEach((d) => { selectableDocs.forEach((d) => {
next.add(d.id); next.add(d.id);
}); });
else else
sorted.forEach((d) => { selectableDocs.forEach((d) => {
next.delete(d.id); next.delete(d.id);
}); });
setSelectedIds(next); setSelectedIds(next);
@ -230,9 +238,10 @@ export function DocumentsTableShell({
const icon = getDocumentTypeIcon(doc.document_type); const icon = getDocumentTypeIcon(doc.document_type);
const title = doc.title; const title = doc.title;
const truncatedTitle = title.length > 30 ? `${title.slice(0, 30)}...` : title; const truncatedTitle = title.length > 30 ? `${title.slice(0, 30)}...` : title;
const isSurfsenseDoc = doc.document_type === "SURFSENSE_DOCS";
return ( return (
<motion.tr <motion.tr
key={doc.id} key={`${doc.document_type}-${doc.id}`}
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ animate={{
opacity: 1, opacity: 1,
@ -249,8 +258,9 @@ export function DocumentsTableShell({
> >
<TableCell className="px-4 py-3"> <TableCell className="px-4 py-3">
<Checkbox <Checkbox
checked={selectedIds.has(doc.id)} checked={selectedIds.has(doc.id) && !isSurfsenseDoc}
onCheckedChange={(v) => toggleOne(doc.id, !!v)} onCheckedChange={(v) => !isSurfsenseDoc && toggleOne(doc.id, !!v)}
disabled={isSurfsenseDoc}
aria-label="Select row" aria-label="Select row"
/> />
</TableCell> </TableCell>

View file

@ -28,6 +28,9 @@ import type { Document } from "./types";
// Only FILE and NOTE document types can be edited // Only FILE and NOTE document types can be edited
const EDITABLE_DOCUMENT_TYPES = ["FILE", "NOTE"] as const; const EDITABLE_DOCUMENT_TYPES = ["FILE", "NOTE"] as const;
// SURFSENSE_DOCS are system-managed and cannot be deleted
const NON_DELETABLE_DOCUMENT_TYPES = ["SURFSENSE_DOCS"] as const;
export function RowActions({ export function RowActions({
document, document,
deleteDocument, deleteDocument,
@ -48,6 +51,10 @@ export function RowActions({
document.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number] document.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number]
); );
const isDeletable = !NON_DELETABLE_DOCUMENT_TYPES.includes(
document.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number]
);
const handleDelete = async () => { const handleDelete = async () => {
setIsDeleting(true); setIsDeleting(true);
try { try {
@ -120,29 +127,31 @@ export function RowActions({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> {isDeletable && (
<TooltipTrigger asChild> <Tooltip>
<motion.div <TooltipTrigger asChild>
whileHover={{ scale: 1.1 }} <motion.div
whileTap={{ scale: 0.95 }} whileHover={{ scale: 1.1 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }} whileTap={{ scale: 0.95 }}
> transition={{ type: "spring", stiffness: 400, damping: 17 }}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => setIsDeleteOpen(true)}
disabled={isDeleting}
> >
<Trash2 className="h-4 w-4" /> <Button
<span className="sr-only">Delete</span> variant="ghost"
</Button> size="icon"
</motion.div> className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
</TooltipTrigger> onClick={() => setIsDeleteOpen(true)}
<TooltipContent side="top"> disabled={isDeleting}
<p>Delete</p> >
</TooltipContent> <Trash2 className="h-4 w-4" />
</Tooltip> <span className="sr-only">Delete</span>
</Button>
</motion.div>
</TooltipTrigger>
<TooltipContent side="top">
<p>Delete</p>
</TooltipContent>
</Tooltip>
)}
</div> </div>
{/* Mobile Actions Dropdown */} {/* Mobile Actions Dropdown */}
@ -165,13 +174,15 @@ export function RowActions({
<FileText className="mr-2 h-4 w-4" /> <FileText className="mr-2 h-4 w-4" />
<span>Metadata</span> <span>Metadata</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem {isDeletable && (
onClick={() => setIsDeleteOpen(true)} <DropdownMenuItem
className="text-destructive focus:text-destructive" onClick={() => setIsDeleteOpen(true)}
> className="text-destructive focus:text-destructive"
<Trash2 className="mr-2 h-4 w-4" /> >
<span>Delete</span> <Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem> <span>Delete</span>
</DropdownMenuItem>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>

View file

@ -19,7 +19,7 @@ import { DocumentsFilters } from "./components/DocumentsFilters";
import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell"; import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell";
import { PaginationControls } from "./components/PaginationControls"; import { PaginationControls } from "./components/PaginationControls";
import { ProcessingIndicator } from "./components/ProcessingIndicator"; import { ProcessingIndicator } from "./components/ProcessingIndicator";
import type { ColumnVisibility } from "./components/types"; import type { ColumnVisibility, Document } from "./components/types";
function useDebounced<T>(value: T, delay = 250) { function useDebounced<T>(value: T, delay = 250) {
const [debounced, setDebounced] = useState(value); const [debounced, setDebounced] = useState(value);
@ -55,33 +55,43 @@ export default function DocumentsTable() {
const [sortKey, setSortKey] = useState<SortKey>("title"); const [sortKey, setSortKey] = useState<SortKey>("title");
const [sortDesc, setSortDesc] = useState(false); const [sortDesc, setSortDesc] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const { data: typeCounts } = useAtomValue(documentTypeCountsAtom); const { data: rawTypeCounts } = useAtomValue(documentTypeCountsAtom);
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom); const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
// Build query parameters for fetching documents // Filter out SURFSENSE_DOCS from active types for regular documents API
const regularDocumentTypes = useMemo(
() => activeTypes.filter((t) => t !== "SURFSENSE_DOCS"),
[activeTypes]
);
// Check if only SURFSENSE_DOCS is selected (skip regular docs query)
const onlySurfsenseDocsSelected =
activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS";
// Build query parameters for fetching documents (excluding SURFSENSE_DOCS type)
const queryParams = useMemo( const queryParams = useMemo(
() => ({ () => ({
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
page: pageIndex, page: pageIndex,
page_size: pageSize, page_size: pageSize,
...(activeTypes.length > 0 && { document_types: activeTypes }), ...(regularDocumentTypes.length > 0 && { document_types: regularDocumentTypes }),
}), }),
[searchSpaceId, pageIndex, pageSize, activeTypes] [searchSpaceId, pageIndex, pageSize, regularDocumentTypes]
); );
// Build search query parameters // Build search query parameters (excluding SURFSENSE_DOCS type)
const searchQueryParams = useMemo( const searchQueryParams = useMemo(
() => ({ () => ({
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
page: pageIndex, page: pageIndex,
page_size: pageSize, page_size: pageSize,
title: debouncedSearch.trim(), title: debouncedSearch.trim(),
...(activeTypes.length > 0 && { document_types: activeTypes }), ...(regularDocumentTypes.length > 0 && { document_types: regularDocumentTypes }),
}), }),
[searchSpaceId, pageIndex, pageSize, activeTypes, debouncedSearch] [searchSpaceId, pageIndex, pageSize, regularDocumentTypes, debouncedSearch]
); );
// Use query for fetching documents // Use query for fetching documents (disabled when only SURFSENSE_DOCS is selected)
const { const {
data: documentsResponse, data: documentsResponse,
isLoading: isDocumentsLoading, isLoading: isDocumentsLoading,
@ -91,10 +101,10 @@ export default function DocumentsTable() {
queryKey: cacheKeys.documents.globalQueryParams(queryParams), queryKey: cacheKeys.documents.globalQueryParams(queryParams),
queryFn: () => documentsApiService.getDocuments({ queryParams }), queryFn: () => documentsApiService.getDocuments({ queryParams }),
staleTime: 3 * 60 * 1000, // 3 minutes staleTime: 3 * 60 * 1000, // 3 minutes
enabled: !!searchSpaceId && !debouncedSearch.trim(), enabled: !!searchSpaceId && !debouncedSearch.trim() && !onlySurfsenseDocsSelected,
}); });
// Use query for searching documents // Use query for searching documents (disabled when only SURFSENSE_DOCS is selected)
const { const {
data: searchResponse, data: searchResponse,
isLoading: isSearchLoading, isLoading: isSearchLoading,
@ -104,16 +114,109 @@ export default function DocumentsTable() {
queryKey: cacheKeys.documents.globalQueryParams(searchQueryParams), queryKey: cacheKeys.documents.globalQueryParams(searchQueryParams),
queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }), queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }),
staleTime: 3 * 60 * 1000, // 3 minutes staleTime: 3 * 60 * 1000, // 3 minutes
enabled: !!searchSpaceId && !!debouncedSearch.trim(), enabled: !!searchSpaceId && !!debouncedSearch.trim() && !onlySurfsenseDocsSelected,
}); });
// Determine if we should show SurfSense docs (when no type filter or SURFSENSE_DOCS is selected)
const showSurfsenseDocs =
activeTypes.length === 0 || activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum);
// Use query for fetching SurfSense docs
const {
data: surfsenseDocsResponse,
isLoading: isSurfsenseDocsLoading,
refetch: refetchSurfsenseDocs,
} = useQuery({
queryKey: ["surfsense-docs", debouncedSearch, pageIndex, pageSize],
queryFn: () =>
documentsApiService.getSurfsenseDocs({
page: pageIndex,
page_size: pageSize,
title: debouncedSearch.trim() || undefined,
}),
staleTime: 3 * 60 * 1000, // 3 minutes
enabled: showSurfsenseDocs,
});
// Transform SurfSense docs to match the Document type
const surfsenseDocsAsDocuments: Document[] = useMemo(() => {
if (!surfsenseDocsResponse?.items) return [];
return surfsenseDocsResponse.items.map((doc) => ({
id: doc.id,
title: doc.title,
document_type: "SURFSENSE_DOCS",
document_metadata: { source: doc.source },
content: doc.content,
created_at: doc.created_at || doc.updated_at || new Date().toISOString(),
search_space_id: -1, // Special value for global docs
}));
}, [surfsenseDocsResponse]);
// Merge type counts with SURFSENSE_DOCS count
const typeCounts = useMemo(() => {
const counts = { ...(rawTypeCounts || {}) };
if (surfsenseDocsResponse?.total) {
counts.SURFSENSE_DOCS = surfsenseDocsResponse.total;
}
return counts;
}, [rawTypeCounts, surfsenseDocsResponse?.total]);
// Extract documents and total based on search state // Extract documents and total based on search state
const documents = debouncedSearch.trim() const regularDocuments = debouncedSearch.trim()
? searchResponse?.items || [] ? searchResponse?.items || []
: documentsResponse?.items || []; : documentsResponse?.items || [];
const total = debouncedSearch.trim() ? searchResponse?.total || 0 : documentsResponse?.total || 0; const regularTotal = debouncedSearch.trim()
const loading = debouncedSearch.trim() ? isSearchLoading : isDocumentsLoading; ? searchResponse?.total || 0
const error = debouncedSearch.trim() ? searchError : documentsError; : documentsResponse?.total || 0;
// Merge regular documents with SurfSense docs
const documents = useMemo(() => {
// If filtering by type and not including SURFSENSE_DOCS, only show regular docs
if (activeTypes.length > 0 && !activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum)) {
return regularDocuments;
}
// If filtering only by SURFSENSE_DOCS, only show surfsense docs
if (activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS") {
return surfsenseDocsAsDocuments;
}
// Otherwise, merge both (surfsense docs first)
return [...surfsenseDocsAsDocuments, ...regularDocuments];
}, [regularDocuments, surfsenseDocsAsDocuments, activeTypes]);
const total = useMemo(() => {
if (activeTypes.length > 0 && !activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum)) {
return regularTotal;
}
if (activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS") {
return surfsenseDocsResponse?.total || 0;
}
return regularTotal + (surfsenseDocsResponse?.total || 0);
}, [regularTotal, surfsenseDocsResponse?.total, activeTypes]);
const loading = useMemo(() => {
// If only SURFSENSE_DOCS selected, only check surfsense loading
if (onlySurfsenseDocsSelected) {
return isSurfsenseDocsLoading;
}
// Otherwise check both regular docs and surfsense docs loading
const regularLoading = debouncedSearch.trim() ? isSearchLoading : isDocumentsLoading;
return regularLoading || (showSurfsenseDocs && isSurfsenseDocsLoading);
}, [
onlySurfsenseDocsSelected,
isSurfsenseDocsLoading,
debouncedSearch,
isSearchLoading,
isDocumentsLoading,
showSurfsenseDocs,
]);
const error = useMemo(() => {
// If only SURFSENSE_DOCS selected, no regular docs errors
if (onlySurfsenseDocsSelected) {
return null;
}
return debouncedSearch.trim() ? searchError : documentsError;
}, [onlySurfsenseDocsSelected, debouncedSearch, searchError, documentsError]);
// Display server-filtered results directly // Display server-filtered results directly
const displayDocs = documents || []; const displayDocs = documents || [];
@ -136,16 +239,24 @@ export default function DocumentsTable() {
if (isRefreshing) return; if (isRefreshing) return;
setIsRefreshing(true); setIsRefreshing(true);
try { try {
if (debouncedSearch.trim()) { const refetchPromises: Promise<unknown>[] = [];
await refetchSearch(); // Only refetch regular documents if not in "only surfsense docs" mode
} else { if (!onlySurfsenseDocsSelected) {
await refetchDocuments(); if (debouncedSearch.trim()) {
refetchPromises.push(refetchSearch());
} else {
refetchPromises.push(refetchDocuments());
}
} }
if (showSurfsenseDocs) {
refetchPromises.push(refetchSurfsenseDocs());
}
await Promise.all(refetchPromises);
toast.success(t("refresh_success") || "Documents refreshed"); toast.success(t("refresh_success") || "Documents refreshed");
} finally { } finally {
setIsRefreshing(false); setIsRefreshing(false);
} }
}, [debouncedSearch, refetchSearch, refetchDocuments, t, isRefreshing]); }, [debouncedSearch, refetchSearch, refetchDocuments, refetchSurfsenseDocs, showSurfsenseDocs, onlySurfsenseDocsSelected, t, isRefreshing]);
// Set up smart polling for active tasks - only polls when tasks are in progress // Set up smart polling for active tasks - only polls when tasks are in progress
const { summary } = useLogsSummary(searchSpaceId, 24, { const { summary } = useLogsSummary(searchSpaceId, 24, {

View file

@ -270,7 +270,10 @@ export default function NewChatPage() {
setThreadId(null); setThreadId(null);
setCurrentThread(null); setCurrentThread(null);
setMessageThinkingSteps(new Map()); setMessageThinkingSteps(new Map());
setMentionedDocumentIds([]); setMentionedDocumentIds({
surfsense_doc_ids: [],
document_ids: [],
});
setMentionedDocuments([]); setMentionedDocuments([]);
setMessageDocumentsMap({}); setMessageDocumentsMap({});
clearPlanOwnerRegistry(); // Reset plan ownership for new chat clearPlanOwnerRegistry(); // Reset plan ownership for new chat
@ -456,7 +459,7 @@ export default function NewChatPage() {
// Track message sent // Track message sent
trackChatMessageSent(searchSpaceId, currentThreadId, { trackChatMessageSent(searchSpaceId, currentThreadId, {
hasAttachments: messageAttachments.length > 0, hasAttachments: messageAttachments.length > 0,
hasMentionedDocuments: mentionedDocumentIds.length > 0, hasMentionedDocuments: mentionedDocumentIds.surfsense_doc_ids.length > 0 || mentionedDocumentIds.document_ids.length > 0,
messageLength: userQuery.length, messageLength: userQuery.length,
}); });
@ -654,12 +657,16 @@ export default function NewChatPage() {
// Extract attachment content to send with the request // Extract attachment content to send with the request
const attachments = extractAttachmentContent(messageAttachments); const attachments = extractAttachmentContent(messageAttachments);
// Get mentioned document IDs for context // Get mentioned document IDs for context (separate fields for backend)
const documentIds = mentionedDocumentIds.length > 0 ? [...mentionedDocumentIds] : undefined; const hasDocumentIds = mentionedDocumentIds.document_ids.length > 0;
const hasSurfsenseDocIds = mentionedDocumentIds.surfsense_doc_ids.length > 0;
// Clear mentioned documents after capturing them // Clear mentioned documents after capturing them
if (mentionedDocumentIds.length > 0) { if (hasDocumentIds || hasSurfsenseDocIds) {
setMentionedDocumentIds([]); setMentionedDocumentIds({
surfsense_doc_ids: [],
document_ids: [],
});
setMentionedDocuments([]); setMentionedDocuments([]);
} }
@ -675,7 +682,8 @@ export default function NewChatPage() {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
messages: messageHistory, messages: messageHistory,
attachments: attachments.length > 0 ? attachments : undefined, 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, signal: controller.signal,
}); });

View file

@ -13,6 +13,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { LLMRoleManager } from "@/components/settings/llm-role-manager"; import { LLMRoleManager } from "@/components/settings/llm-role-manager";
import { ModelConfigManager } from "@/components/settings/model-config-manager"; import { ModelConfigManager } from "@/components/settings/model-config-manager";
@ -23,28 +24,28 @@ import { cn } from "@/lib/utils";
interface SettingsNavItem { interface SettingsNavItem {
id: string; id: string;
label: string; labelKey: string;
description: string; descriptionKey: string;
icon: LucideIcon; icon: LucideIcon;
} }
const settingsNavItems: SettingsNavItem[] = [ const settingsNavItems: SettingsNavItem[] = [
{ {
id: "models", id: "models",
label: "Agent Configs", labelKey: "nav_agent_configs",
description: "LLM models with prompts & citations", descriptionKey: "nav_agent_configs_desc",
icon: Bot, icon: Bot,
}, },
{ {
id: "roles", id: "roles",
label: "Role Assignments", labelKey: "nav_role_assignments",
description: "Assign configs to agent roles", descriptionKey: "nav_role_assignments_desc",
icon: Brain, icon: Brain,
}, },
{ {
id: "prompts", id: "prompts",
label: "System Instructions", labelKey: "nav_system_instructions",
description: "SearchSpace-wide AI instructions", descriptionKey: "nav_system_instructions_desc",
icon: MessageSquare, icon: MessageSquare,
}, },
]; ];
@ -62,6 +63,8 @@ function SettingsSidebar({
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
}) { }) {
const t = useTranslations("searchSpaceSettings");
const handleNavClick = (sectionId: string) => { const handleNavClick = (sectionId: string) => {
onSectionChange(sectionId); onSectionChange(sectionId);
onClose(); // Close sidebar on mobile after selection onClose(); // Close sidebar on mobile after selection
@ -94,22 +97,28 @@ function SettingsSidebar({
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0" isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)} )}
> >
{/* Header with back button */} {/* Header with title */}
<div className="p-4 flex items-center justify-between"> <div className="p-4 space-y-3">
<Button <div className="flex items-center justify-between">
variant="ghost" <Button
onClick={onBackToApp} variant="ghost"
className="flex-1 justify-start gap-3 h-11 px-3 hover:bg-muted group" onClick={onBackToApp}
> className="justify-start gap-3 h-11 px-3 hover:bg-muted group"
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 group-hover:bg-primary/20 transition-colors"> >
<ArrowLeft className="h-4 w-4 text-primary" /> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 group-hover:bg-primary/20 transition-colors">
</div> <ArrowLeft className="h-4 w-4 text-primary" />
<span className="font-medium">Back to app</span> </div>
</Button> <span className="font-medium">{t("back_to_app")}</span>
{/* Mobile close button */} </Button>
<Button variant="ghost" size="icon" onClick={onClose} className="md:hidden h-9 w-9"> {/* Mobile close button */}
<X className="h-5 w-5" /> <Button variant="ghost" size="icon" onClick={onClose} className="md:hidden h-9 w-9">
</Button> <X className="h-5 w-5" />
</Button>
</div>
{/* Settings Title */}
<div className="px-3">
<h2 className="text-lg font-semibold text-foreground">{t("title")}</h2>
</div>
</div> </div>
{/* Navigation Items */} {/* Navigation Items */}
@ -159,9 +168,9 @@ function SettingsSidebar({
isActive ? "text-foreground" : "text-muted-foreground" isActive ? "text-foreground" : "text-muted-foreground"
)} )}
> >
{item.label} {t(item.labelKey)}
</p> </p>
<p className="text-xs text-muted-foreground/70 truncate">{item.description}</p> <p className="text-xs text-muted-foreground/70 truncate">{t(item.descriptionKey)}</p>
</div> </div>
<ChevronRight <ChevronRight
className={cn( className={cn(
@ -176,10 +185,6 @@ function SettingsSidebar({
})} })}
</nav> </nav>
{/* Footer */}
<div className="p-4">
<p className="text-xs text-muted-foreground text-center">Search Space Settings</p>
</div>
</aside> </aside>
</> </>
); );
@ -194,6 +199,7 @@ function SettingsContent({
searchSpaceId: number; searchSpaceId: number;
onMenuClick: () => void; onMenuClick: () => void;
}) { }) {
const t = useTranslations("searchSpaceSettings");
const activeItem = settingsNavItems.find((item) => item.id === activeSection); const activeItem = settingsNavItems.find((item) => item.id === activeSection);
const Icon = activeItem?.icon || Settings; const Icon = activeItem?.icon || Settings;
@ -236,7 +242,7 @@ function SettingsContent({
</motion.div> </motion.div>
<div className="min-w-0"> <div className="min-w-0">
<h1 className="text-lg md:text-2xl font-bold tracking-tight truncate"> <h1 className="text-lg md:text-2xl font-bold tracking-tight truncate">
{activeItem?.label} {activeItem ? t(activeItem.labelKey) : ""}
</h1> </h1>
</div> </div>
</div> </div>

View file

@ -75,20 +75,27 @@ function UserSettingsSidebar({
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0" isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)} )}
> >
<div className="flex items-center justify-between p-4"> {/* Header with title */}
<Button <div className="space-y-3 p-4">
variant="ghost" <div className="flex items-center justify-between">
onClick={onBackToApp} <Button
className="group h-11 flex-1 justify-start gap-3 px-3 hover:bg-muted" variant="ghost"
> onClick={onBackToApp}
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 transition-colors group-hover:bg-primary/20"> className="group h-11 justify-start gap-3 px-3 hover:bg-muted"
<ArrowLeft className="h-4 w-4 text-primary" /> >
</div> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 transition-colors group-hover:bg-primary/20">
<span className="font-medium">{t("back_to_app")}</span> <ArrowLeft className="h-4 w-4 text-primary" />
</Button> </div>
<Button variant="ghost" size="icon" onClick={onClose} className="h-9 w-9 md:hidden"> <span className="font-medium">{t("back_to_app")}</span>
<X className="h-5 w-5" /> </Button>
</Button> <Button variant="ghost" size="icon" onClick={onClose} className="h-9 w-9 md:hidden">
<X className="h-5 w-5" />
</Button>
</div>
{/* Settings Title */}
<div className="px-3">
<h2 className="text-lg font-semibold text-foreground">{t("title")}</h2>
</div>
</div> </div>
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-2"> <nav className="flex-1 space-y-1 overflow-y-auto px-3 py-2">
@ -154,9 +161,6 @@ function UserSettingsSidebar({
})} })}
</nav> </nav>
<div className="p-4">
<p className="text-center text-xs text-muted-foreground">{t("footer")}</p>
</div>
</aside> </aside>
</> </>
); );

View file

@ -1,19 +1,25 @@
"use client"; "use client";
import { atom } from "jotai"; 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. * 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. * 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. * Atom to store the full document objects mentioned in the current chat composer.
* This persists across component remounts. * 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 * Simplified document info for display purposes

View file

@ -53,7 +53,10 @@ export const Composer: FC = () => {
// Sync mentioned document IDs to atom for use in chat request // Sync mentioned document IDs to atom for use in chat request
useEffect(() => { 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]); }, [mentionedDocuments, setMentionedDocumentIds]);
// Handle text change from inline editor - sync with assistant-ui composer // Handle text change from inline editor - sync with assistant-ui composer
@ -119,7 +122,10 @@ export const Composer: FC = () => {
// Clear the editor after sending // Clear the editor after sending
editorRef.current?.clear(); editorRef.current?.clear();
setMentionedDocuments([]); setMentionedDocuments([]);
setMentionedDocumentIds([]); setMentionedDocumentIds({
surfsense_doc_ids: [],
document_ids: [],
});
} }
}, [ }, [
showDocumentPopover, showDocumentPopover,
@ -129,41 +135,48 @@ export const Composer: FC = () => {
setMentionedDocumentIds, setMentionedDocumentIds,
]); ]);
// Handle document removal from inline editor
const handleDocumentRemove = useCallback( const handleDocumentRemove = useCallback(
(docId: number) => { (docId: number, docType?: string) => {
setMentionedDocuments((prev) => { setMentionedDocuments((prev) => {
const updated = prev.filter((doc) => doc.id !== docId); const updated = prev.filter(
// Immediately sync document IDs to avoid race conditions (doc) => !(doc.id === docId && doc.document_type === docType)
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; return updated;
}); });
}, },
[setMentionedDocuments, setMentionedDocumentIds] [setMentionedDocuments, setMentionedDocumentIds]
); );
// Handle document selection from picker
const handleDocumentsMention = useCallback( const handleDocumentsMention = useCallback(
(documents: Document[]) => { (documents: Pick<Document, "id" | "title" | "document_type">[]) => {
// Insert chips into the inline editor for each new document const existingKeys = new Set(
const existingIds = new Set(mentionedDocuments.map((d) => d.id)); mentionedDocuments.map((d) => `${d.document_type}:${d.id}`)
const newDocs = documents.filter((doc) => !existingIds.has(doc.id)); );
const newDocs = documents.filter(
(doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`)
);
for (const doc of newDocs) { for (const doc of newDocs) {
editorRef.current?.insertDocumentChip(doc); editorRef.current?.insertDocumentChip(doc);
} }
// Update mentioned documents state
setMentionedDocuments((prev) => { setMentionedDocuments((prev) => {
const existingIdSet = new Set(prev.map((d) => d.id)); const existingKeySet = new Set(prev.map((d) => `${d.document_type}:${d.id}`));
const uniqueNewDocs = documents.filter((doc) => !existingIdSet.has(doc.id)); const uniqueNewDocs = documents.filter(
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
);
const updated = [...prev, ...uniqueNewDocs]; const updated = [...prev, ...uniqueNewDocs];
// Immediately sync document IDs to avoid race conditions setMentionedDocumentIds({
setMentionedDocumentIds(updated.map((doc) => doc.id)); 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; return updated;
}); });
// Reset mention query but keep popover open for more selections
setMentionQuery(""); setMentionQuery("");
}, },
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds] [mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]

View file

@ -25,7 +25,7 @@ export interface InlineMentionEditorRef {
clear: () => void; clear: () => void;
getText: () => string; getText: () => string;
getMentionedDocuments: () => MentionedDocument[]; getMentionedDocuments: () => MentionedDocument[];
insertDocumentChip: (doc: Document) => void; insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
} }
interface InlineMentionEditorProps { interface InlineMentionEditorProps {
@ -34,7 +34,7 @@ interface InlineMentionEditorProps {
onMentionClose?: () => void; onMentionClose?: () => void;
onSubmit?: () => void; onSubmit?: () => void;
onChange?: (text: string, docs: MentionedDocument[]) => void; onChange?: (text: string, docs: MentionedDocument[]) => void;
onDocumentRemove?: (docId: number) => void; onDocumentRemove?: (docId: number, docType?: string) => void;
onKeyDown?: (e: React.KeyboardEvent) => void; onKeyDown?: (e: React.KeyboardEvent) => void;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
@ -44,6 +44,7 @@ interface InlineMentionEditorProps {
// Unique data attribute to identify chip elements // Unique data attribute to identify chip elements
const CHIP_DATA_ATTR = "data-mention-chip"; const CHIP_DATA_ATTR = "data-mention-chip";
const CHIP_ID_ATTR = "data-mention-id"; 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 * 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; 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>( export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMentionEditorProps>(
( (
{ {
@ -84,15 +92,15 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
) => { ) => {
const editorRef = useRef<HTMLDivElement>(null); const editorRef = useRef<HTMLDivElement>(null);
const [isEmpty, setIsEmpty] = useState(true); const [isEmpty, setIsEmpty] = useState(true);
const [mentionedDocs, setMentionedDocs] = useState<Map<number, MentionedDocument>>( const [mentionedDocs, setMentionedDocs] = useState<Map<string, MentionedDocument>>(
() => new Map(initialDocuments.map((d) => [d.id, d])) () => new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d]))
); );
const isComposingRef = useRef(false); const isComposingRef = useRef(false);
// Sync initial documents // Sync initial documents
useEffect(() => { useEffect(() => {
if (initialDocuments.length > 0) { 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]); }, [initialDocuments]);
@ -153,6 +161,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
const chip = document.createElement("span"); const chip = document.createElement("span");
chip.setAttribute(CHIP_DATA_ATTR, "true"); chip.setAttribute(CHIP_DATA_ATTR, "true");
chip.setAttribute(CHIP_ID_ATTR, String(doc.id)); chip.setAttribute(CHIP_ID_ATTR, String(doc.id));
chip.setAttribute(CHIP_DOCTYPE_ATTR, doc.document_type ?? "UNKNOWN");
chip.contentEditable = "false"; chip.contentEditable = "false";
chip.className = 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"; "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.preventDefault();
e.stopPropagation(); e.stopPropagation();
chip.remove(); chip.remove();
const docKey = `${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
setMentionedDocs((prev) => { setMentionedDocs((prev) => {
const next = new Map(prev); const next = new Map(prev);
next.delete(doc.id); next.delete(docKey);
return next; return next;
}); });
// Notify parent that a document was removed // Notify parent that a document was removed
onDocumentRemove?.(doc.id); onDocumentRemove?.(doc.id, doc.document_type);
focusAtEnd(); focusAtEnd();
}; };
@ -195,7 +205,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
// Insert a document chip at the current cursor position // Insert a document chip at the current cursor position
const insertDocumentChip = useCallback( const insertDocumentChip = useCallback(
(doc: Document) => { (doc: Pick<Document, "id" | "title" | "document_type">) => {
if (!editorRef.current) return; if (!editorRef.current) return;
// Validate required fields for type safety // Validate required fields for type safety
@ -210,8 +220,9 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
document_type: doc.document_type, document_type: doc.document_type,
}; };
// Add to mentioned docs map // Add to mentioned docs map using unique key
setMentionedDocs((prev) => new Map(prev).set(doc.id, mentionDoc)); const docKey = `${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
setMentionedDocs((prev) => new Map(prev).set(docKey, mentionDoc));
// Find and remove the @query text // Find and remove the @query text
const selection = window.getSelection(); const selection = window.getSelection();
@ -413,15 +424,17 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
if (isChipElement(prevSibling)) { if (isChipElement(prevSibling)) {
e.preventDefault(); e.preventDefault();
const chipId = getChipId(prevSibling); const chipId = getChipId(prevSibling);
const chipDocType = getChipDocType(prevSibling);
if (chipId !== null) { if (chipId !== null) {
prevSibling.remove(); prevSibling.remove();
const chipKey = `${chipDocType}:${chipId}`;
setMentionedDocs((prev) => { setMentionedDocs((prev) => {
const next = new Map(prev); const next = new Map(prev);
next.delete(chipId); next.delete(chipKey);
return next; return next;
}); });
// Notify parent that a document was removed // Notify parent that a document was removed
onDocumentRemove?.(chipId); onDocumentRemove?.(chipId, chipDocType);
} }
return; return;
} }
@ -448,15 +461,17 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
if (isChipElement(prevChild)) { if (isChipElement(prevChild)) {
e.preventDefault(); e.preventDefault();
const chipId = getChipId(prevChild); const chipId = getChipId(prevChild);
const chipDocType = getChipDocType(prevChild);
if (chipId !== null) { if (chipId !== null) {
prevChild.remove(); prevChild.remove();
const chipKey = `${chipDocType}:${chipId}`;
setMentionedDocs((prev) => { setMentionedDocs((prev) => {
const next = new Map(prev); const next = new Map(prev);
next.delete(chipId); next.delete(chipKey);
return next; return next;
}); });
// Notify parent that a document was removed // Notify parent that a document was removed
onDocumentRemove?.(chipId); onDocumentRemove?.(chipId, chipDocType);
} }
} }
} }

View file

@ -16,7 +16,8 @@ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
// Citation pattern: [citation:CHUNK_ID] or [citation:doc-CHUNK_ID] // Citation pattern: [citation:CHUNK_ID] or [citation:doc-CHUNK_ID]
const CITATION_REGEX = /\[citation:(doc-)?(\d+)\]/g; // Also matches Chinese brackets 【】 and handles zero-width spaces that LLM sometimes inserts
const CITATION_REGEX = /[\[【]\u200B?citation:(doc-)?(\d+)\u200B?[\]】]/g;
// Track chunk IDs to citation numbers mapping for consistent numbering // Track chunk IDs to citation numbers mapping for consistent numbering
// This map is reset when a new message starts rendering // This map is reset when a new message starts rendering
@ -90,10 +91,6 @@ function parseTextWithCitations(text: string): ReactNode[] {
} }
const MarkdownTextImpl = () => { const MarkdownTextImpl = () => {
// Reset citation counter at the start of each render
// This ensures consistent numbering as the message streams in
resetCitationCounter();
return ( return (
<MarkdownTextPrimitive <MarkdownTextPrimitive
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}

View file

@ -229,7 +229,10 @@ const Composer: FC = () => {
// Sync mentioned document IDs to atom for use in chat request // Sync mentioned document IDs to atom for use in chat request
useEffect(() => { 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]); }, [mentionedDocuments, setMentionedDocumentIds]);
// Handle text change from inline editor - sync with assistant-ui composer // Handle text change from inline editor - sync with assistant-ui composer
@ -295,7 +298,10 @@ const Composer: FC = () => {
// Clear the editor after sending // Clear the editor after sending
editorRef.current?.clear(); editorRef.current?.clear();
setMentionedDocuments([]); setMentionedDocuments([]);
setMentionedDocumentIds([]); setMentionedDocumentIds({
surfsense_doc_ids: [],
document_ids: [],
});
} }
}, [ }, [
showDocumentPopover, showDocumentPopover,
@ -305,41 +311,48 @@ const Composer: FC = () => {
setMentionedDocumentIds, setMentionedDocumentIds,
]); ]);
// Handle document removal from inline editor
const handleDocumentRemove = useCallback( const handleDocumentRemove = useCallback(
(docId: number) => { (docId: number, docType?: string) => {
setMentionedDocuments((prev) => { setMentionedDocuments((prev) => {
const updated = prev.filter((doc) => doc.id !== docId); const updated = prev.filter(
// Immediately sync document IDs to avoid race conditions (doc) => !(doc.id === docId && doc.document_type === docType)
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; return updated;
}); });
}, },
[setMentionedDocuments, setMentionedDocumentIds] [setMentionedDocuments, setMentionedDocumentIds]
); );
// Handle document selection from picker
const handleDocumentsMention = useCallback( const handleDocumentsMention = useCallback(
(documents: Document[]) => { (documents: Pick<Document, "id" | "title" | "document_type">[]) => {
// Insert chips into the inline editor for each new document const existingKeys = new Set(
const existingIds = new Set(mentionedDocuments.map((d) => d.id)); mentionedDocuments.map((d) => `${d.document_type}:${d.id}`)
const newDocs = documents.filter((doc) => !existingIds.has(doc.id)); );
const newDocs = documents.filter(
(doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`)
);
for (const doc of newDocs) { for (const doc of newDocs) {
editorRef.current?.insertDocumentChip(doc); editorRef.current?.insertDocumentChip(doc);
} }
// Update mentioned documents state
setMentionedDocuments((prev) => { setMentionedDocuments((prev) => {
const existingIdSet = new Set(prev.map((d) => d.id)); const existingKeySet = new Set(prev.map((d) => `${d.document_type}:${d.id}`));
const uniqueNewDocs = documents.filter((doc) => !existingIdSet.has(doc.id)); const uniqueNewDocs = documents.filter(
(doc) => !existingKeySet.has(`${doc.document_type}:${doc.id}`)
);
const updated = [...prev, ...uniqueNewDocs]; const updated = [...prev, ...uniqueNewDocs];
// Immediately sync document IDs to avoid race conditions setMentionedDocumentIds({
setMentionedDocumentIds(updated.map((doc) => doc.id)); 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; return updated;
}); });
// Reset mention query but keep popover open for more selections
setMentionQuery(""); setMentionQuery("");
}, },
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds] [mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
@ -640,7 +653,7 @@ const UserMessage: FC = () => {
{/* Mentioned documents as chips */} {/* Mentioned documents as chips */}
{mentionedDocs?.map((doc) => ( {mentionedDocs?.map((doc) => (
<span <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" 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} title={doc.title}
> >

View file

@ -29,7 +29,7 @@ export const UserMessage: FC = () => {
{/* Mentioned documents as chips */} {/* Mentioned documents as chips */}
{mentionedDocs?.map((doc) => ( {mentionedDocs?.map((doc) => (
<span <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" 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} title={doc.title}
> >

View file

@ -10,7 +10,6 @@ export type {
User, User,
} from "./types/layout.types"; } from "./types/layout.types";
export { export {
AllSearchSpacesSheet,
ChatListItem, ChatListItem,
CreateSearchSpaceDialog, CreateSearchSpaceDialog,
Header, Header,

View file

@ -25,7 +25,6 @@ import { resetUser, trackLogout } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types"; import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types";
import { CreateSearchSpaceDialog } from "../ui/dialogs"; import { CreateSearchSpaceDialog } from "../ui/dialogs";
import { AllSearchSpacesSheet } from "../ui/sheets";
import { LayoutShell } from "../ui/shell"; import { LayoutShell } from "../ui/shell";
import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar"; import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar"; import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
@ -79,8 +78,7 @@ export function LayoutDataProvider({
const [isAllSharedChatsSidebarOpen, setIsAllSharedChatsSidebarOpen] = useState(false); const [isAllSharedChatsSidebarOpen, setIsAllSharedChatsSidebarOpen] = useState(false);
const [isAllPrivateChatsSidebarOpen, setIsAllPrivateChatsSidebarOpen] = useState(false); const [isAllPrivateChatsSidebarOpen, setIsAllPrivateChatsSidebarOpen] = useState(false);
// Search space sheet and dialog state // Search space dialog state
const [isAllSearchSpacesSheetOpen, setIsAllSearchSpacesSheetOpen] = useState(false);
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false); const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
// Delete dialogs state // Delete dialogs state
@ -166,10 +164,6 @@ export function LayoutDataProvider({
setIsCreateSearchSpaceDialogOpen(true); setIsCreateSearchSpaceDialogOpen(true);
}, []); }, []);
const handleSeeAllSearchSpaces = useCallback(() => {
setIsAllSearchSpacesSheetOpen(true);
}, []);
const handleUserSettings = useCallback(() => { const handleUserSettings = useCallback(() => {
router.push("/dashboard/user/settings"); router.push("/dashboard/user/settings");
}, [router]); }, [router]);
@ -303,10 +297,9 @@ export function LayoutDataProvider({
onViewAllSharedChats={handleViewAllSharedChats} onViewAllSharedChats={handleViewAllSharedChats}
onViewAllPrivateChats={handleViewAllPrivateChats} onViewAllPrivateChats={handleViewAllPrivateChats}
user={{ email: user?.email || "", name: user?.email?.split("@")[0] }} user={{ email: user?.email || "", name: user?.email?.split("@")[0] }}
onSettings={handleSettings} onSettings={handleSettings}
onManageMembers={handleManageMembers} onManageMembers={handleManageMembers}
onSeeAllSearchSpaces={handleSeeAllSearchSpaces} onUserSettings={handleUserSettings}
onUserSettings={handleUserSettings}
onLogout={handleLogout} onLogout={handleLogout}
pageUsage={pageUsage} pageUsage={pageUsage}
breadcrumb={breadcrumb} breadcrumb={breadcrumb}
@ -375,20 +368,6 @@ export function LayoutDataProvider({
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}
/> />
{/* All Search Spaces Sheet */}
<AllSearchSpacesSheet
open={isAllSearchSpacesSheetOpen}
onOpenChange={setIsAllSearchSpacesSheetOpen}
searchSpaces={searchSpaces}
onSearchSpaceSelect={handleSearchSpaceSelect}
onCreateNew={() => {
setIsAllSearchSpacesSheetOpen(false);
setIsCreateSearchSpaceDialogOpen(true);
}}
onSettings={handleSearchSpaceSettings}
onDelete={handleDeleteSearchSpace}
/>
{/* Create Search Space Dialog */} {/* Create Search Space Dialog */}
<CreateSearchSpaceDialog <CreateSearchSpaceDialog
open={isCreateSearchSpaceDialogOpen} open={isCreateSearchSpaceDialogOpen}

View file

@ -103,7 +103,6 @@ export interface SidebarProps {
theme?: string; theme?: string;
onSettings?: () => void; onSettings?: () => void;
onManageMembers?: () => void; onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onToggleTheme?: () => void; onToggleTheme?: () => void;
onLogout?: () => void; onLogout?: () => void;
pageUsage?: PageUsage; pageUsage?: PageUsage;

View file

@ -1,7 +1,6 @@
export { CreateSearchSpaceDialog } from "./dialogs"; export { CreateSearchSpaceDialog } from "./dialogs";
export { Header } from "./header"; export { Header } from "./header";
export { IconRail, NavIcon, SearchSpaceAvatar } from "./icon-rail"; export { IconRail, NavIcon, SearchSpaceAvatar } from "./icon-rail";
export { AllSearchSpacesSheet } from "./sheets";
export { LayoutShell } from "./shell"; export { LayoutShell } from "./shell";
export { export {
ChatListItem, ChatListItem,

View file

@ -1,241 +0,0 @@
"use client";
import {
Calendar,
MoreHorizontal,
Search,
Settings,
Share2,
Trash2,
UserCheck,
Users,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import type { SearchSpace } from "../../types/layout.types";
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
interface AllSearchSpacesSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
searchSpaces: SearchSpace[];
onSearchSpaceSelect: (id: number) => void;
onCreateNew?: () => void;
onSettings?: (id: number) => void;
onDelete?: (id: number) => void;
}
export function AllSearchSpacesSheet({
open,
onOpenChange,
searchSpaces,
onSearchSpaceSelect,
onCreateNew,
onSettings,
onDelete,
}: AllSearchSpacesSheetProps) {
const t = useTranslations("searchSpace");
const tCommon = useTranslations("common");
const [spaceToDelete, setSpaceToDelete] = useState<SearchSpace | null>(null);
const handleSelect = (id: number) => {
onSearchSpaceSelect(id);
onOpenChange(false);
};
const handleSettings = (e: React.MouseEvent, space: SearchSpace) => {
e.stopPropagation();
onOpenChange(false);
onSettings?.(space.id);
};
const handleDeleteClick = (e: React.MouseEvent, space: SearchSpace) => {
e.stopPropagation();
setSpaceToDelete(space);
};
const confirmDelete = () => {
if (spaceToDelete) {
onDelete?.(spaceToDelete.id);
setSpaceToDelete(null);
}
};
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md">
<SheetHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Search className="h-5 w-5 text-primary" />
</div>
<div className="flex flex-col gap-0.5">
<SheetTitle>{t("all_search_spaces")}</SheetTitle>
<SheetDescription>
{t("search_spaces_count", { count: searchSpaces.length })}
</SheetDescription>
</div>
</div>
</SheetHeader>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 pb-4">
{searchSpaces.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-12 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<div className="flex flex-col gap-1">
<p className="font-medium">{t("no_search_spaces")}</p>
<p className="text-sm text-muted-foreground">{t("create_first_search_space")}</p>
</div>
{onCreateNew && (
<Button onClick={onCreateNew} className="mt-2">
{t("create_button")}
</Button>
)}
</div>
) : (
searchSpaces.map((space) => (
<button
key={space.id}
type="button"
onClick={() => handleSelect(space.id)}
className="flex w-full flex-col gap-2 rounded-lg border p-4 text-left transition-colors hover:bg-accent hover:border-accent-foreground/20"
>
<div className="flex items-start justify-between gap-2">
<div className="flex flex-1 flex-col gap-1">
<span className="font-medium leading-tight">{space.name}</span>
{space.description && (
<span className="text-sm text-muted-foreground line-clamp-2">
{space.description}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{space.memberCount > 1 && (
<Badge variant="outline" className="shrink-0">
<Share2 className="mr-1 h-3 w-3" />
{tCommon("shared")}
</Badge>
)}
{space.isOwner && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => handleSettings(e, space)}>
<Settings className="mr-2 h-4 w-4" />
{tCommon("settings")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleDeleteClick(e, space)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{tCommon("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
{space.isOwner ? (
<UserCheck className="h-3.5 w-3.5" />
) : (
<Users className="h-3.5 w-3.5" />
)}
{t("members_count", { count: space.memberCount })}
</span>
{space.createdAt && (
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{formatDate(space.createdAt)}
</span>
)}
</div>
</button>
))
)}
</div>
{searchSpaces.length > 0 && onCreateNew && (
<div className="border-t p-4">
<Button onClick={onCreateNew} variant="outline" className="w-full">
{t("create_new_search_space")}
</Button>
</div>
)}
</SheetContent>
</Sheet>
<AlertDialog open={!!spaceToDelete} onOpenChange={(open) => !open && setSpaceToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("delete_title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("delete_confirm", { name: spaceToDelete?.name ?? "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -1 +0,0 @@
export { AllSearchSpacesSheet } from "./AllSearchSpacesSheet";

View file

@ -29,7 +29,6 @@ interface LayoutShellProps {
user: User; user: User;
onSettings?: () => void; onSettings?: () => void;
onManageMembers?: () => void; onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onUserSettings?: () => void; onUserSettings?: () => void;
onLogout?: () => void; onLogout?: () => void;
pageUsage?: PageUsage; pageUsage?: PageUsage;
@ -62,7 +61,6 @@ export function LayoutShell({
user, user,
onSettings, onSettings,
onManageMembers, onManageMembers,
onSeeAllSearchSpaces,
onUserSettings, onUserSettings,
onLogout, onLogout,
pageUsage, pageUsage,
@ -113,7 +111,6 @@ export function LayoutShell({
user={user} user={user}
onSettings={onSettings} onSettings={onSettings}
onManageMembers={onManageMembers} onManageMembers={onManageMembers}
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
onUserSettings={onUserSettings} onUserSettings={onUserSettings}
onLogout={onLogout} onLogout={onLogout}
pageUsage={pageUsage} pageUsage={pageUsage}
@ -158,7 +155,6 @@ export function LayoutShell({
user={user} user={user}
onSettings={onSettings} onSettings={onSettings}
onManageMembers={onManageMembers} onManageMembers={onManageMembers}
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
onUserSettings={onUserSettings} onUserSettings={onUserSettings}
onLogout={onLogout} onLogout={onLogout}
pageUsage={pageUsage} pageUsage={pageUsage}

View file

@ -28,7 +28,6 @@ interface MobileSidebarProps {
user: User; user: User;
onSettings?: () => void; onSettings?: () => void;
onManageMembers?: () => void; onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onUserSettings?: () => void; onUserSettings?: () => void;
onLogout?: () => void; onLogout?: () => void;
pageUsage?: PageUsage; pageUsage?: PageUsage;
@ -64,7 +63,6 @@ export function MobileSidebar({
user, user,
onSettings, onSettings,
onManageMembers, onManageMembers,
onSeeAllSearchSpaces,
onUserSettings, onUserSettings,
onLogout, onLogout,
pageUsage, pageUsage,
@ -129,6 +127,21 @@ export function MobileSidebar({
}} }}
onChatSelect={handleChatSelect} onChatSelect={handleChatSelect}
onChatDelete={onChatDelete} onChatDelete={onChatDelete}
onViewAllChats={onViewAllChats}
notes={notes}
activeNoteId={activeNoteId}
onNoteSelect={handleNoteSelect}
onNoteDelete={onNoteDelete}
onAddNote={onAddNote}
onViewAllNotes={onViewAllNotes}
user={user}
onSettings={onSettings}
onManageMembers={onManageMembers}
onUserSettings={onUserSettings}
onLogout={onLogout}
pageUsage={pageUsage}
className="w-full border-none"
/>
onViewAllSharedChats={onViewAllSharedChats} onViewAllSharedChats={onViewAllSharedChats}
onViewAllPrivateChats={onViewAllPrivateChats} onViewAllPrivateChats={onViewAllPrivateChats}
user={user} user={user}

View file

@ -32,7 +32,6 @@ interface SidebarProps {
user: User; user: User;
onSettings?: () => void; onSettings?: () => void;
onManageMembers?: () => void; onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onUserSettings?: () => void; onUserSettings?: () => void;
onLogout?: () => void; onLogout?: () => void;
pageUsage?: PageUsage; pageUsage?: PageUsage;
@ -56,7 +55,6 @@ export function Sidebar({
user, user,
onSettings, onSettings,
onManageMembers, onManageMembers,
onSeeAllSearchSpaces,
onUserSettings, onUserSettings,
onLogout, onLogout,
pageUsage, pageUsage,
@ -87,7 +85,6 @@ export function Sidebar({
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
onSettings={onSettings} onSettings={onSettings}
onManageMembers={onManageMembers} onManageMembers={onManageMembers}
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
/> />
<div className=""> <div className="">
<SidebarCollapseButton <SidebarCollapseButton

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { ChevronsUpDown, LayoutGrid, Settings, Users } from "lucide-react"; import { ChevronsUpDown, Settings, Users } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -18,7 +18,6 @@ interface SidebarHeaderProps {
isCollapsed?: boolean; isCollapsed?: boolean;
onSettings?: () => void; onSettings?: () => void;
onManageMembers?: () => void; onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
className?: string; className?: string;
} }
@ -27,7 +26,6 @@ export function SidebarHeader({
isCollapsed, isCollapsed,
onSettings, onSettings,
onManageMembers, onManageMembers,
onSeeAllSearchSpaces,
className, className,
}: SidebarHeaderProps) { }: SidebarHeaderProps) {
const t = useTranslations("sidebar"); const t = useTranslations("sidebar");
@ -59,11 +57,6 @@ export function SidebarHeader({
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />
{t("search_space_settings")} {t("search_space_settings")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSeeAllSearchSpaces}>
<LayoutGrid className="mr-2 h-4 w-4" />
{t("see_all_search_spaces")}
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>

View file

@ -25,9 +25,9 @@ export interface DocumentMentionPickerRef {
interface DocumentMentionPickerProps { interface DocumentMentionPickerProps {
searchSpaceId: number; searchSpaceId: number;
onSelectionChange: (documents: Document[]) => void; onSelectionChange: (documents: Pick<Document, "id" | "title" | "document_type">[]) => void;
onDone: () => void; onDone: () => void;
initialSelectedDocuments?: Document[]; initialSelectedDocuments?: Pick<Document, "id" | "title" | "document_type">[];
externalSearch?: string; externalSearch?: string;
} }
@ -57,7 +57,7 @@ export const DocumentMentionPicker = forwardRef<
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
// State for pagination // State for pagination
const [accumulatedDocuments, setAccumulatedDocuments] = useState<Document[]>([]); const [accumulatedDocuments, setAccumulatedDocuments] = useState<Pick<Document, "id" | "title" | "document_type">[]>([]);
const [currentPage, setCurrentPage] = useState(0); const [currentPage, setCurrentPage] = useState(0);
const [hasMore, setHasMore] = useState(false); const [hasMore, setHasMore] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
@ -90,6 +90,17 @@ export const DocumentMentionPicker = forwardRef<
}; };
}, [debouncedSearch, searchSpaceId]); }, [debouncedSearch, searchSpaceId]);
const surfsenseDocsQueryParams = useMemo(() => {
const params: { page: number; page_size: number; title?: string } = {
page: 0,
page_size: PAGE_SIZE,
};
if (debouncedSearch.trim()) {
params.title = debouncedSearch;
}
return params;
}, [debouncedSearch]);
// Use query for fetching first page of documents // Use query for fetching first page of documents
const { data: documents, isLoading: isDocumentsLoading } = useQuery({ const { data: documents, isLoading: isDocumentsLoading } = useQuery({
queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams), queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams),
@ -106,22 +117,45 @@ export const DocumentMentionPicker = forwardRef<
enabled: !!searchSpaceId && !!debouncedSearch.trim() && currentPage === 0, 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", debouncedSearch],
queryFn: () => documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }),
staleTime: 3 * 60 * 1000,
});
// Update accumulated documents when first page loads - combine both sources
useEffect(() => { useEffect(() => {
if (currentPage === 0) { 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 (debouncedSearch.trim()) {
if (searchedDocuments) { if (searchedDocuments?.items) {
setAccumulatedDocuments(searchedDocuments.items); combinedDocs.push(...searchedDocuments.items);
setHasMore(searchedDocuments.has_more); setHasMore(searchedDocuments.has_more);
} }
} else { } else {
if (documents) { if (documents?.items) {
setAccumulatedDocuments(documents.items); combinedDocs.push(...documents.items);
setHasMore(documents.has_more); setHasMore(documents.has_more);
} }
} }
setAccumulatedDocuments(combinedDocs);
} }
}, [documents, searchedDocuments, debouncedSearch, currentPage]); }, [documents, searchedDocuments, surfsenseDocs, debouncedSearch, currentPage]);
// Function to load next page // Function to load next page
const loadNextPage = useCallback(async () => { const loadNextPage = useCallback(async () => {
@ -175,22 +209,22 @@ export const DocumentMentionPicker = forwardRef<
const actualDocuments = accumulatedDocuments; const actualDocuments = accumulatedDocuments;
const actualLoading = const actualLoading =
(debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) && currentPage === 0; ((debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) || isSurfsenseDocsLoading) && currentPage === 0;
// Track already selected document IDs // Track already selected documents using unique key (document_type:id) to avoid ID collisions
const selectedIds = useMemo( const selectedKeys = useMemo(
() => new Set(initialSelectedDocuments.map((d) => d.id)), () => new Set(initialSelectedDocuments.map((d) => `${d.document_type}:${d.id}`)),
[initialSelectedDocuments] [initialSelectedDocuments]
); );
// Filter out already selected documents for navigation // Filter out already selected documents for navigation
const selectableDocuments = useMemo( const selectableDocuments = useMemo(
() => actualDocuments.filter((doc) => !selectedIds.has(doc.id)), () => actualDocuments.filter((doc) => !selectedKeys.has(`${doc.document_type}:${doc.id}`)),
[actualDocuments, selectedIds] [actualDocuments, selectedKeys]
); );
const handleSelectDocument = useCallback( const handleSelectDocument = useCallback(
(doc: Document) => { (doc: Pick<Document, "id" | "title" | "document_type">) => {
onSelectionChange([...initialSelectedDocuments, doc]); onSelectionChange([...initialSelectedDocuments, doc]);
onDone(); onDone();
}, },
@ -287,13 +321,16 @@ export const DocumentMentionPicker = forwardRef<
) : ( ) : (
<div className="py-1"> <div className="py-1">
{actualDocuments.map((doc) => { {actualDocuments.map((doc) => {
const isAlreadySelected = selectedIds.has(doc.id); const docKey = `${doc.document_type}:${doc.id}`;
const selectableIndex = selectableDocuments.findIndex((d) => d.id === 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; const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
return ( return (
<button <button
key={doc.id} key={docKey}
ref={(el) => { ref={(el) => {
if (el && selectableIndex >= 0) { if (el && selectableIndex >= 0) {
itemRefs.current.set(selectableIndex, el); itemRefs.current.set(selectableIndex, el);

View file

@ -1,5 +1,6 @@
import { IconLinkPlus, IconUsersGroup } from "@tabler/icons-react"; import { IconLinkPlus, IconUsersGroup } from "@tabler/icons-react";
import { import {
BookOpen,
File, File,
FileText, FileText,
Globe, Globe,
@ -86,6 +87,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
return <FileText {...iconProps} />; return <FileText {...iconProps} />;
case "EXTENSION": case "EXTENSION":
return <Webhook {...iconProps} />; return <Webhook {...iconProps} />;
case "SURFSENSE_DOCS":
return <BookOpen {...iconProps} />;
case "DEEP": case "DEEP":
return <Sparkles {...iconProps} />; return <Sparkles {...iconProps} />;
case "DEEPER": case "DEEPER":

View file

@ -22,6 +22,7 @@ export const documentTypeEnum = z.enum([
"LINEAR_CONNECTOR", "LINEAR_CONNECTOR",
"NOTE", "NOTE",
"CIRCLEBACK", "CIRCLEBACK",
"SURFSENSE_DOCS",
]); ]);
export const document = z.object({ export const document = z.object({
@ -183,6 +184,23 @@ export const getSurfsenseDocsByChunkRequest = z.object({
export const getSurfsenseDocsByChunkResponse = surfsenseDocsDocumentWithChunks; export const getSurfsenseDocsByChunkResponse = surfsenseDocsDocumentWithChunks;
/**
* List Surfsense docs
*/
export const getSurfsenseDocsRequest = z.object({
queryParams: paginationQueryParams.extend({
title: z.string().optional(),
}),
});
export const getSurfsenseDocsResponse = z.object({
items: z.array(surfsenseDocsDocument),
total: z.number(),
page: z.number(),
page_size: z.number(),
has_more: z.boolean(),
});
/** /**
* Update document * Update document
*/ */
@ -227,3 +245,5 @@ export type SurfsenseDocsDocument = z.infer<typeof surfsenseDocsDocument>;
export type SurfsenseDocsDocumentWithChunks = z.infer<typeof surfsenseDocsDocumentWithChunks>; export type SurfsenseDocsDocumentWithChunks = z.infer<typeof surfsenseDocsDocumentWithChunks>;
export type GetSurfsenseDocsByChunkRequest = z.infer<typeof getSurfsenseDocsByChunkRequest>; export type GetSurfsenseDocsByChunkRequest = z.infer<typeof getSurfsenseDocsByChunkRequest>;
export type GetSurfsenseDocsByChunkResponse = z.infer<typeof getSurfsenseDocsByChunkResponse>; export type GetSurfsenseDocsByChunkResponse = z.infer<typeof getSurfsenseDocsByChunkResponse>;
export type GetSurfsenseDocsRequest = z.infer<typeof getSurfsenseDocsRequest>;
export type GetSurfsenseDocsResponse = z.infer<typeof getSurfsenseDocsResponse>;

View file

@ -9,6 +9,7 @@ import {
type GetDocumentRequest, type GetDocumentRequest,
type GetDocumentsRequest, type GetDocumentsRequest,
type GetDocumentTypeCountsRequest, type GetDocumentTypeCountsRequest,
type GetSurfsenseDocsRequest,
getDocumentByChunkRequest, getDocumentByChunkRequest,
getDocumentByChunkResponse, getDocumentByChunkResponse,
getDocumentRequest, getDocumentRequest,
@ -18,6 +19,7 @@ import {
getDocumentTypeCountsRequest, getDocumentTypeCountsRequest,
getDocumentTypeCountsResponse, getDocumentTypeCountsResponse,
getSurfsenseDocsByChunkResponse, getSurfsenseDocsByChunkResponse,
getSurfsenseDocsResponse,
type SearchDocumentsRequest, type SearchDocumentsRequest,
searchDocumentsRequest, searchDocumentsRequest,
searchDocumentsResponse, searchDocumentsResponse,
@ -27,6 +29,7 @@ import {
updateDocumentResponse, updateDocumentResponse,
uploadDocumentRequest, uploadDocumentRequest,
uploadDocumentResponse, uploadDocumentResponse,
getSurfsenseDocsRequest,
} from "@/contracts/types/document.types"; } from "@/contracts/types/document.types";
import { ValidationError } from "../error"; import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service"; import { baseApiService } from "./base-api.service";
@ -221,6 +224,35 @@ class DocumentsApiService {
); );
}; };
/**
* List all Surfsense documentation documents
*/
getSurfsenseDocs = async (request: GetSurfsenseDocsRequest) => {
const parsedRequest = getSurfsenseDocsRequest.safeParse(request);
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}`);
}
// 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);
};
/** /**
* Update a document * Update a document
*/ */

View file

@ -105,7 +105,6 @@
"title": "User Settings", "title": "User Settings",
"description": "Manage your account settings and API access", "description": "Manage your account settings and API access",
"back_to_app": "Back to app", "back_to_app": "Back to app",
"footer": "User Settings",
"api_key_nav_label": "API Key", "api_key_nav_label": "API Key",
"api_key_nav_description": "Manage your API access token", "api_key_nav_description": "Manage your API access token",
"api_key_title": "API Key", "api_key_title": "API Key",
@ -671,6 +670,16 @@
"server_error": "Server error", "server_error": "Server error",
"network_error": "Network error" "network_error": "Network error"
}, },
"searchSpaceSettings": {
"title": "Search Space Settings",
"back_to_app": "Back to app",
"nav_agent_configs": "Agent Configs",
"nav_agent_configs_desc": "LLM models with prompts & citations",
"nav_role_assignments": "Role Assignments",
"nav_role_assignments_desc": "Assign configs to agent roles",
"nav_system_instructions": "System Instructions",
"nav_system_instructions_desc": "SearchSpace-wide AI instructions"
},
"homepage": { "homepage": {
"hero_title_part1": "The AI Workspace", "hero_title_part1": "The AI Workspace",
"hero_title_part2": "Built for Teams", "hero_title_part2": "Built for Teams",

View file

@ -105,7 +105,6 @@
"title": "用户设置", "title": "用户设置",
"description": "管理您的账户设置和API访问", "description": "管理您的账户设置和API访问",
"back_to_app": "返回应用", "back_to_app": "返回应用",
"footer": "用户设置",
"api_key_nav_label": "API密钥", "api_key_nav_label": "API密钥",
"api_key_nav_description": "管理您的API访问令牌", "api_key_nav_description": "管理您的API访问令牌",
"api_key_title": "API密钥", "api_key_title": "API密钥",
@ -671,6 +670,16 @@
"server_error": "服务器错误", "server_error": "服务器错误",
"network_error": "网络错误" "network_error": "网络错误"
}, },
"searchSpaceSettings": {
"title": "搜索空间设置",
"back_to_app": "返回应用",
"nav_agent_configs": "代理配置",
"nav_agent_configs_desc": "LLM 模型配置提示词和引用",
"nav_role_assignments": "角色分配",
"nav_role_assignments_desc": "为代理角色分配配置",
"nav_system_instructions": "系统指令",
"nav_system_instructions_desc": "搜索空间级别的 AI 指令"
},
"homepage": { "homepage": {
"hero_title_part1": "AI 工作空间", "hero_title_part1": "AI 工作空间",
"hero_title_part2": "为团队而生", "hero_title_part2": "为团队而生",