mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-03-26 17:51:54 +01:00
Enhance document explorer
This commit is contained in:
parent
31bfd015ae
commit
23ee45c033
@ -19,6 +19,7 @@ from danswer.document_index.factory import get_default_document_index
|
||||
from danswer.document_index.vespa.index import VespaIndex
|
||||
from danswer.search.access_filters import build_access_filters_for_user
|
||||
from danswer.search.danswer_helper import recommend_search_flow
|
||||
from danswer.search.models import BaseFilters
|
||||
from danswer.search.models import IndexFilters
|
||||
from danswer.search.search_runner import chunks_to_search_docs
|
||||
from danswer.search.search_runner import danswer_search
|
||||
@ -47,6 +48,7 @@ router = APIRouter()
|
||||
|
||||
class AdminSearchRequest(BaseModel):
|
||||
query: str
|
||||
filters: BaseFilters
|
||||
|
||||
|
||||
class AdminSearchResponse(BaseModel):
|
||||
@ -64,9 +66,9 @@ def admin_search(
|
||||
|
||||
user_acl_filters = build_access_filters_for_user(user, db_session)
|
||||
final_filters = IndexFilters(
|
||||
source_type=None,
|
||||
document_set=None,
|
||||
time_cutoff=None,
|
||||
source_type=question.filters.source_type,
|
||||
document_set=question.filters.document_set,
|
||||
time_cutoff=question.filters.time_cutoff,
|
||||
access_control_list=user_acl_filters,
|
||||
)
|
||||
document_index = get_default_document_index()
|
||||
|
214
web/src/app/admin/documents/explorer/Explorer.tsx
Normal file
214
web/src/app/admin/documents/explorer/Explorer.tsx
Normal file
@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { adminSearch } from "./lib";
|
||||
import { MagnifyingGlass } from "@phosphor-icons/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { DanswerDocument } from "@/lib/search/interfaces";
|
||||
import { getSourceIcon } from "@/components/source";
|
||||
import { buildDocumentSummaryDisplay } from "@/components/search/DocumentDisplay";
|
||||
import { CustomCheckbox } from "@/components/CustomCheckbox";
|
||||
import { updateHiddenStatus } from "../lib";
|
||||
import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { getErrorMsg } from "@/lib/fetchUtils";
|
||||
import { ScoreSection } from "../ScoreEditor";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { HorizontalFilters } from "@/components/search/filtering/Filters";
|
||||
import { useFilters } from "@/lib/hooks";
|
||||
import { buildFilters } from "@/lib/search/utils";
|
||||
import { DocumentUpdatedAtBadge } from "@/components/search/DocumentUpdatedAtBadge";
|
||||
import { Connector, DocumentSet } from "@/lib/types";
|
||||
|
||||
const DocumentDisplay = ({
|
||||
document,
|
||||
refresh,
|
||||
setPopup,
|
||||
}: {
|
||||
document: DanswerDocument;
|
||||
refresh: () => void;
|
||||
setPopup: (popupSpec: PopupSpec | null) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
key={document.document_id}
|
||||
className="text-sm border-b border-gray-800 mb-3"
|
||||
>
|
||||
<div className="flex relative">
|
||||
<a
|
||||
className={
|
||||
"rounded-lg flex font-bold " +
|
||||
(document.link ? "" : "pointer-events-none")
|
||||
}
|
||||
href={document.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{getSourceIcon(document.source_type, 22)}
|
||||
<p className="truncate break-all ml-2 my-auto text-base">
|
||||
{document.semantic_identifier || document.document_id}
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-2 mt-1 text-xs">
|
||||
<div className="px-1 py-0.5 bg-gray-700 rounded flex">
|
||||
<p className="mr-1 my-auto">Boost:</p>
|
||||
<ScoreSection
|
||||
documentId={document.document_id}
|
||||
initialScore={document.boost}
|
||||
setPopup={setPopup}
|
||||
refresh={refresh}
|
||||
consistentWidth={false}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
onClick={async () => {
|
||||
const response = await updateHiddenStatus(
|
||||
document.document_id,
|
||||
!document.hidden
|
||||
);
|
||||
if (response.ok) {
|
||||
refresh();
|
||||
} else {
|
||||
setPopup({
|
||||
type: "error",
|
||||
message: `Failed to update document - ${getErrorMsg(
|
||||
response
|
||||
)}}`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="px-1 py-0.5 bg-gray-700 hover:bg-gray-600 rounded flex cursor-pointer select-none"
|
||||
>
|
||||
<div className="my-auto">
|
||||
{document.hidden ? (
|
||||
<div className="text-red-500">Hidden</div>
|
||||
) : (
|
||||
"Visible"
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-1 my-auto">
|
||||
<CustomCheckbox checked={!document.hidden} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{document.updated_at && (
|
||||
<div className="mt-2">
|
||||
<DocumentUpdatedAtBadge updatedAt={document.updated_at} />
|
||||
</div>
|
||||
)}
|
||||
<p className="pl-1 pt-2 pb-3 text-gray-200 break-words">
|
||||
{buildDocumentSummaryDisplay(document.match_highlights, document.blurb)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function Explorer({
|
||||
initialSearchValue,
|
||||
connectors,
|
||||
documentSets,
|
||||
}: {
|
||||
initialSearchValue: string | undefined;
|
||||
connectors: Connector<any>[];
|
||||
documentSets: DocumentSet[];
|
||||
}) {
|
||||
console.log(connectors);
|
||||
const router = useRouter();
|
||||
const { popup, setPopup } = usePopup();
|
||||
|
||||
const [query, setQuery] = useState(initialSearchValue || "");
|
||||
const [timeoutId, setTimeoutId] = useState<number | null>(null);
|
||||
const [results, setResults] = useState<DanswerDocument[]>([]);
|
||||
|
||||
const filterManager = useFilters();
|
||||
|
||||
const onSearch = async (query: string) => {
|
||||
const filters = buildFilters(
|
||||
filterManager.selectedSources,
|
||||
filterManager.selectedDocumentSets,
|
||||
filterManager.timeRange
|
||||
);
|
||||
const results = await adminSearch(query, filters);
|
||||
if (results.ok) {
|
||||
setResults((await results.json()).documents);
|
||||
}
|
||||
setTimeoutId(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (query && query.trim() !== "") {
|
||||
router.replace(
|
||||
`/admin/documents/explorer?query=${encodeURIComponent(query)}`
|
||||
);
|
||||
|
||||
const newTimeoutId = window.setTimeout(() => onSearch(query), 300);
|
||||
setTimeoutId(newTimeoutId);
|
||||
} else {
|
||||
setResults([]);
|
||||
}
|
||||
}, [
|
||||
query,
|
||||
filterManager.selectedDocumentSets,
|
||||
filterManager.selectedSources,
|
||||
filterManager.timeRange,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{popup}
|
||||
<div className="justify-center py-2">
|
||||
<div className="flex items-center w-full border-2 border-gray-600 rounded px-4 py-2 focus-within:border-blue-500">
|
||||
<MagnifyingGlass className="text-gray-400" />
|
||||
<textarea
|
||||
autoFocus
|
||||
className="flex-grow ml-2 h-6 bg-transparent outline-none placeholder-gray-400 overflow-hidden whitespace-normal resize-none"
|
||||
role="textarea"
|
||||
aria-multiline
|
||||
placeholder="Find documents based on title / content..."
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
onSearch(query);
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
suppressContentEditableWarning={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<HorizontalFilters
|
||||
{...filterManager}
|
||||
availableDocumentSets={documentSets}
|
||||
existingSources={connectors.map((connector) => connector.source)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<div className="mt-3">
|
||||
{results.map((document) => {
|
||||
return (
|
||||
<DocumentDisplay
|
||||
key={document.document_id}
|
||||
document={document}
|
||||
refresh={() => onSearch(query)}
|
||||
setPopup={setPopup}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!query && (
|
||||
<div className="flex text-gray-400 mt-3">
|
||||
Search for a document above to modify it's boost or hide it from
|
||||
searches.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,4 +1,6 @@
|
||||
export const adminSearch = async (query: string) => {
|
||||
import { Filters } from "@/lib/search/interfaces";
|
||||
|
||||
export const adminSearch = async (query: string, filters: Filters) => {
|
||||
const response = await fetch("/api/admin/search", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@ -6,6 +8,7 @@ export const adminSearch = async (query: string) => {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
filters,
|
||||
}),
|
||||
});
|
||||
return response;
|
||||
|
@ -1,193 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { ZoomInIcon } from "@/components/icons/icons";
|
||||
import { adminSearch } from "./lib";
|
||||
import { MagnifyingGlass } from "@phosphor-icons/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { DanswerDocument } from "@/lib/search/interfaces";
|
||||
import { FiZap } from "react-icons/fi";
|
||||
import { getSourceIcon } from "@/components/source";
|
||||
import { buildDocumentSummaryDisplay } from "@/components/search/DocumentDisplay";
|
||||
import { CustomCheckbox } from "@/components/CustomCheckbox";
|
||||
import { updateHiddenStatus } from "../lib";
|
||||
import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { getErrorMsg } from "@/lib/fetchUtils";
|
||||
import { ScoreSection } from "../ScoreEditor";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Explorer } from "./Explorer";
|
||||
import { fetchValidFilterInfo } from "@/lib/search/utilsSS";
|
||||
|
||||
const DocumentDisplay = ({
|
||||
document,
|
||||
refresh,
|
||||
setPopup,
|
||||
}: {
|
||||
document: DanswerDocument;
|
||||
refresh: () => void;
|
||||
setPopup: (popupSpec: PopupSpec | null) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
key={document.document_id}
|
||||
className="text-sm border-b border-gray-800 mb-3"
|
||||
>
|
||||
<div className="flex relative">
|
||||
<a
|
||||
className={
|
||||
"rounded-lg flex font-bold " +
|
||||
(document.link ? "" : "pointer-events-none")
|
||||
}
|
||||
href={document.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{getSourceIcon(document.source_type, 22)}
|
||||
<p className="truncate break-all ml-2 my-auto text-base">
|
||||
{document.semantic_identifier || document.document_id}
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-2 mt-1 text-xs">
|
||||
<div className="px-1 py-0.5 bg-gray-700 rounded flex">
|
||||
<p className="mr-1 my-auto">Boost:</p>
|
||||
<ScoreSection
|
||||
documentId={document.document_id}
|
||||
initialScore={document.boost}
|
||||
setPopup={setPopup}
|
||||
refresh={refresh}
|
||||
consistentWidth={false}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
onClick={async () => {
|
||||
const response = await updateHiddenStatus(
|
||||
document.document_id,
|
||||
!document.hidden
|
||||
);
|
||||
if (response.ok) {
|
||||
refresh();
|
||||
} else {
|
||||
setPopup({
|
||||
type: "error",
|
||||
message: `Failed to update document - ${getErrorMsg(
|
||||
response
|
||||
)}}`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="px-1 py-0.5 bg-gray-700 hover:bg-gray-600 rounded flex cursor-pointer select-none"
|
||||
>
|
||||
<div className="my-auto">
|
||||
{document.hidden ? (
|
||||
<div className="text-red-500">Hidden</div>
|
||||
) : (
|
||||
"Visible"
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-1 my-auto">
|
||||
<CustomCheckbox checked={!document.hidden} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="pl-1 pt-2 pb-3 text-gray-200 break-words">
|
||||
{buildDocumentSummaryDisplay(document.match_highlights, document.blurb)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Main = ({
|
||||
initialSearchValue,
|
||||
}: {
|
||||
initialSearchValue: string | undefined;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { popup, setPopup } = usePopup();
|
||||
|
||||
const [query, setQuery] = useState(initialSearchValue || "");
|
||||
const [timeoutId, setTimeoutId] = useState<number | null>(null);
|
||||
const [results, setResults] = useState<DanswerDocument[]>([]);
|
||||
|
||||
const onSearch = async (query: string) => {
|
||||
const results = await adminSearch(query);
|
||||
if (results.ok) {
|
||||
setResults((await results.json()).documents);
|
||||
}
|
||||
setTimeoutId(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (query && query.trim() !== "") {
|
||||
router.replace(
|
||||
`/admin/documents/explorer?query=${encodeURIComponent(query)}`
|
||||
);
|
||||
|
||||
const timeoutId = window.setTimeout(() => onSearch(query), 300);
|
||||
setTimeoutId(timeoutId);
|
||||
} else {
|
||||
setResults([]);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{popup}
|
||||
<div className="flex justify-center py-2">
|
||||
<div className="flex items-center w-full border-2 border-gray-600 rounded px-4 py-2 focus-within:border-blue-500">
|
||||
<MagnifyingGlass className="text-gray-400" />
|
||||
<textarea
|
||||
autoFocus
|
||||
className="flex-grow ml-2 h-6 bg-transparent outline-none placeholder-gray-400 overflow-hidden whitespace-normal resize-none"
|
||||
role="textarea"
|
||||
aria-multiline
|
||||
placeholder="Find documents based on title / content..."
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
}}
|
||||
suppressContentEditableWarning={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<div className="mt-3">
|
||||
{results.map((document) => {
|
||||
return (
|
||||
<DocumentDisplay
|
||||
key={document.document_id}
|
||||
document={document}
|
||||
refresh={() => onSearch(query)}
|
||||
setPopup={setPopup}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!query && (
|
||||
<div className="flex">
|
||||
<FiZap className="my-auto mr-1 text-blue-400" /> Search for a document
|
||||
above to modify it's boost or hide it from searches.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Page = ({
|
||||
const Page = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { [key: string]: string };
|
||||
}) => {
|
||||
const { connectors, documentSets } = await fetchValidFilterInfo();
|
||||
|
||||
return (
|
||||
<div className="mx-auto container">
|
||||
<div className="border-solid border-gray-600 border-b pb-2 mb-3 flex">
|
||||
<ZoomInIcon size={32} />
|
||||
<h1 className="text-3xl font-bold pl-2">Document Explorer</h1>
|
||||
</div>
|
||||
<AdminPageTitle
|
||||
icon={<ZoomInIcon size={32} />}
|
||||
title="Document Explorer"
|
||||
/>
|
||||
|
||||
<Main initialSearchValue={searchParams.query} />
|
||||
<Explorer
|
||||
initialSearchValue={searchParams.query}
|
||||
connectors={connectors}
|
||||
documentSets={documentSets}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
22
web/src/components/admin/Title.tsx
Normal file
22
web/src/components/admin/Title.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { HealthCheckBanner } from "../health/healthcheck";
|
||||
import { Divider } from "@tremor/react";
|
||||
|
||||
export function AdminPageTitle({
|
||||
icon,
|
||||
title,
|
||||
}: {
|
||||
icon: JSX.Element;
|
||||
title: string | JSX.Element;
|
||||
}) {
|
||||
return (
|
||||
<div className="dark">
|
||||
<div className="mb-4">
|
||||
<HealthCheckBanner />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold flex gap-x-2 mb-2">
|
||||
{icon} {title}
|
||||
</h1>
|
||||
<Divider />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -4,6 +4,7 @@ import { getSourceIcon } from "../source";
|
||||
import { useState } from "react";
|
||||
import { PopupSpec } from "../admin/connectors/Popup";
|
||||
import { timeAgo } from "@/lib/time";
|
||||
import { DocumentUpdatedAtBadge } from "./DocumentUpdatedAtBadge";
|
||||
|
||||
export const buildDocumentSummaryDisplay = (
|
||||
matchHighlights: string[],
|
||||
@ -167,25 +168,7 @@ export const DocumentDisplay = ({
|
||||
</div>
|
||||
</div>
|
||||
{document.updated_at && (
|
||||
<div className="flex flex-wrap gap-x-2 mt-1">
|
||||
<div
|
||||
className={`
|
||||
text-xs
|
||||
text-gray-200
|
||||
bg-gray-800
|
||||
rounded-full
|
||||
px-1
|
||||
py-0.5
|
||||
w-fit
|
||||
my-auto
|
||||
select-none
|
||||
mr-2`}
|
||||
>
|
||||
<div className="mr-1 my-auto flex">
|
||||
{"Updated " + timeAgo(document.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DocumentUpdatedAtBadge updatedAt={document.updated_at} />
|
||||
)}
|
||||
<p className="pl-1 pt-2 pb-3 text-gray-200 break-words">
|
||||
{buildDocumentSummaryDisplay(document.match_highlights, document.blurb)}
|
||||
|
25
web/src/components/search/DocumentUpdatedAtBadge.tsx
Normal file
25
web/src/components/search/DocumentUpdatedAtBadge.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { timeAgo } from "@/lib/time";
|
||||
|
||||
export function DocumentUpdatedAtBadge({ updatedAt }: { updatedAt: string }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-2 mt-1">
|
||||
<div
|
||||
className={`
|
||||
text-xs
|
||||
text-gray-200
|
||||
bg-gray-800
|
||||
rounded-full
|
||||
px-1
|
||||
py-0.5
|
||||
w-fit
|
||||
my-auto
|
||||
select-none
|
||||
mr-2`}
|
||||
>
|
||||
<div className="mr-1 my-auto flex">
|
||||
{"Updated " + timeAgo(updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -3,7 +3,7 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
import { SearchResultsDisplay } from "./SearchResultsDisplay";
|
||||
import { SourceSelector } from "./Filters";
|
||||
import { SourceSelector } from "./filtering/Filters";
|
||||
import { Connector, DocumentSet } from "@/lib/types";
|
||||
import { SearchTypeSelector } from "./SearchTypeSelector";
|
||||
import {
|
||||
@ -23,7 +23,7 @@ import { SearchHelper } from "./SearchHelper";
|
||||
import { CancellationToken, cancellable } from "@/lib/search/cancellable";
|
||||
import { NEXT_PUBLIC_DISABLE_STREAMING } from "@/lib/constants";
|
||||
import { searchRequest } from "@/lib/search/qa";
|
||||
import { useObjectState, useTimeRange } from "@/lib/hooks";
|
||||
import { useFilters, useObjectState, useTimeRange } from "@/lib/hooks";
|
||||
import { questionValidationStreamed } from "@/lib/search/streamingQuestionValidation";
|
||||
|
||||
const SEARCH_DEFAULT_OVERRIDES_START: SearchDefaultOverrides = {
|
||||
@ -60,11 +60,7 @@ export const SearchSection: React.FC<SearchSectionProps> = ({
|
||||
useObjectState<ValidQuestionResponse>(VALID_QUESTION_RESPONSE_DEFAULT);
|
||||
|
||||
// Filters
|
||||
const [timeRange, setTimeRange] = useTimeRange();
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const [selectedDocumentSets, setSelectedDocumentSets] = useState<string[]>(
|
||||
[]
|
||||
);
|
||||
const filterManager = useFilters();
|
||||
|
||||
// Search Type
|
||||
const [selectedSearchType, setSelectedSearchType] =
|
||||
@ -140,9 +136,9 @@ export const SearchSection: React.FC<SearchSectionProps> = ({
|
||||
: searchRequestStreamed;
|
||||
const searchFnArgs = {
|
||||
query,
|
||||
sources,
|
||||
documentSets: selectedDocumentSets,
|
||||
timeRange,
|
||||
sources: filterManager.selectedSources,
|
||||
documentSets: filterManager.selectedDocumentSets,
|
||||
timeRange: filterManager.timeRange,
|
||||
updateCurrentAnswer: cancellable({
|
||||
cancellationToken: lastSearchCancellationToken.current,
|
||||
fn: updateCurrentAnswer,
|
||||
@ -193,12 +189,7 @@ export const SearchSection: React.FC<SearchSectionProps> = ({
|
||||
<div className="absolute left-0 hidden 2xl:block w-64">
|
||||
{(connectors.length > 0 || documentSets.length > 0) && (
|
||||
<SourceSelector
|
||||
timeRange={timeRange}
|
||||
setTimeRange={setTimeRange}
|
||||
selectedSources={sources}
|
||||
setSelectedSources={setSources}
|
||||
selectedDocumentSets={selectedDocumentSets}
|
||||
setSelectedDocumentSets={setSelectedDocumentSets}
|
||||
{...filterManager}
|
||||
availableDocumentSets={documentSets}
|
||||
existingSources={connectors.map((connector) => connector.source)}
|
||||
/>
|
||||
|
105
web/src/components/search/filtering/FilterDropdown.tsx
Normal file
105
web/src/components/search/filtering/FilterDropdown.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { FiCheck, FiChevronDown } from "react-icons/fi";
|
||||
import { CustomDropdown } from "../../Dropdown";
|
||||
|
||||
interface Option {
|
||||
key: string;
|
||||
display: string | JSX.Element;
|
||||
}
|
||||
|
||||
export function FilterDropdown({
|
||||
options,
|
||||
selected,
|
||||
handleSelect,
|
||||
icon,
|
||||
defaultDisplay,
|
||||
}: {
|
||||
options: Option[];
|
||||
selected: string[];
|
||||
handleSelect: (option: Option) => void;
|
||||
icon: JSX.Element;
|
||||
defaultDisplay: string | JSX.Element;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-64">
|
||||
<CustomDropdown
|
||||
dropdown={
|
||||
<div
|
||||
className={`
|
||||
border
|
||||
border-gray-800
|
||||
rounded-lg
|
||||
flex
|
||||
flex-col
|
||||
w-64
|
||||
max-h-96
|
||||
overflow-y-auto
|
||||
overscroll-contain`}
|
||||
>
|
||||
{options.map((option, ind) => {
|
||||
const isSelected = selected.includes(option.key);
|
||||
return (
|
||||
<div
|
||||
key={option.key}
|
||||
className={`
|
||||
flex
|
||||
px-3
|
||||
text-sm
|
||||
text-gray-200
|
||||
py-2.5
|
||||
select-none
|
||||
cursor-pointer
|
||||
${
|
||||
ind === options.length - 1
|
||||
? ""
|
||||
: "border-b border-gray-800"
|
||||
}
|
||||
${
|
||||
isSelected
|
||||
? "bg-dark-tremor-background-muted"
|
||||
: "hover:bg-dark-tremor-background-muted "
|
||||
}
|
||||
`}
|
||||
onClick={(event) => {
|
||||
handleSelect(option);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{option.display}
|
||||
{isSelected && (
|
||||
<div className="ml-auto mr-1">
|
||||
<FiCheck />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
flex
|
||||
w-64
|
||||
text-sm
|
||||
text-gray-400
|
||||
px-3
|
||||
py-1.5
|
||||
rounded-lg
|
||||
border
|
||||
border-gray-800
|
||||
cursor-pointer
|
||||
hover:bg-dark-tremor-background-muted`}
|
||||
>
|
||||
{icon}
|
||||
{selected.length === 0 ? (
|
||||
defaultDisplay
|
||||
) : (
|
||||
<p className="text-gray-200 line-clamp-1">{selected.join(", ")}</p>
|
||||
)}
|
||||
<FiChevronDown className="my-auto ml-auto" />
|
||||
</div>
|
||||
</CustomDropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,12 +1,13 @@
|
||||
import React from "react";
|
||||
import { getSourceIcon } from "../source";
|
||||
import { getSourceIcon } from "../../source";
|
||||
import { DocumentSet, ValidSources } from "@/lib/types";
|
||||
import { Source } from "@/lib/search/interfaces";
|
||||
import { InfoIcon, defaultTailwindCSS } from "../icons/icons";
|
||||
import { HoverPopup } from "../HoverPopup";
|
||||
import { FiFilter } from "react-icons/fi";
|
||||
import { DateRangeSelector } from "./DateRangeSelector";
|
||||
import { InfoIcon, defaultTailwindCSS } from "../../icons/icons";
|
||||
import { HoverPopup } from "../../HoverPopup";
|
||||
import { FiBook, FiBookmark, FiFilter, FiMap, FiX } from "react-icons/fi";
|
||||
import { DateRangeSelector } from "../DateRangeSelector";
|
||||
import { DateRangePickerValue } from "@tremor/react";
|
||||
import { FilterDropdown } from "./FilterDropdown";
|
||||
|
||||
const sources: Source[] = [
|
||||
{ displayName: "Google Drive", internalName: "google_drive" },
|
||||
@ -168,3 +169,167 @@ export function SourceSelector({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectedBubble({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: string | JSX.Element;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex cursor-pointer items-center text-white border border-gray-800 " +
|
||||
"py-1 my-1.5 rounded-lg px-2 w-fit bg-dark-tremor-background-muted hover:bg-gray-800"
|
||||
}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
<FiX className="ml-2 text-gray-400" size={14} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HorizontalFilters({
|
||||
timeRange,
|
||||
setTimeRange,
|
||||
selectedSources,
|
||||
setSelectedSources,
|
||||
selectedDocumentSets,
|
||||
setSelectedDocumentSets,
|
||||
availableDocumentSets,
|
||||
existingSources,
|
||||
}: SourceSelectorProps) {
|
||||
const handleSourceSelect = (source: Source) => {
|
||||
setSelectedSources((prev: Source[]) => {
|
||||
const prevSourceNames = prev.map((source) => source.internalName);
|
||||
if (prevSourceNames.includes(source.internalName)) {
|
||||
return prev.filter((s) => s.internalName !== source.internalName);
|
||||
} else {
|
||||
return [...prev, source];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDocumentSetSelect = (documentSetName: string) => {
|
||||
setSelectedDocumentSets((prev: string[]) => {
|
||||
if (prev.includes(documentSetName)) {
|
||||
return prev.filter((s) => s !== documentSetName);
|
||||
} else {
|
||||
return [...prev, documentSetName];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const availableSources = sources.filter((source) =>
|
||||
existingSources.includes(source.internalName)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="dark">
|
||||
<div className="flex gap-x-3">
|
||||
<div className="w-64">
|
||||
<DateRangeSelector value={timeRange} onValueChange={setTimeRange} />
|
||||
</div>
|
||||
|
||||
<FilterDropdown
|
||||
options={availableSources.map((source) => {
|
||||
return {
|
||||
key: source.displayName,
|
||||
display: (
|
||||
<>
|
||||
{" "}
|
||||
{getSourceIcon(source.internalName, 16)}
|
||||
<span className="ml-2 text-sm text-gray-200">
|
||||
{source.displayName}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
};
|
||||
})}
|
||||
selected={selectedSources.map((source) => source.displayName)}
|
||||
handleSelect={(option) =>
|
||||
handleSourceSelect(
|
||||
sources.find((source) => source.displayName === option.key)!
|
||||
)
|
||||
}
|
||||
icon={
|
||||
<div className="my-auto mr-2 text-gray-500 w-[16px] h-[16px]">
|
||||
<FiMap size={16} />
|
||||
</div>
|
||||
}
|
||||
defaultDisplay="All Sources"
|
||||
/>
|
||||
|
||||
<FilterDropdown
|
||||
options={availableDocumentSets.map((documentSet) => {
|
||||
return {
|
||||
key: documentSet.name,
|
||||
display: (
|
||||
<>
|
||||
<div className="text-gray-500 my-auto">
|
||||
<FiBookmark />
|
||||
</div>
|
||||
<span className="ml-2 text-sm text-gray-200">
|
||||
{documentSet.name}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
};
|
||||
})}
|
||||
selected={selectedDocumentSets}
|
||||
handleSelect={(option) => handleDocumentSetSelect(option.key)}
|
||||
icon={
|
||||
<div className="my-auto mr-2 text-gray-500 w-[16px] h-[16px]">
|
||||
<FiBook size={16} />
|
||||
</div>
|
||||
}
|
||||
defaultDisplay="All Document Sets"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex border-b border-gray-800 pb-4 mt-2 h-12">
|
||||
<div className="flex flex-wrap gap-x-2">
|
||||
{timeRange && timeRange.selectValue && (
|
||||
<SelectedBubble onClick={() => setTimeRange(null)}>
|
||||
<div className="text-sm flex text-gray-400">
|
||||
{timeRange.selectValue}
|
||||
</div>
|
||||
</SelectedBubble>
|
||||
)}
|
||||
{existingSources.length > 0 &&
|
||||
selectedSources.map((source) => (
|
||||
<SelectedBubble
|
||||
key={source.internalName}
|
||||
onClick={() => handleSourceSelect(source)}
|
||||
>
|
||||
<>
|
||||
{getSourceIcon(source.internalName, 16)}
|
||||
<span className="ml-2 text-sm text-gray-400">
|
||||
{source.displayName}
|
||||
</span>
|
||||
</>
|
||||
</SelectedBubble>
|
||||
))}
|
||||
{selectedDocumentSets.length > 0 &&
|
||||
selectedDocumentSets.map((documentSetName) => (
|
||||
<SelectedBubble
|
||||
key={documentSetName}
|
||||
onClick={() => handleDocumentSetSelect(documentSetName)}
|
||||
>
|
||||
<>
|
||||
<div className="text-gray-500">
|
||||
<FiBookmark />
|
||||
</div>
|
||||
<span className="ml-2 text-sm text-gray-400">
|
||||
{documentSetName}
|
||||
</span>
|
||||
</>
|
||||
</SelectedBubble>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -7,6 +7,7 @@ import useSWR, { mutate, useSWRConfig } from "swr";
|
||||
import { fetcher } from "./fetcher";
|
||||
import { useState } from "react";
|
||||
import { DateRangePickerValue } from "@tremor/react";
|
||||
import { Source } from "./search/interfaces";
|
||||
|
||||
const CREDENTIAL_URL = "/api/manage/admin/credential";
|
||||
|
||||
@ -73,3 +74,20 @@ export const useConnectorCredentialIndexingStatus = (
|
||||
export const useTimeRange = (initialValue?: DateRangePickerValue) => {
|
||||
return useState<DateRangePickerValue | null>(null);
|
||||
};
|
||||
|
||||
export function useFilters() {
|
||||
const [timeRange, setTimeRange] = useTimeRange();
|
||||
const [selectedSources, setSelectedSources] = useState<Source[]>([]);
|
||||
const [selectedDocumentSets, setSelectedDocumentSets] = useState<string[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
timeRange,
|
||||
setTimeRange,
|
||||
selectedSources,
|
||||
setSelectedSources,
|
||||
selectedDocumentSets,
|
||||
setSelectedDocumentSets,
|
||||
};
|
||||
}
|
||||
|
@ -59,6 +59,12 @@ export interface SearchDefaultOverrides {
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface Filters {
|
||||
source_type: string[] | null;
|
||||
document_set: string[] | null;
|
||||
time_cutoff: Date | null;
|
||||
}
|
||||
|
||||
export interface SearchRequestArgs {
|
||||
query: string;
|
||||
sources: Source[];
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { Source } from "./interfaces";
|
||||
import { Filters, Source } from "./interfaces";
|
||||
import { DateRangePickerValue } from "@tremor/react";
|
||||
|
||||
export const buildFilters = (
|
||||
sources: Source[],
|
||||
documentSets: string[],
|
||||
timeRange: DateRangePickerValue | null
|
||||
) => {
|
||||
): Filters => {
|
||||
const filters = {
|
||||
source_type:
|
||||
sources.length > 0 ? sources.map((source) => source.internalName) : null,
|
||||
|
29
web/src/lib/search/utilsSS.ts
Normal file
29
web/src/lib/search/utilsSS.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Connector, DocumentSet } from "../types";
|
||||
import { fetchSS } from "../utilsSS";
|
||||
|
||||
export async function fetchValidFilterInfo() {
|
||||
const [connectorsResponse, documentSetResponse] = await Promise.all([
|
||||
fetchSS("/manage/connector"),
|
||||
fetchSS("/manage/document-set"),
|
||||
]);
|
||||
|
||||
let connectors = [] as Connector<any>[];
|
||||
if (connectorsResponse.ok) {
|
||||
connectors = (await connectorsResponse.json()) as Connector<any>[];
|
||||
} else {
|
||||
console.log(
|
||||
`Failed to fetch connectors - ${connectorsResponse.status} - ${connectorsResponse.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
let documentSets = [] as DocumentSet[];
|
||||
if (documentSetResponse.ok) {
|
||||
documentSets = (await documentSetResponse.json()) as DocumentSet[];
|
||||
} else {
|
||||
console.log(
|
||||
`Failed to fetch document sets - ${documentSetResponse.status} - ${documentSetResponse.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
return { connectors, documentSets };
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user