mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-04 22:42:41 +00:00
Compare commits
7 Commits
cli/v0.2.0
...
search_pag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72a856794f | ||
|
|
54edca4ab4 | ||
|
|
d506fa4e31 | ||
|
|
fd8689a18c | ||
|
|
8874fbc2e1 | ||
|
|
da16c4beab | ||
|
|
e75e4878cf |
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ${
|
||||
|
||||
1398
web/src/app/chat/search/SearchPage.tsx
Normal file
1398
web/src/app/chat/search/SearchPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
36
web/src/app/chat/search/WrappedSearch.tsx
Normal file
36
web/src/app/chat/search/WrappedSearch.tsx
Normal 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}
|
||||
/>
|
||||
// )}
|
||||
// />
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
208
web/src/app/chat/search/components/FilterBox.tsx
Normal file
208
web/src/app/chat/search/components/FilterBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
378
web/src/app/chat/search/components/MoreFiltersPopup.tsx
Normal file
378
web/src/app/chat/search/components/MoreFiltersPopup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
web/src/app/chat/search/components/SearchAnswer.tsx
Normal file
52
web/src/app/chat/search/components/SearchAnswer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
web/src/app/chat/search/components/SearchFilters.tsx
Normal file
149
web/src/app/chat/search/components/SearchFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
299
web/src/app/chat/search/components/SearchInput.tsx
Normal file
299
web/src/app/chat/search/components/SearchInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
51
web/src/app/chat/search/components/SearchResults.tsx
Normal file
51
web/src/app/chat/search/components/SearchResults.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
web/src/app/chat/search/page.tsx
Normal file
20
web/src/app/chat/search/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
110
web/src/app/chat/search/searchUtils.ts
Normal file
110
web/src/app/chat/search/searchUtils.ts
Normal 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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
0
web/src/app/chat/transitions.css
Normal file
0
web/src/app/chat/transitions.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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__";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user