mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-09-28 04:49:21 +02:00
Document explorer admin page (#590)
This commit is contained in:
89
web/src/app/admin/documents/ScoreEditor.tsx
Normal file
89
web/src/app/admin/documents/ScoreEditor.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { PopupSpec } from "@/components/admin/connectors/Popup";
|
||||
import { useState } from "react";
|
||||
import { updateBoost } from "./lib";
|
||||
import { CheckmarkIcon, EditIcon } from "@/components/icons/icons";
|
||||
|
||||
export const ScoreSection = ({
|
||||
documentId,
|
||||
initialScore,
|
||||
setPopup,
|
||||
refresh,
|
||||
consistentWidth = true,
|
||||
}: {
|
||||
documentId: string;
|
||||
initialScore: number;
|
||||
setPopup: (popupSpec: PopupSpec | null) => void;
|
||||
refresh: () => void;
|
||||
consistentWidth?: boolean;
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [score, setScore] = useState(initialScore.toString());
|
||||
|
||||
const onSubmit = async () => {
|
||||
const numericScore = Number(score);
|
||||
if (isNaN(numericScore)) {
|
||||
setPopup({
|
||||
message: "Score must be a number",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMsg = await updateBoost(documentId, numericScore);
|
||||
if (errorMsg) {
|
||||
setPopup({
|
||||
message: errorMsg,
|
||||
type: "error",
|
||||
});
|
||||
} else {
|
||||
setPopup({
|
||||
message: "Updated score!",
|
||||
type: "success",
|
||||
});
|
||||
refresh();
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
return (
|
||||
<div className="my-auto h-full flex">
|
||||
<input
|
||||
value={score}
|
||||
onChange={(e) => {
|
||||
setScore(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
onSubmit();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
setScore(initialScore.toString());
|
||||
}
|
||||
}}
|
||||
className="border bg-slate-700 text-gray-200 border-gray-300 rounded py-1 px-3 w-16 h-5 my-auto"
|
||||
/>
|
||||
<div onClick={onSubmit} className="cursor-pointer my-auto ml-2">
|
||||
<CheckmarkIcon size={16} className="text-green-700" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex my-auto">
|
||||
<div className={"flex" + (consistentWidth && " w-6")}>
|
||||
<div className="ml-auto my-auto">{initialScore}</div>
|
||||
</div>
|
||||
<div
|
||||
className="cursor-pointer ml-2 my-auto"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<EditIcon size={16} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
12
web/src/app/admin/documents/explorer/lib.ts
Normal file
12
web/src/app/admin/documents/explorer/lib.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const adminSearch = async (query: string) => {
|
||||
const response = await fetch("/api/admin/search", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
}),
|
||||
});
|
||||
return response;
|
||||
};
|
195
web/src/app/admin/documents/explorer/page.tsx
Normal file
195
web/src/app/admin/documents/explorer/page.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
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 = ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { [key: string]: string };
|
||||
}) => {
|
||||
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>
|
||||
|
||||
<Main initialSearchValue={searchParams.query} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
@@ -3,13 +3,14 @@ import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { useState } from "react";
|
||||
import { PageSelector } from "@/components/PageSelector";
|
||||
import { DocumentBoostStatus } from "@/lib/types";
|
||||
import { updateBoost, updateHiddenStatus } from "./lib";
|
||||
import { updateBoost, updateHiddenStatus } from "../lib";
|
||||
import { CheckmarkIcon, EditIcon } from "@/components/icons/icons";
|
||||
import { numToDisplay } from "./constants";
|
||||
import { FiCheck, FiCheckSquare, FiEye, FiEyeOff, FiX } from "react-icons/fi";
|
||||
import { FiEye, FiEyeOff } from "react-icons/fi";
|
||||
import { getErrorMsg } from "@/lib/fetchUtils";
|
||||
import { HoverPopup } from "@/components/HoverPopup";
|
||||
import { CustomCheckbox } from "@/components/CustomCheckbox";
|
||||
import { ScoreSection } from "../ScoreEditor";
|
||||
|
||||
const IsVisibleSection = ({
|
||||
document,
|
||||
@@ -74,86 +75,6 @@ const IsVisibleSection = ({
|
||||
);
|
||||
};
|
||||
|
||||
const ScoreSection = ({
|
||||
documentId,
|
||||
initialScore,
|
||||
setPopup,
|
||||
refresh,
|
||||
}: {
|
||||
documentId: string;
|
||||
initialScore: number;
|
||||
setPopup: (popupSpec: PopupSpec | null) => void;
|
||||
refresh: () => void;
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [score, setScore] = useState(initialScore.toString());
|
||||
|
||||
const onSubmit = async () => {
|
||||
const numericScore = Number(score);
|
||||
if (isNaN(numericScore)) {
|
||||
setPopup({
|
||||
message: "Score must be a number",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMsg = await updateBoost(documentId, numericScore);
|
||||
if (errorMsg) {
|
||||
setPopup({
|
||||
message: errorMsg,
|
||||
type: "error",
|
||||
});
|
||||
} else {
|
||||
setPopup({
|
||||
message: "Updated score!",
|
||||
type: "success",
|
||||
});
|
||||
refresh();
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
return (
|
||||
<div className="m-auto flex">
|
||||
<input
|
||||
value={score}
|
||||
onChange={(e) => {
|
||||
setScore(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
onSubmit();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
setScore(initialScore.toString());
|
||||
}
|
||||
}}
|
||||
className="border bg-slate-700 text-gray-200 border-gray-300 rounded py-1 px-3 w-16"
|
||||
/>
|
||||
<div onClick={onSubmit} className="cursor-pointer my-auto ml-2">
|
||||
<CheckmarkIcon size={20} className="text-green-700" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex my-auto">
|
||||
<div className="w-6 flex">
|
||||
<div className="ml-auto">{initialScore}</div>
|
||||
</div>
|
||||
<div className="cursor-pointer ml-2" onClick={() => setIsOpen(true)}>
|
||||
<EditIcon size={20} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DocumentFeedbackTable = ({
|
||||
documents,
|
||||
refresh,
|
||||
|
@@ -12,6 +12,7 @@ export const CustomCheckbox = ({
|
||||
className="hidden"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
readOnly={onChange ? false : true}
|
||||
/>
|
||||
<span className="relative">
|
||||
<span
|
||||
|
@@ -25,6 +25,7 @@ import {
|
||||
Document360Icon,
|
||||
GoogleSitesIcon,
|
||||
GongIcon,
|
||||
ZoomInIcon,
|
||||
} from "@/components/icons/icons";
|
||||
import { getAuthDisabledSS, getCurrentUserSS } from "@/lib/userSS";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -272,6 +273,15 @@ export async function Layout({ children }: { children: React.ReactNode }) {
|
||||
),
|
||||
link: "/admin/documents/sets",
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<ZoomInIcon size={18} />
|
||||
<div className="ml-1">Explorer</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/documents/explorer",
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
|
@@ -4,7 +4,7 @@ import { getSourceIcon } from "../source";
|
||||
import { useState } from "react";
|
||||
import { PopupSpec } from "../admin/connectors/Popup";
|
||||
|
||||
const buildDocumentSummaryDisplay = (
|
||||
export const buildDocumentSummaryDisplay = (
|
||||
matchHighlights: string[],
|
||||
blurb: string
|
||||
) => {
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import React, { useState, KeyboardEvent, ChangeEvent } from "react";
|
||||
import React, { KeyboardEvent, ChangeEvent } from "react";
|
||||
import { MagnifyingGlass } from "@phosphor-icons/react";
|
||||
|
||||
interface SearchBarProps {
|
||||
|
@@ -32,6 +32,7 @@ export interface DanswerDocument {
|
||||
blurb: string;
|
||||
semantic_identifier: string | null;
|
||||
boost: number;
|
||||
hidden: boolean;
|
||||
score: number;
|
||||
match_highlights: string[];
|
||||
}
|
||||
|
Reference in New Issue
Block a user