mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-04-09 12:30:49 +02:00
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:
parent
ac83b4c365
commit
118cdd7701
30
backend/alembic/versions/8f43500ee275_add_index.py
Normal file
30
backend/alembic/versions/8f43500ee275_add_index.py
Normal 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;")
|
152
backend/onyx/db/chat_search.py
Normal file
152
backend/onyx/db/chat_search.py
Normal 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
|
@ -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,
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
31
web/src/app/chat/chat_search/ChatSearchGroup.tsx
Normal file
31
web/src/app/chat/chat_search/ChatSearchGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
30
web/src/app/chat/chat_search/ChatSearchItem.tsx
Normal file
30
web/src/app/chat/chat_search/ChatSearchItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
122
web/src/app/chat/chat_search/ChatSearchModal.tsx
Normal file
122
web/src/app/chat/chat_search/ChatSearchModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
25
web/src/app/chat/chat_search/LoadingSpinner.tsx
Normal file
25
web/src/app/chat/chat_search/LoadingSpinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
22
web/src/app/chat/chat_search/NewChatButton.tsx
Normal file
22
web/src/app/chat/chat_search/NewChatButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
42
web/src/app/chat/chat_search/components/SearchInput.tsx
Normal file
42
web/src/app/chat/chat_search/components/SearchInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
255
web/src/app/chat/chat_search/hooks/useChatSearch.ts
Normal file
255
web/src/app/chat/chat_search/hooks/useChatSearch.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
@ -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 };
|
||||
}
|
34
web/src/app/chat/chat_search/interfaces.ts
Normal file
34
web/src/app/chat/chat_search/interfaces.ts
Normal 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;
|
||||
}
|
79
web/src/app/chat/chat_search/utils.ts
Normal file
79
web/src/app/chat/chat_search/utils.ts
Normal 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}`);
|
||||
}
|
||||
}
|
@ -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}
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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}
|
||||
|
Loading…
x
Reference in New Issue
Block a user