Chat search (#4113)

* add chat search

* don't add the bible

* base functional

* k

* k

* functioning

* functioning well

* functioning well

* k

* delete bible

* quick cleanup

* quick cleanup

* k

* fixed frontend hooks

* delete bible

* nit

* nit

* nit

* fix build

* k

* improved debouncing

* address comments

* fix alembic

* k
This commit is contained in:
pablonyx 2025-02-25 12:49:46 -08:00 committed by GitHub
parent ac83b4c365
commit 118cdd7701
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1114 additions and 41 deletions

View File

@ -0,0 +1,30 @@
"""add index
Revision ID: 8f43500ee275
Revises: da42808081e3
Create Date: 2025-02-24 17:35:33.072714
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "8f43500ee275"
down_revision = "da42808081e3"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create a basic index on the lowercase message column for direct text matching
op.execute(
"""
CREATE INDEX idx_chat_message_message_lower
ON chat_message (LOWER(message))
"""
)
def downgrade() -> None:
# Drop the index
op.execute("DROP INDEX IF EXISTS idx_chat_message_message_lower;")

View File

@ -0,0 +1,152 @@
from typing import List
from typing import Optional
from typing import Tuple
from uuid import UUID
from sqlalchemy import desc
from sqlalchemy import func
from sqlalchemy import literal
from sqlalchemy import Select
from sqlalchemy import select
from sqlalchemy import union_all
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import Session
from onyx.db.models import ChatMessage
from onyx.db.models import ChatSession
def search_chat_sessions(
user_id: UUID | None,
db_session: Session,
query: Optional[str] = None,
page: int = 1,
page_size: int = 10,
include_deleted: bool = False,
include_onyxbot_flows: bool = False,
) -> Tuple[List[ChatSession], bool]:
"""
Search for chat sessions based on the provided query.
If no query is provided, returns recent chat sessions.
Returns a tuple of (chat_sessions, has_more)
"""
offset = (page - 1) * page_size
# If no search query, we use standard SQLAlchemy pagination
if not query or not query.strip():
stmt = select(ChatSession)
if user_id:
stmt = stmt.where(ChatSession.user_id == user_id)
if not include_onyxbot_flows:
stmt = stmt.where(ChatSession.onyxbot_flow.is_(False))
if not include_deleted:
stmt = stmt.where(ChatSession.deleted.is_(False))
stmt = stmt.order_by(desc(ChatSession.time_created))
# Apply pagination
stmt = stmt.offset(offset).limit(page_size + 1)
result = db_session.execute(stmt.options(joinedload(ChatSession.persona)))
chat_sessions = result.scalars().all()
has_more = len(chat_sessions) > page_size
if has_more:
chat_sessions = chat_sessions[:page_size]
return list(chat_sessions), has_more
words = query.lower().strip().split()
# Message mach subquery
message_matches = []
for word in words:
word_like = f"%{word}%"
message_match: Select = (
select(ChatMessage.chat_session_id, literal(1.0).label("search_rank"))
.join(ChatSession, ChatSession.id == ChatMessage.chat_session_id)
.where(func.lower(ChatMessage.message).like(word_like))
)
if user_id:
message_match = message_match.where(ChatSession.user_id == user_id)
message_matches.append(message_match)
if message_matches:
message_matches_query = union_all(*message_matches).alias("message_matches")
else:
return [], False
# Description matches
description_match: Select = select(
ChatSession.id.label("chat_session_id"), literal(0.5).label("search_rank")
).where(func.lower(ChatSession.description).like(f"%{query.lower()}%"))
if user_id:
description_match = description_match.where(ChatSession.user_id == user_id)
if not include_onyxbot_flows:
description_match = description_match.where(ChatSession.onyxbot_flow.is_(False))
if not include_deleted:
description_match = description_match.where(ChatSession.deleted.is_(False))
# Combine all match sources
combined_matches = union_all(
message_matches_query.select(), description_match
).alias("combined_matches")
# Use CTE to group and get max rank
session_ranks = (
select(
combined_matches.c.chat_session_id,
func.max(combined_matches.c.search_rank).label("rank"),
)
.group_by(combined_matches.c.chat_session_id)
.alias("session_ranks")
)
# Get ranked sessions with pagination
ranked_query = (
db_session.query(session_ranks.c.chat_session_id, session_ranks.c.rank)
.order_by(desc(session_ranks.c.rank), session_ranks.c.chat_session_id)
.offset(offset)
.limit(page_size + 1)
)
result = ranked_query.all()
# Extract session IDs and ranks
session_ids_with_ranks = {row.chat_session_id: row.rank for row in result}
session_ids = list(session_ids_with_ranks.keys())
if not session_ids:
return [], False
# Now, let's query the actual ChatSession objects using the IDs
stmt = select(ChatSession).where(ChatSession.id.in_(session_ids))
if user_id:
stmt = stmt.where(ChatSession.user_id == user_id)
if not include_onyxbot_flows:
stmt = stmt.where(ChatSession.onyxbot_flow.is_(False))
if not include_deleted:
stmt = stmt.where(ChatSession.deleted.is_(False))
# Full objects with eager loading
result = db_session.execute(stmt.options(joinedload(ChatSession.persona)))
chat_sessions = result.scalars().all()
# Sort based on above ranking
chat_sessions = sorted(
chat_sessions,
key=lambda session: (
-session_ids_with_ranks.get(session.id, 0), # Rank (higher first)
session.time_created.timestamp() * -1, # Then by time (newest first)
),
)
has_more = len(chat_sessions) > page_size
if has_more:
chat_sessions = chat_sessions[:page_size]
return chat_sessions, has_more

View File

@ -1,15 +1,18 @@
import asyncio
import datetime
import io
import json
import os
import uuid
from collections.abc import Callable
from collections.abc import Generator
from datetime import timedelta
from uuid import UUID
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import Query
from fastapi import Request
from fastapi import Response
from fastapi import UploadFile
@ -44,6 +47,7 @@ from onyx.db.chat import get_or_create_root_message
from onyx.db.chat import set_as_latest_chat_message
from onyx.db.chat import translate_db_message_to_chat_message_detail
from onyx.db.chat import update_chat_session
from onyx.db.chat_search import search_chat_sessions
from onyx.db.engine import get_session
from onyx.db.engine import get_session_with_tenant
from onyx.db.feedback import create_chat_message_feedback
@ -65,10 +69,13 @@ from onyx.secondary_llm_flows.chat_session_naming import (
from onyx.server.query_and_chat.models import ChatFeedbackRequest
from onyx.server.query_and_chat.models import ChatMessageIdentifier
from onyx.server.query_and_chat.models import ChatRenameRequest
from onyx.server.query_and_chat.models import ChatSearchResponse
from onyx.server.query_and_chat.models import ChatSessionCreationRequest
from onyx.server.query_and_chat.models import ChatSessionDetailResponse
from onyx.server.query_and_chat.models import ChatSessionDetails
from onyx.server.query_and_chat.models import ChatSessionGroup
from onyx.server.query_and_chat.models import ChatSessionsResponse
from onyx.server.query_and_chat.models import ChatSessionSummary
from onyx.server.query_and_chat.models import ChatSessionUpdateRequest
from onyx.server.query_and_chat.models import CreateChatMessageRequest
from onyx.server.query_and_chat.models import CreateChatSessionID
@ -794,3 +801,84 @@ def fetch_chat_file(
file_io = file_store.read_file(file_id, mode="b")
return StreamingResponse(file_io, media_type=media_type)
@router.get("/search")
async def search_chats(
query: str | None = Query(None),
page: int = Query(1),
page_size: int = Query(10),
user: User | None = Depends(current_user),
db_session: Session = Depends(get_session),
) -> ChatSearchResponse:
"""
Search for chat sessions based on the provided query.
If no query is provided, returns recent chat sessions.
"""
# Use the enhanced database function for chat search
chat_sessions, has_more = search_chat_sessions(
user_id=user.id if user else None,
db_session=db_session,
query=query,
page=page,
page_size=page_size,
include_deleted=False,
include_onyxbot_flows=False,
)
# Group chat sessions by time period
today = datetime.datetime.now().date()
yesterday = today - timedelta(days=1)
this_week = today - timedelta(days=7)
this_month = today - timedelta(days=30)
today_chats: list[ChatSessionSummary] = []
yesterday_chats: list[ChatSessionSummary] = []
this_week_chats: list[ChatSessionSummary] = []
this_month_chats: list[ChatSessionSummary] = []
older_chats: list[ChatSessionSummary] = []
for session in chat_sessions:
session_date = session.time_created.date()
chat_summary = ChatSessionSummary(
id=session.id,
name=session.description,
persona_id=session.persona_id,
time_created=session.time_created,
shared_status=session.shared_status,
folder_id=session.folder_id,
current_alternate_model=session.current_alternate_model,
current_temperature_override=session.temperature_override,
)
if session_date == today:
today_chats.append(chat_summary)
elif session_date == yesterday:
yesterday_chats.append(chat_summary)
elif session_date > this_week:
this_week_chats.append(chat_summary)
elif session_date > this_month:
this_month_chats.append(chat_summary)
else:
older_chats.append(chat_summary)
# Create groups
groups = []
if today_chats:
groups.append(ChatSessionGroup(title="Today", chats=today_chats))
if yesterday_chats:
groups.append(ChatSessionGroup(title="Yesterday", chats=yesterday_chats))
if this_week_chats:
groups.append(ChatSessionGroup(title="This Week", chats=this_week_chats))
if this_month_chats:
groups.append(ChatSessionGroup(title="This Month", chats=this_month_chats))
if older_chats:
groups.append(ChatSessionGroup(title="Older", chats=older_chats))
return ChatSearchResponse(
groups=groups,
has_more=has_more,
next_page=page + 1 if has_more else None,
)

View File

@ -24,6 +24,7 @@ from onyx.llm.override_models import LLMOverride
from onyx.llm.override_models import PromptOverride
from onyx.tools.models import ToolCallFinalResult
if TYPE_CHECKING:
pass
@ -282,3 +283,35 @@ class AdminSearchRequest(BaseModel):
class AdminSearchResponse(BaseModel):
documents: list[SearchDoc]
class ChatSessionSummary(BaseModel):
id: UUID
name: str | None = None
persona_id: int | None = None
time_created: datetime
shared_status: ChatSessionSharedStatus
folder_id: int | None = None
current_alternate_model: str | None = None
current_temperature_override: float | None = None
class ChatSessionGroup(BaseModel):
title: str
chats: list[ChatSessionSummary]
class ChatSearchResponse(BaseModel):
groups: list[ChatSessionGroup]
has_more: bool
next_page: int | None = None
class ChatSearchRequest(BaseModel):
query: str | None = None
page: int = 1
page_size: int = 10
class CreateChatResponse(BaseModel):
chat_session_id: str

View File

@ -142,6 +142,7 @@ import {
import { Button } from "@/components/ui/button";
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
import { MessageChannel } from "node:worker_threads";
import { ChatSearchModal } from "./chat_search/ChatSearchModal";
const TEMP_USER_MESSAGE_ID = -1;
const TEMP_ASSISTANT_MESSAGE_ID = -2;
@ -870,6 +871,7 @@ export function ChatPage({
}, [liveAssistant]);
const filterManager = useFilters();
const [isChatSearchModalOpen, setIsChatSearchModalOpen] = useState(false);
const [currentFeedback, setCurrentFeedback] = useState<
[FeedbackType, number] | null
@ -2329,6 +2331,11 @@ export function ChatPage({
/>
)}
<ChatSearchModal
open={isChatSearchModalOpen}
onCloseModal={() => setIsChatSearchModalOpen(false)}
/>
{retrievalEnabled && documentSidebarVisible && settings?.isMobile && (
<div className="md:hidden">
<Modal
@ -2436,6 +2443,9 @@ export function ChatPage({
>
<div className="w-full relative">
<HistorySidebar
toggleChatSessionSearchModal={() =>
setIsChatSearchModalOpen((open) => !open)
}
liveAssistant={liveAssistant}
setShowAssistantsModal={setShowAssistantsModal}
explicitlyUntoggle={explicitlyUntoggle}
@ -2452,6 +2462,7 @@ export function ChatPage({
showDeleteAllModal={() => setShowDeleteAllModal(true)}
/>
</div>
<div
className={`
flex-none

View File

@ -0,0 +1,31 @@
import React from "react";
import { ChatSearchItem } from "./ChatSearchItem";
import { ChatSessionSummary } from "./interfaces";
interface ChatSearchGroupProps {
title: string;
chats: ChatSessionSummary[];
onSelectChat: (id: string) => void;
}
export function ChatSearchGroup({
title,
chats,
onSelectChat,
}: ChatSearchGroupProps) {
return (
<div className="mb-4">
<div className="sticky -top-1 mt-1 z-10 bg-[#fff]/90 dark:bg-gray-800/90 py-2 px-4 px-4">
<div className="text-xs font-medium leading-4 text-gray-600 dark:text-gray-400">
{title}
</div>
</div>
<ol>
{chats.map((chat) => (
<ChatSearchItem key={chat.id} chat={chat} onSelect={onSelectChat} />
))}
</ol>
</div>
);
}

View File

@ -0,0 +1,30 @@
import React from "react";
import { MessageSquare } from "lucide-react";
import { ChatSessionSummary } from "./interfaces";
interface ChatSearchItemProps {
chat: ChatSessionSummary;
onSelect: (id: string) => void;
}
export function ChatSearchItem({ chat, onSelect }: ChatSearchItemProps) {
return (
<li>
<div className="cursor-pointer" onClick={() => onSelect(chat.id)}>
<div className="group relative flex flex-col rounded-lg px-4 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800">
<div className="flex items-center">
<MessageSquare className="h-5 w-5 text-neutral-600 dark:text-neutral-400" />
<div className="relative grow overflow-hidden whitespace-nowrap pl-4">
<div className="text-sm dark:text-neutral-200">
{chat.name || "Untitled Chat"}
</div>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity text-xs text-neutral-500 dark:text-neutral-400">
{new Date(chat.time_created).toLocaleDateString()}
</div>
</div>
</div>
</div>
</li>
);
}

View File

@ -0,0 +1,122 @@
import React, { useRef } from "react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ChatSearchGroup } from "./ChatSearchGroup";
import { NewChatButton } from "./NewChatButton";
import { useChatSearch } from "./hooks/useChatSearch";
import { LoadingSpinner } from "./LoadingSpinner";
import { useRouter } from "next/navigation";
import { SearchInput } from "./components/SearchInput";
import { ChatSearchSkeletonList } from "./components/ChatSearchSkeleton";
import { useIntersectionObserver } from "./hooks/useIntersectionObserver";
interface ChatSearchModalProps {
open: boolean;
onCloseModal: () => void;
}
export function ChatSearchModal({ open, onCloseModal }: ChatSearchModalProps) {
const {
searchQuery,
setSearchQuery,
chatGroups,
isLoading,
isSearching,
hasMore,
fetchMoreChats,
} = useChatSearch();
const onClose = () => {
setSearchQuery("");
onCloseModal();
};
const router = useRouter();
const scrollAreaRef = useRef<HTMLDivElement>(null);
const { targetRef } = useIntersectionObserver({
root: scrollAreaRef.current,
onIntersect: fetchMoreChats,
enabled: open && hasMore && !isLoading,
});
const handleChatSelect = (chatId: string) => {
router.push(`/chat?chatId=${chatId}`);
onClose();
};
const handleNewChat = async () => {
try {
onClose();
router.push(`/chat`);
} catch (error) {
console.error("Error creating new chat:", error);
}
};
return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
<DialogContent
hideCloseIcon
className="!rounded-xl overflow-hidden p-0 w-full max-w-2xl"
backgroundColor="bg-neutral-950/20 shadow-xl"
>
<div className="w-full flex flex-col bg-white dark:bg-neutral-800 h-[80vh] max-h-[600px]">
<div className="sticky top-0 z-20 px-6 py-3 w-full flex items-center justify-between bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
<SearchInput
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
isSearching={isSearching}
/>
</div>
<ScrollArea
className="flex-grow bg-white relative dark:bg-neutral-800"
ref={scrollAreaRef}
type="auto"
>
<div className="px-4 py-2">
<NewChatButton onClick={handleNewChat} />
{isSearching ? (
<ChatSearchSkeletonList />
) : isLoading && chatGroups.length === 0 ? (
<div className="py-8">
<LoadingSpinner size="large" className="mx-auto" />
</div>
) : chatGroups.length > 0 ? (
<>
{chatGroups.map((group, groupIndex) => (
<ChatSearchGroup
key={groupIndex}
title={group.title}
chats={group.chats}
onSelectChat={handleChatSelect}
/>
))}
<div ref={targetRef} className="py-4">
{isLoading && hasMore && (
<LoadingSpinner className="mx-auto" />
)}
{!hasMore && chatGroups.length > 0 && (
<div className="text-center text-xs text-neutral-500 dark:text-neutral-400 py-2">
No more chats to load
</div>
)}
</div>
</>
) : (
!isLoading && (
<div className="px-4 py-3 text-sm text-neutral-500 dark:text-neutral-400">
No chats found
</div>
)
)}
</div>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,25 @@
import React from "react";
interface LoadingSpinnerProps {
size?: "small" | "medium" | "large";
className?: string;
}
export function LoadingSpinner({
size = "medium",
className = "",
}: LoadingSpinnerProps) {
const sizeClasses = {
small: "h-4 w-4 border-2",
medium: "h-6 w-6 border-2",
large: "h-8 w-8 border-3",
};
return (
<div className={`flex justify-center items-center ${className}`}>
<div
className={`${sizeClasses[size]} animate-spin rounded-full border-solid border-gray-300 border-t-gray-600 dark:border-gray-600 dark:border-t-gray-300`}
/>
</div>
);
}

View File

@ -0,0 +1,22 @@
import React from "react";
import { PlusCircle } from "lucide-react";
import { NewChatIcon } from "@/components/icons/icons";
interface NewChatButtonProps {
onClick: () => void;
}
export function NewChatButton({ onClick }: NewChatButtonProps) {
return (
<div className="mb-2">
<div className="cursor-pointer" onClick={onClick}>
<div className="group relative flex items-center rounded-lg px-4 py-3 hover:bg-neutral-100 dark:bg-neutral-800 dark:hover:bg-neutral-700">
<NewChatIcon className="h-5 w-5 text-neutral-600 dark:text-neutral-400" />
<div className="relative grow overflow-hidden whitespace-nowrap pl-4">
<div className="text-sm dark:text-neutral-200">New Chat</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,25 @@
import React from "react";
export function ChatSearchItemSkeleton() {
return (
<div className="animate-pulse px-4 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg">
<div className="flex items-center">
<div className="h-5 w-5 rounded-full bg-neutral-200 dark:bg-neutral-700"></div>
<div className="ml-4 flex-1">
<div className="h-2 my-1 w-3/4 bg-neutral-200 dark:bg-neutral-700 rounded"></div>
<div className="mt-2 h-3 w-1/2 bg-neutral-200 dark:bg-neutral-700 rounded"></div>
</div>
</div>
</div>
);
}
export function ChatSearchSkeletonList() {
return (
<div>
{[...Array(5)].map((_, index) => (
<ChatSearchItemSkeleton key={index} />
))}
</div>
);
}

View File

@ -0,0 +1,42 @@
import React from "react";
import { Input } from "@/components/ui/input";
import { XIcon } from "lucide-react";
import { LoadingSpinner } from "../LoadingSpinner";
interface SearchInputProps {
searchQuery: string;
setSearchQuery: (query: string) => void;
isSearching: boolean;
}
export function SearchInput({
searchQuery,
setSearchQuery,
isSearching,
}: SearchInputProps) {
return (
<div className="relative w-full">
<div className="flex items-center">
<Input
removeFocusRing
className="w-full !focus-visible:ring-offset-0 !focus-visible:ring-none !focus-visible:ring-0 hover:focus-none border-none bg-transparent placeholder:text-neutral-400 focus:border-transparent focus:outline-none focus:ring-0 dark:placeholder:text-neutral-500 dark:text-neutral-200"
placeholder="Search chats..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery &&
(isSearching ? (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<LoadingSpinner size="small" />
</div>
) : (
<XIcon
size={16}
className="absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer"
onClick={() => setSearchQuery("")}
/>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,255 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { fetchChatSessions } from "../utils";
import { ChatSessionGroup, ChatSessionSummary } from "../interfaces";
interface UseChatSearchOptions {
pageSize?: number;
}
interface UseChatSearchResult {
searchQuery: string;
setSearchQuery: (query: string) => void;
chatGroups: ChatSessionGroup[];
isLoading: boolean;
isSearching: boolean;
hasMore: boolean;
fetchMoreChats: () => Promise<void>;
refreshChats: () => Promise<void>;
}
export function useChatSearch(
options: UseChatSearchOptions = {}
): UseChatSearchResult {
const { pageSize = 10 } = options;
const [searchQuery, setSearchQueryInternal] = useState("");
const [chatGroups, setChatGroups] = useState<ChatSessionGroup[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [debouncedIsSearching, setDebouncedIsSearching] = useState(false);
const [page, setPage] = useState(1);
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const currentAbortController = useRef<AbortController | null>(null);
const activeSearchIdRef = useRef<number>(0); // Add a unique ID for each search
const PAGE_SIZE = pageSize;
useEffect(() => {
// Only set a timeout if we're not already in the desired state
if (!isSearching) {
const timeout = setTimeout(() => {
setDebouncedIsSearching(isSearching);
}, 300);
// Keep track of the timeout reference to clear it on cleanup
const timeoutRef = timeout;
return () => clearTimeout(timeoutRef);
} else {
setDebouncedIsSearching(isSearching);
}
}, [isSearching, debouncedIsSearching]);
// Helper function to merge groups properly
const mergeGroups = useCallback(
(
existingGroups: ChatSessionGroup[],
newGroups: ChatSessionGroup[]
): ChatSessionGroup[] => {
const mergedGroups: Record<string, ChatSessionSummary[]> = {};
// Initialize with existing groups
existingGroups.forEach((group) => {
mergedGroups[group.title] = [
...(mergedGroups[group.title] || []),
...group.chats,
];
});
// Merge in new groups
newGroups.forEach((group) => {
mergedGroups[group.title] = [
...(mergedGroups[group.title] || []),
...group.chats,
];
});
// Convert back to array format
return Object.entries(mergedGroups)
.map(([title, chats]) => ({ title, chats }))
.sort((a, b) => {
// Custom sort order for time periods
const order = [
"Today",
"Yesterday",
"This Week",
"This Month",
"Older",
];
return order.indexOf(a.title) - order.indexOf(b.title);
});
},
[]
);
const fetchInitialChats = useCallback(
async (query: string, searchId: number, signal?: AbortSignal) => {
try {
setIsLoading(true);
setPage(1);
const response = await fetchChatSessions({
query,
page: 1,
page_size: PAGE_SIZE,
signal,
});
// Only update state if this is still the active search
if (activeSearchIdRef.current === searchId && !signal?.aborted) {
setChatGroups(response.groups);
setHasMore(response.has_more);
}
} catch (error: any) {
if (
error?.name !== "AbortError" &&
activeSearchIdRef.current === searchId
) {
console.error("Error fetching chats:", error);
}
} finally {
// Only update loading state if this is still the active search
if (activeSearchIdRef.current === searchId) {
setIsLoading(false);
setIsSearching(false);
}
}
},
[PAGE_SIZE]
);
const fetchMoreChats = useCallback(async () => {
if (isLoading || !hasMore) return;
setIsLoading(true);
if (currentAbortController.current) {
currentAbortController.current.abort();
}
const newSearchId = activeSearchIdRef.current + 1;
activeSearchIdRef.current = newSearchId;
const controller = new AbortController();
currentAbortController.current = controller;
const localSignal = controller.signal;
try {
const nextPage = page + 1;
const response = await fetchChatSessions({
query: searchQuery,
page: nextPage,
page_size: PAGE_SIZE,
signal: localSignal,
});
if (activeSearchIdRef.current === newSearchId && !localSignal.aborted) {
// Use mergeGroups instead of just concatenating
setChatGroups((prevGroups) => mergeGroups(prevGroups, response.groups));
setHasMore(response.has_more);
setPage(nextPage);
}
} catch (error: any) {
if (
error?.name !== "AbortError" &&
activeSearchIdRef.current === newSearchId
) {
console.error("Error fetching more chats:", error);
}
} finally {
if (activeSearchIdRef.current === newSearchId) {
setIsLoading(false);
}
}
}, [isLoading, hasMore, page, searchQuery, PAGE_SIZE, mergeGroups]);
const setSearchQuery = useCallback(
(query: string) => {
setSearchQueryInternal(query);
// Clear any pending timeouts
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
searchTimeoutRef.current = null;
}
// Abort any in-flight requests
if (currentAbortController.current) {
currentAbortController.current.abort();
currentAbortController.current = null;
}
// Create a new search ID
const newSearchId = activeSearchIdRef.current + 1;
activeSearchIdRef.current = newSearchId;
if (query.trim()) {
setIsSearching(true);
const controller = new AbortController();
currentAbortController.current = controller;
searchTimeoutRef.current = setTimeout(() => {
fetchInitialChats(query, newSearchId, controller.signal);
}, 500);
} else {
// For empty queries, clear search state immediately
setIsSearching(false);
// Optionally fetch initial unfiltered results
fetchInitialChats("", newSearchId);
}
},
[fetchInitialChats]
);
// Initial fetch on mount
useEffect(() => {
const newSearchId = activeSearchIdRef.current + 1;
activeSearchIdRef.current = newSearchId;
const controller = new AbortController();
currentAbortController.current = controller;
fetchInitialChats(searchQuery, newSearchId, controller.signal);
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
controller.abort();
};
}, [fetchInitialChats, searchQuery]);
return {
searchQuery,
setSearchQuery,
chatGroups,
isLoading,
isSearching: debouncedIsSearching,
hasMore,
fetchMoreChats,
refreshChats: () => {
const newSearchId = activeSearchIdRef.current + 1;
activeSearchIdRef.current = newSearchId;
if (currentAbortController.current) {
currentAbortController.current.abort();
}
const controller = new AbortController();
currentAbortController.current = controller;
return fetchInitialChats(searchQuery, newSearchId, controller.signal);
},
};
}

View File

@ -0,0 +1,51 @@
import { useEffect, useRef } from "react";
interface UseIntersectionObserverOptions {
root?: Element | null;
rootMargin?: string;
threshold?: number;
onIntersect: () => void;
enabled?: boolean;
}
export function useIntersectionObserver({
root = null,
rootMargin = "0px",
threshold = 0.1,
onIntersect,
enabled = true,
}: UseIntersectionObserverOptions) {
const targetRef = useRef<HTMLDivElement | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
useEffect(() => {
if (!enabled) return;
const options = {
root,
rootMargin,
threshold,
};
const observer = new IntersectionObserver((entries) => {
const [entry] = entries;
if (entry.isIntersecting) {
onIntersect();
}
}, options);
if (targetRef.current) {
observer.observe(targetRef.current);
}
observerRef.current = observer;
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [root, rootMargin, threshold, onIntersect, enabled]);
return { targetRef };
}

View File

@ -0,0 +1,34 @@
import { ChatSessionSharedStatus } from "../interfaces";
export interface ChatSessionSummary {
id: string;
name: string | null;
persona_id: number | null;
time_created: string;
shared_status: ChatSessionSharedStatus;
folder_id: number | null;
current_alternate_model: string | null;
current_temperature_override: number | null;
highlights?: string[];
}
export interface ChatSessionGroup {
title: string;
chats: ChatSessionSummary[];
}
export interface ChatSessionsResponse {
sessions: ChatSessionSummary[];
}
export interface ChatSearchResponse {
groups: ChatSessionGroup[];
has_more: boolean;
next_page: number | null;
}
export interface ChatSearchRequest {
query?: string;
page?: number;
page_size?: number;
}

View File

@ -0,0 +1,79 @@
import { ChatSearchRequest, ChatSearchResponse } from "./interfaces";
const API_BASE_URL = "/api";
export interface ExtendedChatSearchRequest extends ChatSearchRequest {
include_highlights?: boolean;
signal?: AbortSignal;
}
export async function fetchChatSessions(
params: ExtendedChatSearchRequest = {}
): Promise<ChatSearchResponse> {
const queryParams = new URLSearchParams();
if (params.query) {
queryParams.append("query", params.query);
}
if (params.page) {
queryParams.append("page", params.page.toString());
}
if (params.page_size) {
queryParams.append("page_size", params.page_size.toString());
}
if (params.include_highlights !== undefined) {
queryParams.append(
"include_highlights",
params.include_highlights.toString()
);
}
const queryString = queryParams.toString()
? `?${queryParams.toString()}`
: "";
const response = await fetch(`${API_BASE_URL}/chat/search${queryString}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`Failed to fetch chat sessions: ${response.statusText}`);
}
return response.json();
}
export async function createNewChat(): Promise<{ chat_session_id: string }> {
const response = await fetch(`${API_BASE_URL}/chat/sessions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
if (!response.ok) {
throw new Error(`Failed to create new chat: ${response.statusText}`);
}
return response.json();
}
export async function deleteChat(chatId: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/chat/sessions/${chatId}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`Failed to delete chat: ${response.statusText}`);
}
}

View File

@ -66,6 +66,7 @@ interface HistorySidebarProps {
explicitlyUntoggle: () => void;
showDeleteAllModal?: () => void;
setShowAssistantsModal: (show: boolean) => void;
toggleChatSessionSearchModal?: () => void;
}
interface SortableAssistantProps {
@ -180,6 +181,7 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
toggleSidebar,
removeToggle,
showShareModal,
toggleChatSessionSearchModal,
showDeleteModal,
showDeleteAllModal,
},
@ -318,7 +320,6 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
)}
</div>
)}
<div className="h-full relative overflow-x-hidden overflow-y-auto">
<div className="flex px-4 font-normal text-sm gap-x-2 leading-normal text-text-500/80 dark:text-[#D4D4D4] items-center font-normal leading-normal">
Assistants
@ -395,6 +396,7 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
</div>
<PagesTab
toggleChatSessionSearchModal={toggleChatSessionSearchModal}
showDeleteModal={showDeleteModal}
showShareModal={showShareModal}
closeSidebar={removeToggle}

View File

@ -17,6 +17,13 @@ import { useState, useCallback, useRef, useContext, useEffect } from "react";
import { Caret } from "@/components/icons/icons";
import { groupSessionsByDateRange } from "../lib";
import React from "react";
import {
Tooltip,
TooltipProvider,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { Search } from "lucide-react";
import {
DndContext,
closestCenter,
@ -101,10 +108,12 @@ export function PagesTab({
showShareModal,
showDeleteModal,
showDeleteAllModal,
toggleChatSessionSearchModal,
}: {
existingChats?: ChatSession[];
currentChatId?: string;
folders?: Folder[];
toggleChatSessionSearchModal?: () => void;
closeSidebar?: () => void;
showShareModal?: (chatSession: ChatSession) => void;
showDeleteModal?: (chatSession: ChatSession) => void;
@ -318,8 +327,28 @@ export function PagesTab({
<div className="flex flex-col gap-y-2 flex-grow">
{popup}
<div className="px-4 mt-2 group mr-2 bg-background-sidebar dark:bg-transparent z-20">
<div className="flex justify-between text-sm gap-x-2 text-text-300/80 items-center font-normal leading-normal">
<div className="flex group justify-between text-sm gap-x-2 text-text-300/80 items-center font-normal leading-normal">
<p>Chats</p>
<TooltipProvider delayDuration={1000}>
<Tooltip>
<TooltipTrigger asChild>
<button
className="my-auto mr-auto group-hover:opacity-100 opacity-0 transition duration-200 cursor-pointer gap-x-1 items-center text-black text-xs font-medium leading-normal mobile:hidden"
onClick={() => {
toggleChatSessionSearchModal?.();
}}
>
<Search
className="flex-none text-text-mobile-sidebar"
size={12}
/>
</button>
</TooltipTrigger>
<TooltipContent>Search Chats</TooltipContent>
</Tooltip>
</TooltipProvider>
<button
onClick={handleCreateFolder}
className="flex group-hover:opacity-100 opacity-0 transition duration-200 cursor-pointer gap-x-1 items-center text-black text-xs font-medium leading-normal"

View File

@ -110,37 +110,38 @@ export default function LogoWithText({
</Tooltip>
</TooltipProvider>
)}
{showArrow && toggleSidebar && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
className="mr-2 my-auto ml-auto"
onClick={() => {
toggleSidebar();
if (toggled) {
explicitlyUntoggle();
}
}}
>
{!toggled && !combinedSettings?.isMobile ? (
<RightToLineIcon className="mobile:hidden text-sidebar-toggle" />
) : (
<LeftToLineIcon className="mobile:hidden text-sidebar-toggle" />
)}
<FiSidebar
size={20}
className="hidden mobile:block text-text-mobile-sidebar"
/>
</button>
</TooltipTrigger>
<TooltipContent className="!border-none">
{toggled ? `Unpin sidebar` : "Pin sidebar"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<div className="flex ml-auto gap-x-4">
{showArrow && toggleSidebar && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
className="mr-2 my-auto"
onClick={() => {
toggleSidebar();
if (toggled) {
explicitlyUntoggle();
}
}}
>
{!toggled && !combinedSettings?.isMobile ? (
<RightToLineIcon className="mobile:hidden text-sidebar-toggle" />
) : (
<LeftToLineIcon className="mobile:hidden text-sidebar-toggle" />
)}
<FiSidebar
size={20}
className="hidden mobile:block text-text-mobile-sidebar"
/>
</button>
</TooltipTrigger>
<TooltipContent className="!border-none">
{toggled ? `Unpin sidebar` : "Pin sidebar"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
);
}

View File

@ -16,12 +16,15 @@ const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
backgroundColor?: string;
}
>(({ className, backgroundColor, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-neutral-950/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
backgroundColor || "bg-neutral-950/60",
"fixed inset-0 z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@ -33,10 +36,11 @@ const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
hideCloseIcon?: boolean;
backgroundColor?: string;
}
>(({ className, children, hideCloseIcon, ...props }, ref) => (
>(({ className, children, hideCloseIcon, backgroundColor, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogOverlay backgroundColor={backgroundColor} />
<DialogPrimitive.Content
ref={ref}
className={cn(

View File

@ -2,13 +2,20 @@ import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
interface InputProps extends React.ComponentProps<"input"> {
removeFocusRing?: boolean;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, removeFocusRing, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-base ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-neutral-950 placeholder:text-neutral-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:border-neutral-800 dark:bg-neutral-950 dark:ring-offset-neutral-950 dark:file:text-neutral-50 dark:placeholder:text-neutral-400 dark:focus-visible:ring-neutral-300",
"flex h-10 w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-base ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-neutral-950 placeholder:text-neutral-500",
removeFocusRing
? ""
: "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:border-neutral-800 dark:bg-neutral-950 dark:ring-offset-neutral-950 dark:file:text-neutral-50 dark:placeholder:text-neutral-400 dark:focus-visible:ring-neutral-300",
className
)}
ref={ref}