mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-30 21:59:46 +02:00
Merge pull request #690 from CREDO23/implement-surfsense-docs-mentions
Implement surfsense docs mentions & UI Enhancements
This commit is contained in:
commit
c0ec7447a6
34 changed files with 704 additions and 486 deletions
|
|
@ -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={
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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, {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { AllSearchSpacesSheet } from "./AllSearchSpacesSheet";
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "为团队而生",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue