Compare commits

...

7 Commits

Author SHA1 Message Date
pablonyx
72a856794f update 2025-04-04 17:52:16 -07:00
pablonyx
54edca4ab4 lookin ga little bit better 2025-04-04 13:15:31 -07:00
pablonyx
d506fa4e31 k 2025-04-04 12:30:22 -07:00
pablonyx
fd8689a18c looking good 2025-04-04 12:27:21 -07:00
pablonyx
8874fbc2e1 add proper labels 2025-04-04 11:30:07 -07:00
pablonyx
da16c4beab fix web build 2025-04-04 11:15:37 -07:00
pablonyx
e75e4878cf add search page 2025-04-04 11:11:49 -07:00
32 changed files with 3105 additions and 172 deletions

View File

@@ -1,5 +1,6 @@
import json
from collections.abc import Generator
from typing import Optional
from fastapi import APIRouter
from fastapi import Depends
@@ -23,8 +24,12 @@ from onyx.chat.chat_utils import prepare_chat_message_request
from onyx.chat.models import PersonaOverrideConfig
from onyx.chat.process_message import ChatPacketStream
from onyx.chat.process_message import stream_chat_message_objects
from onyx.configs.app_configs import FAST_SEARCH_MAX_HITS
from onyx.configs.onyxbot_configs import MAX_THREAD_CONTEXT_PERCENTAGE
from onyx.context.search.enums import LLMEvaluationType
from onyx.context.search.models import BaseFilters
from onyx.context.search.models import SavedSearchDocWithContent
from onyx.context.search.models import SearchDoc
from onyx.context.search.models import SearchRequest
from onyx.context.search.pipeline import SearchPipeline
from onyx.context.search.utils import dedupe_documents
@@ -230,6 +235,26 @@ def get_answer_with_citation(
raise HTTPException(status_code=500, detail="An internal server error occurred")
@basic_router.post("/search")
def get_search_response(
request: OneShotQARequest,
db_session: Session = Depends(get_session),
user: User | None = Depends(current_user),
) -> StreamingResponse:
def stream_generator() -> Generator[str, None, None]:
try:
for packet in get_answer_stream(request, user, db_session):
print("packet is")
print(packet.__dict__)
serialized = get_json_line(packet.model_dump())
yield serialized
except Exception as e:
logger.exception("Error in answer streaming")
yield json.dumps({"error": str(e)})
return StreamingResponse(stream_generator(), media_type="application/json")
@basic_router.post("/stream-answer-with-citation")
def stream_answer_with_citation(
request: OneShotQARequest,
@@ -264,3 +289,112 @@ def get_standard_answer(
except Exception as e:
logger.error(f"Error in get_standard_answer: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail="An internal server error occurred")
class FastSearchRequest(BaseModel):
"""Request for fast search endpoint that returns raw search results without section merging."""
query: str
filters: BaseFilters | None = (
None # Direct filter options instead of retrieval_options
)
max_results: Optional[
int
] = None # If not provided, defaults to FAST_SEARCH_MAX_HITS
class FastSearchResult(BaseModel):
"""A search result without section expansion or merging."""
document_id: str
chunk_id: int
content: str
source_links: dict[int, str] | None = None
score: Optional[float] = None
metadata: Optional[dict] = None
class FastSearchResponse(BaseModel):
"""Response from the fast search endpoint."""
results: list[SearchDoc]
total_found: int
@basic_router.post("/fast-search")
def get_fast_search_response(
request: FastSearchRequest,
db_session: Session = Depends(get_session),
user: User | None = Depends(current_user),
) -> FastSearchResponse:
"""Endpoint for fast search that returns up to 300 results without section merging.
This is optimized for quickly returning a large number of search results without the overhead
of section expansion, reranking, relevance evaluation, and merging.
"""
try:
# Set up the search request with optimized settings
max_results = request.max_results or FAST_SEARCH_MAX_HITS
# Create a search request with optimized settings
search_request = SearchRequest(
query=request.query,
human_selected_filters=request.filters,
# Skip section expansion
chunks_above=0,
chunks_below=0,
# Skip LLM evaluation
evaluation_type=LLMEvaluationType.SKIP,
# Limit the number of results
limit=max_results,
)
# Set up the LLM instances
llm, fast_llm = get_default_llms()
# Create the search pipeline with optimized settings
search_pipeline = SearchPipeline(
search_request=search_request,
user=user,
llm=llm,
fast_llm=fast_llm,
skip_query_analysis=True, # Skip expensive query analysis
db_session=db_session,
bypass_acl=False,
)
# Only retrieve chunks without further processing
chunks = search_pipeline._get_chunks()
# Convert chunks to response format
results = [
SearchDoc(
document_id=chunk.document_id,
chunk_ind=chunk.chunk_id,
semantic_identifier=chunk.semantic_identifier,
link=None, # Assuming source_links might be used for link
blurb=chunk.content,
source_type=chunk.source_type, # Default source type
boost=0, # Default boost value
hidden=False, # Default visibility
metadata=chunk.metadata,
score=chunk.score,
is_relevant=None,
relevance_explanation=None,
match_highlights=[],
updated_at=chunk.updated_at,
primary_owners=None,
secondary_owners=None,
is_internet=False,
)
for chunk in chunks
]
return FastSearchResponse(
results=results,
total_found=len(results),
)
except Exception as e:
logger.exception("Error in fast search")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -712,3 +712,4 @@ IMAGE_ANALYSIS_SYSTEM_PROMPT = os.environ.get(
DISABLE_AUTO_AUTH_REFRESH = (
os.environ.get("DISABLE_AUTO_AUTH_REFRESH", "").lower() == "true"
)
FAST_SEARCH_MAX_HITS = 300

View File

@@ -49,6 +49,7 @@ def _create_indexable_chunks(
) -> tuple[list[Document], list[DocMetadataAwareIndexChunk]]:
ids_to_documents = {}
chunks = []
now = datetime.datetime.now(datetime.timezone.utc)
for preprocessed_doc in preprocessed_docs:
document = Document(
id=preprocessed_doc["url"], # For Web connector, the URL is the ID
@@ -64,7 +65,7 @@ def _create_indexable_chunks(
source=DocumentSource.WEB,
semantic_identifier=preprocessed_doc["title"],
metadata={},
doc_updated_at=None,
doc_updated_at=now,
primary_owners=[],
secondary_owners=[],
chunk_count=preprocessed_doc["chunk_ind"] + 1,
@@ -214,6 +215,9 @@ def seed_initial_documents(
)(cohere_enabled)
docs, chunks = _create_indexable_chunks(processed_docs, tenant_id)
for i, doc in enumerate(docs):
print(f"{i}: has {doc.doc_updated_at}")
print(doc.doc_updated_at)
index_doc_batch_prepare(
documents=docs,

View File

@@ -48,6 +48,7 @@ class Settings(BaseModel):
application_status: ApplicationStatus = ApplicationStatus.ACTIVE
anonymous_user_enabled: bool | None = None
pro_search_enabled: bool | None = None
search_page_disabled: bool | None = None
temperature_override_enabled: bool | None = False
auto_scroll: bool | None = False

View File

@@ -87,7 +87,7 @@ ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
# Use NODE_OPTIONS in the build command
RUN NODE_OPTIONS="${NODE_OPTIONS}" npx next build
RUN NODE_OPTIONS="${NODE_OPTIONS} --max-old-space-size=4096" npx next build
# Step 2. Production image, copy all the files and run next
FROM base AS runner

View File

@@ -1,18 +1,19 @@
"use client";
import { ArrayHelpers, ErrorMessage, Field, useFormikContext } from "formik";
import { ArrayHelpers } from "formik";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useEffect, useState } from "react";
import { FiTrash2, FiRefreshCcw, FiRefreshCw } from "react-icons/fi";
import { StarterMessage } from "./interfaces";
import { useState } from "react";
import { FiTrash2, FiRefreshCw } from "react-icons/fi";
import { Button } from "@/components/ui/button";
import { SwapIcon } from "@/components/icons/icons";
import { TextFormField } from "@/components/admin/connectors/Field";
import { StarterMessage } from "./interfaces";
export default function StarterMessagesList({
values,

View File

@@ -16,7 +16,13 @@ import { AnonymousUserPath } from "./AnonymousUserPath";
import { useChatContext } from "@/components/context/ChatContext";
import { LLMSelector } from "@/components/llm/LLMSelector";
import { useVisionProviders } from "./hooks/useVisionProviders";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function Checkbox({
label,
sublabel,
@@ -196,6 +202,9 @@ export function SettingsForm() {
updateSettingField(updates);
}
}
function handleToggleDefaultPage(value: "search" | "chat") {
updateSettingField([{ fieldName: "default_page", newValue: value }]);
}
function handleConfirmAnonymousUsers() {
const updates: { fieldName: keyof Settings; newValue: any }[] = [
@@ -260,6 +269,39 @@ export function SettingsForm() {
}
/>
<Checkbox
label="Disable Search Page"
sublabel="If set, users will not be able to access the search page."
checked={settings.search_page_disabled ?? false}
onChange={(e) =>
handleToggleSettingsField("search_page_disabled", e.target.checked)
}
/>
{/* Place to ad a default page (search or chat), if search is not disabled */}
{settings.search_page_disabled && (
<div className="max-w-sm mt-4">
<Label>Default Page</Label>
<SubLabel>
Select the default page to open when a user navigates to the search
page.
</SubLabel>
<Select
value={settings.default_page ?? "search"}
onValueChange={(value) =>
handleToggleDefaultPage(value as "search" | "chat")
}
>
<SelectTrigger>
<SelectValue placeholder="Select default page" />
</SelectTrigger>
<SelectContent>
<SelectItem value="search">Search</SelectItem>
<SelectItem value="chat">Chat</SelectItem>
</SelectContent>
</Select>
</div>
)}
{NEXT_PUBLIC_CLOUD_ENABLED && settings.anonymous_user_enabled && (
<AnonymousUserPath setPopup={setPopup} />
)}

View File

@@ -11,6 +11,8 @@ export enum QueryHistoryType {
}
export interface Settings {
search_page_disabled: boolean;
default_page: "search" | "chat";
anonymous_user_enabled: boolean;
anonymous_user_path?: string;
maximum_chat_retention_days?: number | null;

View File

@@ -168,6 +168,32 @@ export function ChatPage({
}) {
const router = useRouter();
const searchParams = useSearchParams();
const transitionQuery = searchParams?.get("transitionQuery");
// Add a new state to track transition status
const [isMessagesVisible, setIsMessagesVisible] = useState(!transitionQuery);
// Handle the transition from search to chat if there's a transitionQuery
useEffect(() => {
if (transitionQuery) {
setMessage(transitionQuery);
// Start with messages hidden
setIsMessagesVisible(false);
// After the input bar has moved into position, fade in the messages
setTimeout(() => {
setIsMessagesVisible(true);
}, 800); // Slightly longer than the input animation (700ms)
}
}, [transitionQuery]);
// This class will be used to control message visibility
const messagesVisibilityClass = isMessagesVisible
? "opacity-100"
: "opacity-0";
const proSearchOverriden = searchParams?.get("agentic") === "true";
const {
chatSessions,
@@ -178,9 +204,11 @@ export function ChatPage({
folders,
shouldShowWelcomeModal,
refreshChatSessions,
proSearchToggled,
proSearchToggled: proSearchToggledFromContext,
} = useChatContext();
const proSearchToggled = proSearchOverriden || proSearchToggledFromContext;
const {
selectedFiles,
selectedFolders,
@@ -325,10 +353,6 @@ export function ChatPage({
)
: undefined
);
// Gather default temperature settings
const search_param_temperature = searchParams?.get(
SEARCH_PARAM_NAMES.TEMPERATURE
);
const setSelectedAssistantFromId = (assistantId: number) => {
// NOTE: also intentionally look through available assistants here, so that
@@ -2582,7 +2606,9 @@ export function ChatPage({
currentSessionChatState == "input" &&
!loadingError &&
!submittedMessage && (
<div className="h-full w-[95%] mx-auto flex flex-col justify-center items-center">
<div
className={`h-full w-[95%] mx-auto flex flex-col justify-center items-center transition-opacity duration-700 ${messagesVisibilityClass}`}
>
<ChatIntro selectedPersona={liveAssistant} />
<StarterMessages
@@ -2598,17 +2624,14 @@ export function ChatPage({
<div
style={{ overflowAnchor: "none" }}
key={currentSessionId()}
className={
(hasPerformedInitialScroll ? "" : " hidden ") +
"desktop:-ml-4 w-full mx-auto " +
"absolute mobile:top-0 desktop:top-0 left-0 " +
(settings?.enterpriseSettings
className={`transition-opacity duration-700 ${messagesVisibilityClass} ${
hasPerformedInitialScroll ? "" : "hidden"
} desktop:-ml-4 w-full mx-auto absolute mobile:top-0 desktop:top-0 left-0 ${
settings?.enterpriseSettings
?.two_lines_for_chat_header
? "pt-20 "
: "pt-4 ")
}
// NOTE: temporarily removing this to fix the scroll bug
// (hasPerformedInitialScroll ? "" : "invisible")
? "pt-20"
: "pt-4"
}`}
>
{messageHistory.map((message, i) => {
const messageMap = currentMessageMap(

View File

@@ -1,111 +0,0 @@
import React from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { SearchIcon } from "lucide-react";
interface AgenticToggleProps {
proSearchEnabled: boolean;
setProSearchEnabled: (enabled: boolean) => void;
}
const ProSearchIcon = () => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21 21L16.65 16.65M19 11C19 15.4183 15.4183 19 11 19C6.58172 19 3 15.4183 3 11C3 6.58172 6.58172 3 11 3C15.4183 3 19 6.58172 19 11Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M11 8V14M8 11H14"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export function AgenticToggle({
proSearchEnabled,
setProSearchEnabled,
}: AgenticToggleProps) {
const handleToggle = () => {
setProSearchEnabled(!proSearchEnabled);
};
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
className={`ml-auto py-1.5
rounded-lg
group
px-2 inline-flex items-center`}
onClick={handleToggle}
role="switch"
aria-checked={proSearchEnabled}
>
<div
className={`
${
proSearchEnabled
? "border-background-200 group-hover:border-[#000] dark:group-hover:border-neutral-300"
: "border-background-200 group-hover:border-[#000] dark:group-hover:border-neutral-300"
}
relative inline-flex h-[16px] w-8 items-center rounded-full transition-colors focus:outline-none border animate transition-all duration-200 border-background-200 group-hover:border-[1px] `}
>
<span
className={`${
proSearchEnabled
? "bg-agent translate-x-4 scale-75"
: "bg-background-600 group-hover:bg-background-950 translate-x-0.5 scale-75"
} inline-block h-[12px] w-[12px] group-hover:scale-90 transform rounded-full transition-transform duration-200 ease-in-out`}
/>
</div>
<span
className={`ml-2 text-sm font-[550] flex items-center ${
proSearchEnabled ? "text-agent" : "text-text-dark"
}`}
>
Agent
</span>
</button>
</TooltipTrigger>
<TooltipContent
side="top"
width="w-72"
className="p-4 bg-white rounded-lg shadow-lg border border-background-200 dark:border-neutral-900"
>
<div className="flex items-center space-x-2 mb-3">
<h3 className="text-sm font-semibold text-neutral-900">
Agent Search
</h3>
</div>
<p className="text-xs text-neutral-600 dark:text-neutral-700 mb-2">
Use AI agents to break down questions and run deep iterative
research through promising pathways. Gives more thorough and
accurate responses but takes slightly longer.
</p>
<ul className="text-xs text-text-600 dark:text-neutral-700 list-disc list-inside">
<li>Improved accuracy of search results</li>
<li>Less hallucinations</li>
<li>More comprehensive answers</li>
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View File

@@ -35,14 +35,11 @@ import { getFormattedDateRangeString } from "@/lib/dateUtils";
import { truncateString } from "@/lib/utils";
import { buildImgUrl } from "../files/images/utils";
import { useUser } from "@/components/user/UserProvider";
import { useDocumentSelection } from "../useDocumentSelection";
import { AgenticToggle } from "./AgenticToggle";
import { SettingsContext } from "@/components/settings/SettingsProvider";
import { getProviderIcon } from "@/app/admin/configuration/llm/interfaces";
import { LoadingIndicator } from "react-select/dist/declarations/src/components/indicators";
import { FidgetSpinner } from "react-loader-spinner";
import { LoadingAnimation } from "@/components/Loading";
import { useDocumentsContext } from "../my-documents/DocumentsContext";
import { useDocumentsContext } from "@/app/chat/my-documents/DocumentsContext";
import { SearchModeDropdown } from "@/app/chat/search/components/SearchInput";
import { useRouter, useSearchParams } from "next/navigation";
const MAX_INPUT_HEIGHT = 200;
export const SourceChip2 = ({
@@ -240,7 +237,13 @@ export function ChatInputBar({
currentMessageFiles,
setCurrentMessageFiles,
} = useDocumentsContext();
const [mode, setMode] = useState<"search" | "chat">("chat");
const searchParams = useSearchParams();
const transitionQuery = searchParams?.get("transitionQuery");
const fromPosition = searchParams?.get("fromPosition");
const isFromMiddle = fromPosition === "middle";
const [isTransitioning, setIsTransitioning] = useState(!!transitionQuery);
const router = useRouter();
const settings = useContext(SettingsContext);
useEffect(() => {
const textarea = textAreaRef.current;
@@ -253,6 +256,15 @@ export function ChatInputBar({
}
}, [message, textAreaRef]);
useEffect(() => {
if (isTransitioning) {
const timer = setTimeout(() => {
setIsTransitioning(false);
}, 100);
return () => clearTimeout(timer);
}
}, [isTransitioning]);
const handlePaste = (event: React.ClipboardEvent) => {
const items = event.clipboardData?.items;
if (items) {
@@ -427,9 +439,36 @@ export function ChatInputBar({
}
};
// Use different translation based on starting position
const transitionClass = isTransitioning
? isFromMiddle
? "translate-y-[-45vh]"
: "translate-y-[-90vh]"
: "translate-y-0";
// Handler for mode switching
const handleModeSwitch = (newMode: string) => {
if (newMode === "search") {
// When switching to search, navigate with parameters
const params = new URLSearchParams();
if (message.trim()) {
}
// Add parameter to indicate we're coming from chat
params.append("query", message);
params.append("fromChat", "true");
router.push(`/chat/search?${params.toString()}`);
} else {
setMode(newMode as "search" | "chat");
}
};
return (
<div id="onyx-chat-input">
<div className="flex justify-center mx-auto">
<div
id="onyx-chat-input"
className={`transition-transform duration-700 ease-out transform ${transitionClass}`}
>
<div className="flex justify-center mx-auto">
<div
className="
max-w-full
@@ -842,12 +881,11 @@ export function ChatInputBar({
)}
</div>
<div className="flex items-center my-auto">
{retrievalEnabled && settings?.settings.pro_search_enabled && (
<AgenticToggle
proSearchEnabled={proSearchEnabled}
setProSearchEnabled={setProSearchEnabled}
/>
)}
<SearchModeDropdown
mode={mode}
setMode={handleModeSwitch}
query={message}
/>
<button
id="onyx-chat-input-send-button"
className={`cursor-pointer ${

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
"use client";
import { useChatContext } from "@/components/context/ChatContext";
import FunctionalWrapper from "../../../components/chat/FunctionalWrapper";
import SearchPage from "./SearchPage";
import { redirect } from "next/navigation";
import { useContext } from "react";
import { SettingsContext } from "@/components/settings/SettingsProvider";
export default function WrappedSearch({
defaultSidebarOff,
isTransitioningFromChat,
}: {
// This is required for the chrome extension side panel
// we don't want to show the sidebar by default when the user opens the side panel
defaultSidebarOff?: boolean;
isTransitioningFromChat?: boolean;
}) {
const combinedSettings = useContext(SettingsContext);
const isSearchPageDisabled = combinedSettings?.settings.search_page_disabled;
if (isSearchPageDisabled) {
redirect("/chat");
}
return (
// <FunctionalWrapper
// content={(sidebarVisible, toggle) => (
<SearchPage
toggle={() => {}}
sidebarVisible={false}
firstMessage={undefined}
isTransitioningFromChat={isTransitioningFromChat}
/>
// )}
// />
);
}

View File

@@ -0,0 +1,65 @@
# SpecStory Artifacts Directory
This directory is automatically created and maintained by the SpecStory extension to preserve your Cursor composer and chat history.
## What's Here?
- `.specstory/history`: Contains markdown files of your AI coding sessions
- Each file represents a separate chat or composer session
- Files are automatically updated as you work
- `.specstory/cursor_rules_backups`: Contains backups of the `.cursor/rules/derived-cursor-rules.mdc` file
- Backups are automatically created each time the `.cursor/rules/derived-cursor-rules.mdc` file is updated
- You can enable/disable the Cursor Rules feature in the SpecStory settings
## Valuable Uses
- Capture: Keep your context window up-to-date when starting new Chat/Composer sessions via @ references
- Search: For previous prompts and code snippets
- Learn: Meta-analyze your patterns and learn from your past experiences
- Derive: Keep Cursor on course with your past decisions by automatically deriving Cursor rules from your AI interactions
## Version Control
We recommend keeping this directory under version control to maintain a history of your AI interactions. However, if you prefer not to version these files, you can exclude them by adding this to your `.gitignore`:
```
.specstory
```
We recommend not keeping the `.specstory/cursor_rules_backups` directory under version control if you are already using git to version the `.cursor/rules` directory, and committing regularly. You can exclude it by adding this to your `.gitignore`:
```
.specstory/cursor_rules_backups
```
## Searching Your Codebase
When searching your codebase in Cursor, search results may include your previous AI coding interactions. To focus solely on your actual code files, you can exclude the AI interaction history from search results.
To exclude AI interaction history:
1. Open the "Find in Files" search in Cursor (Cmd/Ctrl + Shift + F)
2. Navigate to the "files to exclude" section
3. Add the following pattern:
```
.specstory/*
```
This will ensure your searches only return results from your working codebase files.
## Notes
- Auto-save only works when Cursor/sqlite flushes data to disk. This results in a small delay after the AI response is complete before SpecStory can save the history.
- Auto-save does not yet work on remote WSL workspaces.
## Settings
You can control auto-saving behavior in Cursor:
1. Open Cursor → Settings → VS Code Settings (Cmd/Ctrl + ,)
2. Search for "SpecStory"
3. Find "Auto Save" setting to enable/disable
Auto-save occurs when changes are detected in Cursor's sqlite database, or every 2 minutes as a safety net.

View File

@@ -0,0 +1,208 @@
import React from "react";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { SourceMetadata } from "@/lib/search/interfaces";
import { SourceIcon } from "@/components/SourceIcon";
import { Badge } from "@/components/ui/badge";
import { Check, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface FilterBoxProps {
label: string;
icon?: React.ReactNode;
contentComponent?: React.ReactNode;
selected?: boolean;
count?: number;
onClick?: () => void;
}
export function FilterBox({
label,
icon,
contentComponent,
selected = false,
count,
onClick,
}: FilterBoxProps) {
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"border border-gray-300 rounded-md px-4 py-2 h-auto flex items-center gap-2 text-sm font-medium transition-colors hover:bg-gray-50 w-[160px] justify-between",
selected && "bg-gray-100 border-gray-400"
)}
onClick={onClick}
>
<div className="flex items-center gap-2">
{icon}
<span>{label}</span>
{count !== undefined && (
<Badge variant="secondary" className=" -my-1 -mr-4 ml-1 text-xs">
{count}
</Badge>
)}
</div>
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
{contentComponent && (
<PopoverContent
className="w-64 p-0 max-h-[300px] overflow-y-auto"
align="start"
>
{contentComponent}
</PopoverContent>
)}
</Popover>
);
}
// Source Filter Component
export function SourceFilter({
sources,
selectedSources,
onSourceSelect,
}: {
sources: SourceMetadata[];
selectedSources: SourceMetadata[];
onSourceSelect: (source: SourceMetadata) => void;
}) {
const selectedSourceIds = selectedSources.map((s) => s.internalName);
return (
<div className="p-2 divide-y divide-gray-100">
<div className="py-2 px-2 font-medium">Sources</div>
<div>
{sources.map((source) => (
<div
key={source.internalName}
className="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-gray-50"
onClick={() => onSourceSelect(source)}
>
<div className="flex items-center gap-2">
<SourceIcon sourceType={source.internalName} iconSize={16} />
<span className="text-sm">{source.displayName}</span>
</div>
{selectedSourceIds.includes(source.internalName) && (
<Check className="h-4 w-4 text-blue-600" />
)}
</div>
))}
</div>
</div>
);
}
// Time Filter Component
export function TimeFilter({
onTimeSelect,
selectedTimeRange,
}: {
onTimeSelect: (
range: { from: Date; to: Date; selectValue: string } | null
) => void;
selectedTimeRange: { from: Date; to: Date; selectValue: string } | null;
}) {
const timeOptions = [
{ label: "Any time", range: null },
{
label: "Past 24 hours",
range: {
from: new Date(Date.now() - 24 * 60 * 60 * 1000),
to: new Date(),
selectValue: "past_day",
},
},
{
label: "Past week",
range: {
from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
to: new Date(),
selectValue: "past_week",
},
},
{
label: "Past month",
range: {
from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
to: new Date(),
selectValue: "past_month",
},
},
{
label: "Past year",
range: {
from: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000),
to: new Date(),
selectValue: "past_year",
},
},
];
return (
<div className="p-2 divide-y divide-gray-100">
<div className="py-2 px-2 font-medium">Time Range</div>
<div>
{timeOptions.map((option) => (
<div
key={option.label}
className="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-gray-50"
onClick={() => onTimeSelect(option.range)}
>
<span className="text-sm">{option.label}</span>
{(!selectedTimeRange && !option.range) ||
(selectedTimeRange &&
option.range &&
selectedTimeRange.from.getTime() ===
option.range.from.getTime()) ? (
<Check className="h-4 w-4 text-blue-600" />
) : null}
</div>
))}
</div>
</div>
);
}
// Author Filter Component
export function AuthorFilter({
authors = [
"John Doe",
"Jane Smith",
"Alex Johnson",
"Maria Garcia",
"Sam Lee",
],
selectedAuthors = [],
onAuthorSelect,
}: {
authors?: string[];
selectedAuthors?: string[];
onAuthorSelect: (author: string) => void;
}) {
return (
<div className="p-2 divide-y divide-gray-100">
<div className="py-2 px-2 font-medium">Authors</div>
<div>
{authors.map((author) => (
<div
key={author}
className="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-gray-50"
onClick={() => onAuthorSelect(author)}
>
<span className="text-sm">{author}</span>
{selectedAuthors.includes(author) && (
<Check className="h-4 w-4 text-blue-600" />
)}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,378 @@
import React, { useState, useEffect } from "react";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import {
FiCalendar,
FiTag,
FiChevronLeft,
FiChevronRight,
FiDatabase,
FiBook,
} from "react-icons/fi";
import { FilterManager } from "@/lib/hooks";
import { DocumentSet, Tag } from "@/lib/types";
import { SourceMetadata } from "@/lib/search/interfaces";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { SourceIcon } from "@/components/SourceIcon";
import { Input } from "@/components/ui/input";
interface MoreFiltersPopupProps {
filterManager: FilterManager;
trigger: React.ReactNode;
availableSources: SourceMetadata[];
availableDocumentSets: DocumentSet[];
availableTags: Tag[];
}
export enum FilterCategories {
date = "date",
documentSets = "documentSets",
tags = "tags",
}
export function MoreFiltersPopup({
availableSources,
availableDocumentSets,
availableTags,
filterManager,
trigger,
}: MoreFiltersPopupProps) {
const [selectedFilter, setSelectedFilter] = useState<FilterCategories>(
FilterCategories.date
);
const [currentDate, setCurrentDate] = useState(new Date());
const [documentSetSearch, setDocumentSetSearch] = useState("");
const [filteredDocumentSets, setFilteredDocumentSets] = useState<
DocumentSet[]
>(availableDocumentSets);
const [tagSearch, setTagSearch] = useState("");
const [filteredTags, setFilteredTags] = useState<Tag[]>(availableTags);
useEffect(() => {
const lowercasedFilter = documentSetSearch.toLowerCase();
const filtered = availableDocumentSets.filter((docSet) =>
docSet.name.toLowerCase().includes(lowercasedFilter)
);
setFilteredDocumentSets(filtered);
}, [documentSetSearch, availableDocumentSets]);
useEffect(() => {
const lowercasedFilter = tagSearch.toLowerCase();
const filtered = availableTags.filter(
(tag) =>
tag.tag_key.toLowerCase().includes(lowercasedFilter) ||
tag.tag_value.toLowerCase().includes(lowercasedFilter)
);
setFilteredTags(filtered);
}, [tagSearch, availableTags]);
const FilterOption = ({
category,
icon,
label,
}: {
category: FilterCategories;
icon: React.ReactNode;
label: string;
}) => (
<li
className={`px-3 py-2 flex items-center gap-x-2 cursor-pointer transition-colors duration-200 ${
selectedFilter === category
? "bg-blue-50 text-blue-700 font-medium"
: "text-gray-600 hover:bg-gray-50"
}`}
onClick={() => {
setSelectedFilter(category);
}}
>
{icon}
<span className="text-sm font-medium">{label}</span>
</li>
);
const renderCalendar = () => {
const daysInMonth = new Date(
currentDate.getFullYear(),
currentDate.getMonth() + 1,
0
).getDate();
const firstDayOfMonth = new Date(
currentDate.getFullYear(),
currentDate.getMonth(),
1
).getDay();
const days = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
const isDateInRange = (date: Date) => {
if (!filterManager.timeRange) return false;
return (
date >= filterManager.timeRange.from &&
date <= filterManager.timeRange.to
);
};
const isStartDate = (date: Date) =>
filterManager.timeRange?.from.toDateString() === date.toDateString();
const isEndDate = (date: Date) =>
filterManager.timeRange?.to.toDateString() === date.toDateString();
return (
<div className="w-full">
<div className="flex justify-between items-center mb-4">
<button
onClick={() =>
setCurrentDate(
new Date(
currentDate.getFullYear(),
currentDate.getMonth() - 1,
1
)
)
}
className="text-gray-600 hover:text-gray-800"
>
<FiChevronLeft size={20} />
</button>
<span className="text-base font-semibold">
{currentDate.toLocaleString("default", {
month: "long",
year: "numeric",
})}
</span>
<button
onClick={() =>
setCurrentDate(
new Date(
currentDate.getFullYear(),
currentDate.getMonth() + 1,
1
)
)
}
className="text-gray-600 hover:text-gray-800"
>
<FiChevronRight size={20} />
</button>
</div>
<div className="grid grid-cols-7 gap-1 text-center mb-2">
{days.map((day) => (
<div key={day} className="text-xs font-medium text-gray-400">
{day}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1 text-center">
{Array.from({ length: firstDayOfMonth }).map((_, index) => (
<div key={`empty-${index}`} />
))}
{Array.from({ length: daysInMonth }).map((_, index) => {
const date = new Date(
currentDate.getFullYear(),
currentDate.getMonth(),
index + 1
);
const isInRange = isDateInRange(date);
const isStart = isStartDate(date);
const isEnd = isEndDate(date);
return (
<button
key={index + 1}
className={`w-8 h-8 text-sm rounded-full flex items-center justify-center
${isInRange ? "bg-blue-100" : "hover:bg-gray-100"}
${isStart || isEnd ? "bg-blue-500 text-white" : ""}
${
isInRange && !isStart && !isEnd
? "text-blue-600"
: "text-gray-700"
}
`}
onClick={() => {
if (!filterManager.timeRange || (isStart && isEnd)) {
filterManager.setTimeRange({
from: date,
to: date,
selectValue: "",
});
} else if (date < filterManager.timeRange.from) {
filterManager.setTimeRange({
...filterManager.timeRange,
from: date,
});
} else {
filterManager.setTimeRange({
...filterManager.timeRange,
to: date,
});
}
}}
>
{index + 1}
</button>
);
})}
</div>
</div>
);
};
return (
<Popover>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
className="bg-white w-[400px] p-0 shadow-lg"
align="start"
>
<div className="flex h-[325px]">
<div className="w-1/3 border-r border-gray-200 p-2">
<ul className="space-y-1">
<FilterOption
category={FilterCategories.date}
icon={<FiCalendar className="w-4 h-4" />}
label="Date"
/>
{availableDocumentSets.length > 0 && (
<FilterOption
category={FilterCategories.documentSets}
icon={<FiBook className="w-4 h-4" />}
label="Sets"
/>
)}
{availableTags.length > 0 && (
<FilterOption
category={FilterCategories.tags}
icon={<FiTag className="w-4 h-4" />}
label="Tags"
/>
)}
</ul>
</div>
<div className="w-2/3 p-4 overflow-auto">
{selectedFilter === FilterCategories.date && renderCalendar()}
{selectedFilter === FilterCategories.documentSets && (
<div className="pt-4 h-full flex flex-col w-full">
<div className="flex pb-2 px-4">
<Input
placeholder="Search document sets..."
value={documentSetSearch}
onChange={(e) => setDocumentSetSearch(e.target.value)}
className="border border-gray-300 w-full"
/>
</div>
<div className="space-y-1 border-t pt-2 border-t-gray-200 px-4 w-full max-h-64 overflow-y-auto">
{filteredDocumentSets.map((docSet) => (
<div
key={docSet.id}
className="p-2 flex gap-x-2 items-center rounded cursor-pointer transition-colors duration-200 hover:bg-gray-100"
onClick={() => {
const isSelected =
filterManager.selectedDocumentSets.includes(
docSet.name
);
filterManager.setSelectedDocumentSets((prev) =>
isSelected
? prev.filter((id) => id !== docSet.name)
: [...prev, docSet.name]
);
}}
>
<Checkbox
checked={filterManager.selectedDocumentSets.includes(
docSet.name
)}
/>
<span className="text-sm">{docSet.name}</span>
</div>
))}
</div>
</div>
)}
{selectedFilter === FilterCategories.tags && (
<div className="pt-4 h-full flex flex-col w-full">
<div className="flex pb-2 px-4">
<Input
placeholder="Search tags..."
value={tagSearch}
onChange={(e) => setTagSearch(e.target.value)}
className="border border-gray-300 w-full"
/>
</div>
<div className="space-y-1 border-t pt-2 border-t-gray-200 px-4 w-full max-h-64 overflow-y-auto">
{filteredTags
.sort((a, b) => a.tag_key.localeCompare(b.tag_key))
.map((tag, index) => (
<div
key={index}
className="p-2 flex gap-x-2 items-center rounded cursor-pointer transition-colors duration-200 hover:bg-gray-100"
onClick={() => {
const isSelected = filterManager.selectedTags.some(
(t) =>
t.tag_key === tag.tag_key &&
t.tag_value === tag.tag_value
);
filterManager.setSelectedTags((prev) =>
isSelected
? prev.filter(
(t) =>
t.tag_key !== tag.tag_key ||
t.tag_value !== tag.tag_value
)
: [...prev, tag]
);
}}
>
<Checkbox
checked={filterManager.selectedTags.some(
(t) =>
t.tag_key === tag.tag_key &&
t.tag_value === tag.tag_value
)}
/>
<span className="text-sm">{`${tag.tag_key}=${tag.tag_value}`}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
<Separator className="mt-0 mb-2" />
<div className="flex justify-between items-center px-4 py-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
filterManager.setTimeRange(null);
filterManager.setSelectedDocumentSets([]);
filterManager.setSelectedTags([]);
}}
className="text-xs"
>
Clear Filters
</Button>
<div className="text-xs text-gray-500 flex items-center space-x-1">
{filterManager.selectedDocumentSets.length > 0 && (
<span className="bg-gray-100 px-1.5 py-0.5 rounded-full">
{filterManager.selectedDocumentSets.length} sets
</span>
)}
{filterManager.selectedTags.length > 0 && (
<span className="bg-gray-100 px-1.5 py-0.5 rounded-full">
{filterManager.selectedTags.length} tags
</span>
)}
{filterManager.timeRange && (
<span className="bg-gray-100 px-1.5 py-0.5 rounded-full">
Date range
</span>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,52 @@
import React from "react";
import { FiInfo } from "react-icons/fi";
interface SearchAnswerProps {
answer: string | null;
isLoading: boolean;
error: string | null;
}
export function SearchAnswer({ answer, isLoading, error }: SearchAnswerProps) {
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<div className="flex items-start">
<div className="flex-shrink-0 text-red-500">
<FiInfo size={20} />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Error</h3>
<div className="mt-2 text-sm text-red-700">
<p>{error}</p>
</div>
</div>
</div>
</div>
);
}
if (isLoading && !answer) {
return (
<div className="bg-white border border-gray-200 rounded-lg p-4 mb-4 shadow-sm">
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-5/6 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-2/3"></div>
</div>
</div>
);
}
if (!answer) {
return null;
}
return (
<div className="bg-white border border-gray-200 rounded-lg p-4 mb-4 shadow-sm">
<h3 className="text-sm font-medium text-gray-900 mb-2">Answer</h3>
<div className="text-sm text-gray-700 whitespace-pre-line">{answer}</div>
</div>
);
}

View File

@@ -0,0 +1,149 @@
import React from "react";
import { FiFilter, FiChevronDown } from "react-icons/fi";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { SourceMetadata } from "@/lib/search/interfaces";
import { SourceIcon } from "@/components/SourceIcon";
import { FilterIcon, SearchIcon } from "lucide-react";
import { FilterManager } from "@/lib/hooks";
import { DocumentSet, Tag } from "@/lib/types";
import { MoreFiltersPopup } from "./MoreFiltersPopup";
interface SearchFiltersProps {
totalResults: number;
selectedSources: string[];
setSelectedSources: (sources: string[]) => void;
availableSources: SourceMetadata[];
sourceResults: Record<string, number>;
filterManager: FilterManager;
availableDocumentSets: DocumentSet[];
availableTags: Tag[];
}
export function SearchFilters({
totalResults,
selectedSources,
setSelectedSources,
availableSources,
sourceResults,
filterManager,
availableDocumentSets,
availableTags,
}: SearchFiltersProps) {
// Toggle source selection
const toggleSource = (source: string) => {
if (source === "all") {
// If "All" is clicked, either select only "all" or clear if already selected
setSelectedSources(selectedSources.includes("all") ? [] : ["all"]);
} else {
// If any other source is clicked
const newSelectedSources = [...selectedSources].filter(
(s) => s !== "all"
);
// Toggle the clicked source
if (newSelectedSources.includes(source)) {
newSelectedSources.splice(newSelectedSources.indexOf(source), 1);
} else {
newSelectedSources.push(source);
}
// If no sources are selected, default to "all"
setSelectedSources(
newSelectedSources.length > 0 ? newSelectedSources : ["all"]
);
}
};
return (
<div className="flex flex-col w-full">
<div className="flex items-center justify-between mb-2">
<p className="text-sm text-gray-500">Found {totalResults} results</p>
<div className="flex items-center gap-1 text-gray-500">
<FilterIcon size={14} />
</div>
</div>
<div className="flex flex-col w-full space-y-1">
<FilterButton
label="All"
icon={<SearchIcon size={16} className="text-gray-500" />}
count={totalResults}
isSelected={selectedSources.includes("all")}
onClick={() => toggleSource("all")}
/>
{availableSources.map((source) => (
<FilterButton
key={source.internalName}
label={source.displayName}
count={sourceResults[source.internalName] || 0}
isSelected={selectedSources.includes(source.internalName)}
onClick={() => toggleSource(source.internalName)}
icon={<SourceIcon sourceType={source.internalName} iconSize={16} />}
/>
))}
<MoreFiltersPopup
filterManager={filterManager}
availableSources={availableSources}
availableDocumentSets={availableDocumentSets}
availableTags={availableTags}
trigger={
<div
className={`flex items-center justify-between px-3 py-2 rounded-md cursor-pointer hover:bg-gray-100`}
>
<div className="flex items-center gap-2">
<FilterIcon size={16} />
<span className="text-sm">More Filters</span>
</div>
<div className="flex items-center space-x-1">
{(filterManager.selectedSources.length > 0 ||
filterManager.selectedDocumentSets.length > 0 ||
filterManager.selectedTags.length > 0 ||
filterManager.timeRange) && (
<span className="bg-blue-100 text-blue-800 text-xs px-1.5 py-0.5 rounded-full">
Active
</span>
)}
</div>
</div>
}
/>
</div>
</div>
);
}
interface FilterButtonProps {
label: string;
count: number;
isSelected: boolean;
onClick: () => void;
icon?: React.ReactNode;
}
function FilterButton({
label,
count,
isSelected,
onClick,
icon,
}: FilterButtonProps) {
return (
<div
className={`flex items-center justify-between px-3 py-2 rounded-md cursor-pointer ${
isSelected
? "bg-blue-50 text-blue-700 font-medium"
: "hover:bg-gray-100"
}`}
onClick={onClick}
>
<div className="flex items-center gap-2">
{icon}
<span className="text-sm">{label}</span>
</div>
<span className="text-sm text-gray-500">{count}</span>
</div>
);
}

View File

@@ -0,0 +1,299 @@
import React, {
useState,
KeyboardEvent,
useRef,
useEffect,
useLayoutEffect,
} from "react";
import { FiSearch, FiChevronDown } from "react-icons/fi";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { SendIcon } from "@/components/icons/icons";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useRouter } from "next/navigation";
type ModeType = "search" | "chat" | "agent";
interface SearchModeDropdownProps {
mode: ModeType;
setMode: (mode: ModeType) => void;
query?: string;
isMiddle?: boolean;
}
export const SearchModeDropdown = ({
mode,
setMode,
query = "",
isMiddle = false,
}: SearchModeDropdownProps) => {
const router = useRouter();
const getModeLabel = () => {
switch (mode) {
case "search":
return "Search Fast";
case "chat":
return "Chat";
case "agent":
return "Agent";
default:
return "Search Fast";
}
};
const handleModeChange = (newMode: ModeType) => {
setMode(newMode);
if (newMode === "chat") {
// Navigate to chat with the current query
const params = new URLSearchParams();
if (query.trim()) {
params.append("transitionQuery", query);
}
// Add a parameter to indicate starting position
params.append("fromPosition", isMiddle ? "middle" : "top");
// For an even cleaner transition, directly set the location
// This avoids any flash or reload effects from router navigation
router.push(`/chat?${params.toString()}`);
} else if (newMode === "agent") {
const params = new URLSearchParams();
params.append("agentic", "true");
if (query.trim()) {
params.append("transitionQuery", query);
}
params.append("fromPosition", isMiddle ? "middle" : "top");
router.push(`/chat?${params.toString()}`);
} else {
const params = new URLSearchParams();
if (query.trim()) {
params.append("query", query);
}
params.append("fromChat", "true");
router.push(`/chat/search?${params.toString()}`);
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="px-2 h-8 rounded-md text-xs font-normal flex items-center gap-1"
>
{getModeLabel()}
<FiChevronDown size={14} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuItem
onClick={() => handleModeChange("search")}
className="py-2 px-3 cursor-pointer"
>
<div className="flex flex-col">
<span className="font-medium">Search Fast</span>
<span className="text-xs text-gray-500 mt-1">
Find documents quickly
</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleModeChange("chat")}
className="py-2 px-3 cursor-pointer"
>
<div className="flex flex-col">
<span className="font-medium">Chat</span>
<span className="text-xs text-gray-500 mt-1">
Get AI answers. Chat with the LLM.
</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleModeChange("agent")}
className="py-2 px-3 cursor-pointer"
>
<div className="flex flex-col">
<span className="font-medium">Agent</span>
<span className="text-xs text-gray-500 mt-1">
Tackle complex queries or hard-to-find documents
</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
interface SearchInputProps {
initialQuery?: string;
onSearch: (query: string) => void;
placeholder?: string;
hide?: boolean;
isMiddle?: boolean;
isAnimatingFromChatInitial?: boolean;
}
const TRANSITION_DURATION = 1000; // ms
export const SearchInput = ({
initialQuery = "",
onSearch,
placeholder = "Search...",
hide = false,
isMiddle = false,
isAnimatingFromChatInitial = false,
}: SearchInputProps) => {
const [query, setQuery] = useState(initialQuery);
const [mode, setMode] = useState<ModeType>("search");
const router = useRouter();
const inputContainerRef = useRef<HTMLDivElement>(null);
// Check if we're coming from chat
const searchParams =
typeof window !== "undefined"
? new URLSearchParams(window.location.search)
: new URLSearchParams();
const fromChat = searchParams.get("fromChat") === "true";
const [isAnimatingFromChat, setIsAnimatingFromChat] = useState(
isAnimatingFromChatInitial
);
// Use layout effect for animations that affect layout
useLayoutEffect(() => {
if (fromChat && isMiddle) {
// Add a style to disable all animations temporarily
const style = document.createElement("style");
style.innerHTML = "* { transition: none !important; }";
document.head.appendChild(style);
// Force a repaint
document.body.offsetHeight;
// Remove the style after a small delay
setTimeout(() => {
document.head.removeChild(style);
// Set initial position (from bottom)
setIsAnimatingFromChat(true);
// Start animation after a brief delay
setTimeout(() => {
setIsAnimatingFromChat(false);
}, 50);
}, 10);
}
}, [fromChat, isMiddle]);
// Position class based on animation state
const getPositionClass = () => {
if (isAnimatingFromChat) {
return "translate-y-[85vh] scale-[0.85] opacity-60";
}
return "translate-y-0 scale-100 opacity-100";
};
// Detect if the search input is in the middle of the page
// alert(isAnimatingFromChat);
const [inMiddlePosition, setInMiddlePosition] = useState(isMiddle);
useEffect(() => {
// Update position state based on prop
setInMiddlePosition(isMiddle);
// For auto-detection, we could also use this:
if (inputContainerRef.current && typeof window !== "undefined") {
const rect = inputContainerRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Consider it in the middle if it's roughly in the middle third of the screen
const isInMiddleThird =
rect.top > viewportHeight / 3 && rect.top < (viewportHeight * 2) / 3;
if (isInMiddleThird && !isMiddle) {
setInMiddlePosition(true);
}
}
}, [isMiddle]);
const handleSearch = () => {
if (query.trim()) {
onSearch(query);
// After search is performed, it's definitely not in the middle anymore
setInMiddlePosition(false);
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
handleSearch();
}
};
const getPlaceholderText = () => {
switch (mode) {
case "search":
return placeholder;
case "chat":
return "Ask anything...";
case "agent":
return "Ask a complex question...";
default:
return placeholder;
}
};
return (
<div
ref={inputContainerRef}
className={`flex items-center w-full max-w-4xl relative transition-all duration-1000 ease-[cubic-bezier(0.16,1,0.3,1)] will-change-transform transform ${getPositionClass()} ${
hide && "invisible"
}`}
>
<div className="absolute left-3 text-gray-500">
<FiSearch size={16} />
</div>
<Input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={getPlaceholderText()}
className="pl-10 pr-20 py-2 h-10 text-base border border-gray-300 rounded-full focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-gray-50"
/>
<div className="absolute right-3 flex items-center space-x-1">
<SearchModeDropdown
mode={mode}
setMode={setMode}
query={query}
isMiddle={inMiddlePosition}
/>
<button
className={`cursor-pointer h-[22px] w-[22px] rounded-full ${
query
? "bg-neutral-900 dark:bg-neutral-50"
: "bg-neutral-500 dark:bg-neutral-400"
}`}
onClick={handleSearch}
aria-label={mode === "search" ? "Search" : "Send message"}
>
<SendIcon
size={22}
className="text-neutral-50 dark:text-neutral-900 p-1 my-auto"
/>
</button>
</div>
</div>
);
};

View File

@@ -2,7 +2,7 @@ import React from "react";
import { OnyxDocument } from "@/lib/search/interfaces";
import { ResultIcon } from "@/components/chat/sources/SourceCard";
import { getTimeAgoString } from "@/lib/dateUtils";
import { FiThumbsUp, FiUser, FiClock } from "react-icons/fi";
import { FiThumbsUp, FiUser, FiClock, FiCalendar } from "react-icons/fi";
import {
Tooltip,
TooltipContent,
@@ -21,15 +21,12 @@ export function SearchResultItem({ document, onClick }: SearchResultItemProps) {
onClick(document);
};
// Format the date if available
const formattedDate = document.updated_at
? getTimeAgoString(new Date(document.updated_at))
: "";
const lastUpdated = document.updated_at
// Format the update date if available
const updatedAtFormatted = document.updated_at
? getTimeAgoString(new Date(document.updated_at))
: "";
console.log(JSON.stringify(document));
return (
<div
className="border-b border-gray-200 py-4 hover:bg-gray-50 px-4 cursor-pointer"
@@ -42,27 +39,42 @@ export function SearchResultItem({ document, onClick }: SearchResultItemProps) {
<div className="flex-grow">
<div className="flex items-center gap-2">
<h3 className="text-base font-medium text-gray-900 line-clamp-1">
<h3
className="text-base font-medium text-gray-900 line-clamp-1 hover:underline cursor-pointer"
onClick={handleClick}
>
{document.semantic_identifier || "Untitled Document"}
</h3>
</div>
<div className="flex items-center gap-3 mt-2 text-xs text-gray-500">
<div className="flex items-center gap-3 my-1.5 text-xs text-gray-500">
{document.boost > 1 && (
<span className="text-xs bg-gray-100 px-2 py-0.5 rounded-full text-gray-500">
Matched
</span>
)}
{lastUpdated && (
<span className="flex items-center gap-1">
<FiClock size={12} />
{lastUpdated}
{document.primary_owners && document.primary_owners.length > 0 && (
<span className="text-xs flex items-center gap-1">
<FiUser size={12} />
{document.primary_owners.length === 1 ? (
document.primary_owners[0]
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="cursor-help">
{document.primary_owners.length} Owners
</TooltipTrigger>
<TooltipContent>
{document.primary_owners.join(", ")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</span>
)}
{formattedDate && (
{updatedAtFormatted && (
<span className="flex items-center gap-1">
<FiClock size={12} />
{formattedDate}
<span>Updated {updatedAtFormatted}</span>
</span>
)}
{document.metadata?.helpful && (
@@ -72,7 +84,7 @@ export function SearchResultItem({ document, onClick }: SearchResultItemProps) {
</span>
)}
</div>
<p className="text-sm text-gray-700 mt-1 line-clamp-2">
<p className="text-sm text-gray-700 line-clamp-2">
{document.blurb || "No description available"}
</p>
</div>

View File

@@ -0,0 +1,51 @@
import React from "react";
import { OnyxDocument } from "@/lib/search/interfaces";
import { SearchResultItem } from "./SearchResultItem";
interface SearchResultsProps {
documents: OnyxDocument[];
onDocumentClick: (document: OnyxDocument) => void;
isLoading?: boolean;
}
export function SearchResults({
documents,
onDocumentClick,
isLoading = false,
}: SearchResultsProps) {
if (isLoading) {
return (
<div className="flex flex-col w-full h-full items-center justify-start py-4">
<div className="animate-pulse w-full flex flex-col gap-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex gap-3 p-4">
<div className="rounded-full bg-background-200 h-8 w-8"></div>
<div className="flex-1 space-y-3">
<div className="h-4 bg-background-200 rounded w-3/4"></div>
<div className="h-3 bg-background-200 rounded w-full"></div>
<div className="h-3 bg-background-200 rounded w-5/6"></div>
<div className="h-2 bg-background-200 rounded w-1/4"></div>
</div>
</div>
))}
</div>
</div>
);
}
if (documents.length === 0) {
return (
<div className="flex flex-col w-full h-full items-center justify-center py-8">
<p className="text-text-500">No results found</p>
</div>
);
}
return (
<div className="flex flex-col w-full">
{documents.map((doc, ind) => (
<SearchResultItem key={ind} document={doc} onClick={onDocumentClick} />
))}
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { SEARCH_PARAMS } from "@/lib/extension/constants";
import WrappedSearch from "./WrappedSearch";
export default async function SearchPage(props: {
searchParams: Promise<{ [key: string]: string }>;
}) {
const searchParams = await props.searchParams;
const firstMessage = searchParams.firstMessage;
const defaultSidebarOff =
searchParams[SEARCH_PARAMS.DEFAULT_SIDEBAR_OFF] === "true";
const isTransitioningFromChat =
searchParams[SEARCH_PARAMS.TRANSITIONING_FROM_CHAT] === "true";
return (
<WrappedSearch
defaultSidebarOff={defaultSidebarOff}
isTransitioningFromChat={isTransitioningFromChat}
/>
);
}

View File

@@ -0,0 +1,110 @@
import { handleSSEStream } from "@/lib/search/streamingUtils";
import {
OnyxDocument,
SourceMetadata,
AnswerPiecePacket,
DocumentInfoPacket,
} from "@/lib/search/interfaces";
import { Persona } from "@/app/admin/assistants/interfaces";
import { buildFilters } from "@/lib/search/utils";
import { Tag } from "@/lib/types";
import { DateRangePickerValue } from "@/app/ee/admin/performance/DateRangeSelector";
import { StreamingError } from "@/app/chat/interfaces";
export interface SearchStreamResponse {
answer: string | null;
documents: OnyxDocument[];
error: string | null;
}
// Define interface matching FastSearchResult
interface FastSearchResult {
document_id: string;
chunk_id: number;
content: string;
source_links: string[];
score?: number;
metadata?: {
source_type?: string;
semantic_identifier?: string;
boost?: number;
hidden?: boolean;
updated_at?: string;
primary_owners?: string[];
secondary_owners?: string[];
[key: string]: any;
};
}
export async function* streamSearchWithCitation({
query,
persona,
sources,
documentSets,
timeRange,
tags,
}: {
query: string;
persona: Persona;
sources: SourceMetadata[];
documentSets: string[];
timeRange: DateRangePickerValue | null;
tags: Tag[];
}): AsyncGenerator<SearchStreamResponse> {
const filters = buildFilters(sources, documentSets, timeRange, tags);
// Use the fast-search endpoint instead
const response = await fetch("/api/query/fast-search", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query: query,
filters: filters,
max_results: 300, // Use the default max results for fast search
}),
});
if (!response.ok) {
const errorText = await response.text();
yield {
answer: null,
documents: [],
error: `Error: ${response.status} - ${errorText}`,
};
return;
}
// Since fast-search is not streaming, we need to process the complete response
const searchResults = await response.json();
console.log("searchResults", searchResults);
// Convert results to OnyxDocument format
try {
const documents: OnyxDocument[] = searchResults.results;
console.log("documents", documents);
// First yield just the documents to maintain similar streaming behavior
yield {
answer: null,
documents,
error: null,
};
// Final yield with completed results
yield {
answer: null,
documents,
error: null,
};
} catch (error) {
console.error("Error in streamSearchWithCitation", error);
yield {
answer: null,
documents: [],
error: `Error: ${error}`,
};
}
}

View File

View File

@@ -10,7 +10,12 @@ export function SourceIcon({
sourceType: ValidSources;
iconSize: number;
}) {
return getSourceMetadata(sourceType).icon({
size: iconSize,
});
try {
return getSourceMetadata(sourceType).icon({
size: iconSize,
});
} catch (error) {
console.error("Error getting source icon:", error);
return null;
}
}

View File

@@ -256,7 +256,10 @@ export function UserDropdown({
{toggleUserSettings && (
<DropdownOption
onClick={toggleUserSettings}
onClick={() => {
setUserInfoVisible(false);
toggleUserSettings();
}}
icon={<UserIcon size={16} className="my-auto" />}
label="User Settings"
/>

View File

@@ -20,6 +20,7 @@ interface ChatContextProps {
availableSources: ValidSources[];
ccPairs: CCPairBasicInfo[];
tags: Tag[];
authors?: string[];
documentSets: DocumentSet[];
availableDocumentSets: DocumentSet[];
availableTags: Tag[];
@@ -57,6 +58,7 @@ export const ChatProvider: React.FC<{
const [inputPrompts, setInputPrompts] = useState(value?.inputPrompts || []);
const [chatSessions, setChatSessions] = useState(value?.chatSessions || []);
const [folders, setFolders] = useState(value?.folders || []);
const [authors, setAuthors] = useState(value?.authors || []);
const reorderFolders = (displayPriorityMap: Record<number, number>) => {
setFolders(
@@ -113,6 +115,7 @@ export const ChatProvider: React.FC<{
reorderFolders,
refreshChatSessions,
refreshFolders,
authors,
}}
>
{children}

View File

@@ -55,6 +55,8 @@ export async function fetchSettingsSS(): Promise<CombinedSettings | null> {
pro_search_enabled: true,
temperature_override_enabled: true,
query_history_type: QueryHistoryType.NORMAL,
search_page_disabled: false,
default_page: "chat",
};
} else {
throw new Error(

View File

@@ -176,10 +176,15 @@ export async function fetchChatData(searchParams: {
DOCUMENT_SIDEBAR_WIDTH_COOKIE_NAME
);
const sidebarToggled = requestCookies.get(SIDEBAR_TOGGLED_COOKIE_NAME);
console.log("Pro search override");
console.log(searchParams);
const proSearchOverriden = searchParams["agent"];
const proSearchToggled =
proSearchOverriden === "true" ||
requestCookies.get(PRO_SEARCH_TOGGLED_COOKIE_NAME)?.value.toLowerCase() ===
"true";
"true";
// IF user is an anoymous user, we don't want to show the sidebar (they have no access to chat history)
const sidebarInitiallyVisible =

View File

@@ -124,7 +124,7 @@ export const getTimeAgoString = (date: Date | null) => {
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffDays / 30);
if (now.toDateString() === date.toDateString()) return "Today";
if (diffMs < 24 * 60 * 60 * 1000) return "Today";
if (diffDays === 1) return "Yesterday";
if (diffDays < 7) return `${diffDays}d ago`;
if (diffDays < 30) return `${diffWeeks}w ago`;

View File

@@ -34,6 +34,7 @@ export const LocalStorageKeys = {
export const SEARCH_PARAMS = {
DEFAULT_SIDEBAR_OFF: "defaultSidebarOff",
TRANSITIONING_FROM_CHAT: "fromChat",
};
export const NO_AUTH_USER_ID = "__no_auth_user__";

View File

@@ -114,6 +114,7 @@ export interface OnyxDocument extends MinimalOnyxDocument {
db_doc_id?: number;
is_internet: boolean;
validationState?: null | "good" | "bad";
primary_owners: string[] | null;
}
export interface LoadedOnyxDocument extends OnyxDocument {