mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-23 05:39:05 +00:00
feat: search frontend (#551)
* feat: better highlight * feat(search): add long_summary to search vector for improved search results - Update search vector to include long_summary with weight B (between title A and webvtt C) - Modify SearchController to fetch long_summary and prioritize its snippets - Generate snippets from long_summary first (max 2), then from webvtt for remaining slots - Add comprehensive tests for long_summary search functionality - Create migration to update search_vector_en column in PostgreSQL This improves search quality by including summarized content which often contains key topics and themes that may not be explicitly mentioned in the transcript. * fix: address code review feedback for search enhancements - Fix test file inconsistencies by removing references to non-existent model fields - Comment out tests for unimplemented features (room_ids, status filters, date ranges) - Update tests to only use currently available fields (room_id singular, no room_name/processing_status) - Mark future functionality tests with @pytest.mark.skip - Make snippet counts configurable - Add LONG_SUMMARY_MAX_SNIPPETS constant (default: 2) - Replace hardcoded value with configurable constant - Improve error handling consistency in WebVTT parsing - Use different log levels for different error types (debug for malformed, warning for decode, error for unexpected) - Add catch-all exception handler for unexpected errors - Include stack trace for critical errors All existing tests pass with these changes. * fix: correct datetime test to include required duration field * feat: better highlight * feat: search room names * feat: acknowledge deleted room * feat: search filters fix and rank removal * chore: minor refactoring * feat: better matches frontend * chore: self-review (vibe) * chore: self-review WIP * chore: self-review WIP * chore: self-review WIP * chore: self-review WIP * chore: self-review WIP * chore: self-review WIP * chore: self-review WIP * remove swc (vibe) * search url query sync (vibe) * search url query sync (vibe) * better casts and cap while * PR review + simplify frontend hook * pr: remove search db timeouts * cleanup tests * tests cleanup * frontend cleanup * index declarations * refactor frontend (self-review) * fix search pagination * clear "x" for search input * pagination max pages fix * chore: cleanup * cleanup * cleanup * cleanup * cleanup * cleanup * cleanup * cleanup * lockfile * pr review
This commit is contained in:
@@ -1,33 +1,264 @@
|
||||
"use client";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Flex, Spinner, Heading, Text, Link } from "@chakra-ui/react";
|
||||
import useTranscriptList from "../transcripts/useTranscriptList";
|
||||
import {
|
||||
Flex,
|
||||
Spinner,
|
||||
Heading,
|
||||
Text,
|
||||
Link,
|
||||
Box,
|
||||
Stack,
|
||||
Input,
|
||||
Button,
|
||||
IconButton,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
useQueryState,
|
||||
parseAsString,
|
||||
parseAsInteger,
|
||||
parseAsStringLiteral,
|
||||
} from "nuqs";
|
||||
import { LuX } from "react-icons/lu";
|
||||
import { useSearchTranscripts } from "../transcripts/useSearchTranscripts";
|
||||
import useSessionUser from "../../lib/useSessionUser";
|
||||
import { Room } from "../../api";
|
||||
import Pagination from "./_components/Pagination";
|
||||
import { Room, SourceKind, SearchResult, $SourceKind } from "../../api";
|
||||
import useApi from "../../lib/useApi";
|
||||
import { useError } from "../../(errors)/errorContext";
|
||||
import { SourceKind } from "../../api";
|
||||
import FilterSidebar from "./_components/FilterSidebar";
|
||||
import SearchBar from "./_components/SearchBar";
|
||||
import TranscriptTable from "./_components/TranscriptTable";
|
||||
import Pagination, {
|
||||
FIRST_PAGE,
|
||||
PaginationPage,
|
||||
parsePaginationPage,
|
||||
totalPages as getTotalPages,
|
||||
} from "./_components/Pagination";
|
||||
import TranscriptCards from "./_components/TranscriptCards";
|
||||
import DeleteTranscriptDialog from "./_components/DeleteTranscriptDialog";
|
||||
import { formatLocalDate } from "../../lib/time";
|
||||
import { RECORD_A_MEETING_URL } from "../../api/urls";
|
||||
|
||||
const SEARCH_FORM_QUERY_INPUT_NAME = "query" as const;
|
||||
|
||||
const usePrefetchRooms = (setRooms: (rooms: Room[]) => void): void => {
|
||||
const { setError } = useError();
|
||||
const api = useApi();
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
api
|
||||
.v1RoomsList({ page: 1 })
|
||||
.then((rooms) => setRooms(rooms.items))
|
||||
.catch((err) => setError(err, "There was an error fetching the rooms"));
|
||||
}, [api, setError]);
|
||||
};
|
||||
|
||||
const SearchForm: React.FC<{
|
||||
setPage: (page: PaginationPage) => void;
|
||||
sourceKind: SourceKind | null;
|
||||
roomId: string | null;
|
||||
setSourceKind: (sourceKind: SourceKind | null) => void;
|
||||
setRoomId: (roomId: string | null) => void;
|
||||
rooms: Room[];
|
||||
searchQuery: string | null;
|
||||
setSearchQuery: (query: string | null) => void;
|
||||
}> = ({
|
||||
setPage,
|
||||
sourceKind,
|
||||
roomId,
|
||||
setRoomId,
|
||||
setSourceKind,
|
||||
rooms,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
}) => {
|
||||
// to keep the search input controllable + more fine grained control (urlSearchQuery is updated on submits)
|
||||
const [searchInputValue, setSearchInputValue] = useState(searchQuery || "");
|
||||
const handleSearchQuerySubmit = async (d: FormData) => {
|
||||
await setSearchQuery((d.get(SEARCH_FORM_QUERY_INPUT_NAME) as string) || "");
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchInputValue("");
|
||||
setSearchQuery(null);
|
||||
setPage(FIRST_PAGE);
|
||||
};
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<form action={handleSearchQuerySubmit}>
|
||||
<Flex alignItems="center">
|
||||
<Box position="relative" flex="1">
|
||||
<Input
|
||||
placeholder="Search transcriptions..."
|
||||
value={searchInputValue}
|
||||
onChange={(e) => setSearchInputValue(e.target.value)}
|
||||
name={SEARCH_FORM_QUERY_INPUT_NAME}
|
||||
pr={searchQuery ? "2.5rem" : undefined}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<IconButton
|
||||
aria-label="Clear search"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleClearSearch}
|
||||
position="absolute"
|
||||
right="0.25rem"
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
_hover={{ bg: "gray.100" }}
|
||||
>
|
||||
<LuX />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
<Button ml={2} type="submit">
|
||||
Search
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
<UnderSearchFormFilterIndicators
|
||||
sourceKind={sourceKind}
|
||||
roomId={roomId}
|
||||
setSourceKind={setSourceKind}
|
||||
setRoomId={setRoomId}
|
||||
rooms={rooms}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const UnderSearchFormFilterIndicators: React.FC<{
|
||||
sourceKind: SourceKind | null;
|
||||
roomId: string | null;
|
||||
setSourceKind: (sourceKind: SourceKind | null) => void;
|
||||
setRoomId: (roomId: string | null) => void;
|
||||
rooms: Room[];
|
||||
}> = ({ sourceKind, roomId, setRoomId, setSourceKind, rooms }) => {
|
||||
return (
|
||||
<>
|
||||
{(sourceKind || roomId) && (
|
||||
<Flex gap={2} flexWrap="wrap" align="center">
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
Active filters:
|
||||
</Text>
|
||||
{sourceKind && (
|
||||
<Flex
|
||||
align="center"
|
||||
px={2}
|
||||
py={1}
|
||||
bg="blue.100"
|
||||
borderRadius="md"
|
||||
fontSize="xs"
|
||||
gap={1}
|
||||
>
|
||||
<Text>
|
||||
{roomId
|
||||
? `Room: ${
|
||||
rooms.find((r) => r.id === roomId)?.name || roomId
|
||||
}`
|
||||
: `Source: ${sourceKind}`}
|
||||
</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
minW="auto"
|
||||
h="auto"
|
||||
p="1px"
|
||||
onClick={() => {
|
||||
setSourceKind(null);
|
||||
// TODO questionable
|
||||
setRoomId(null);
|
||||
}}
|
||||
_hover={{ bg: "blue.200" }}
|
||||
aria-label="Clear filter"
|
||||
>
|
||||
<LuX size={14} />
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyResult: React.FC<{
|
||||
searchQuery: string;
|
||||
}> = ({ searchQuery }) => {
|
||||
return (
|
||||
<Flex flexDir="column" alignItems="center" justifyContent="center" py={8}>
|
||||
<Text textAlign="center">
|
||||
{searchQuery
|
||||
? `No results found for "${searchQuery}". Try adjusting your search terms.`
|
||||
: "No transcripts found, but you can "}
|
||||
{!searchQuery && (
|
||||
<>
|
||||
<Link href={RECORD_A_MEETING_URL} color="blue.500">
|
||||
record a meeting
|
||||
</Link>
|
||||
{" to get started."}
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default function TranscriptBrowser() {
|
||||
const [selectedSourceKind, setSelectedSourceKind] =
|
||||
useState<SourceKind | null>(null);
|
||||
const [selectedRoomId, setSelectedRoomId] = useState("");
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const { loading, response, refetch } = useTranscriptList(
|
||||
page,
|
||||
selectedSourceKind,
|
||||
selectedRoomId,
|
||||
searchTerm,
|
||||
const [urlSearchQuery, setUrlSearchQuery] = useQueryState(
|
||||
"q",
|
||||
parseAsString.withDefault("").withOptions({ shallow: false }),
|
||||
);
|
||||
|
||||
const [urlSourceKind, setUrlSourceKind] = useQueryState(
|
||||
"source",
|
||||
parseAsStringLiteral($SourceKind.enum).withOptions({
|
||||
shallow: false,
|
||||
}),
|
||||
);
|
||||
const [urlRoomId, setUrlRoomId] = useQueryState(
|
||||
"room",
|
||||
parseAsString.withDefault("").withOptions({ shallow: false }),
|
||||
);
|
||||
|
||||
const [urlPage, setPage] = useQueryState(
|
||||
"page",
|
||||
parseAsInteger.withDefault(1).withOptions({ shallow: false }),
|
||||
);
|
||||
|
||||
const [page, _setSafePage] = useState(FIRST_PAGE);
|
||||
|
||||
// safety net
|
||||
useEffect(() => {
|
||||
const maybePage = parsePaginationPage(urlPage);
|
||||
if ("error" in maybePage) {
|
||||
setPage(FIRST_PAGE).then(() => {
|
||||
/*may be called n times we dont care*/
|
||||
});
|
||||
return;
|
||||
}
|
||||
_setSafePage(maybePage.value);
|
||||
}, [urlPage]);
|
||||
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
|
||||
const pageSize = 20;
|
||||
const {
|
||||
results,
|
||||
totalCount: totalResults,
|
||||
isLoading,
|
||||
reload,
|
||||
} = useSearchTranscripts(
|
||||
urlSearchQuery,
|
||||
{
|
||||
roomIds: urlRoomId ? [urlRoomId] : null,
|
||||
sourceKind: urlSourceKind,
|
||||
},
|
||||
{
|
||||
pageSize,
|
||||
page,
|
||||
},
|
||||
);
|
||||
|
||||
const totalPages = getTotalPages(totalResults, pageSize);
|
||||
|
||||
const userName = useSessionUser().name;
|
||||
const [deletionLoading, setDeletionLoading] = useState(false);
|
||||
const api = useApi();
|
||||
@@ -35,37 +266,73 @@ export default function TranscriptBrowser() {
|
||||
const cancelRef = React.useRef(null);
|
||||
const [transcriptToDeleteId, setTranscriptToDeleteId] =
|
||||
React.useState<string>();
|
||||
const [deletedItemIds, setDeletedItemIds] = React.useState<string[]>();
|
||||
|
||||
useEffect(() => {
|
||||
setDeletedItemIds([]);
|
||||
}, [page, response]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
api
|
||||
.v1RoomsList({ page: 1 })
|
||||
.then((rooms) => setRooms(rooms.items))
|
||||
.catch((err) => setError(err, "There was an error fetching the rooms"));
|
||||
}, [api]);
|
||||
usePrefetchRooms(setRooms);
|
||||
|
||||
const handleFilterTranscripts = (
|
||||
sourceKind: SourceKind | null,
|
||||
roomId: string,
|
||||
) => {
|
||||
setSelectedSourceKind(sourceKind);
|
||||
setSelectedRoomId(roomId);
|
||||
setUrlSourceKind(sourceKind);
|
||||
setUrlRoomId(roomId);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSearch = (searchTerm: string) => {
|
||||
setPage(1);
|
||||
setSearchTerm(searchTerm);
|
||||
setSelectedSourceKind(null);
|
||||
setSelectedRoomId("");
|
||||
const onCloseDeletion = () => setTranscriptToDeleteId(undefined);
|
||||
|
||||
const confirmDeleteTranscript = (transcriptId: string) => {
|
||||
if (!api || deletionLoading) return;
|
||||
setDeletionLoading(true);
|
||||
api
|
||||
.v1TranscriptDelete({ transcriptId })
|
||||
.then(() => {
|
||||
setDeletionLoading(false);
|
||||
onCloseDeletion();
|
||||
reload();
|
||||
})
|
||||
.catch((err) => {
|
||||
setDeletionLoading(false);
|
||||
setError(err, "There was an error deleting the transcript");
|
||||
});
|
||||
};
|
||||
|
||||
if (loading && !response)
|
||||
const handleProcessTranscript = (transcriptId: string) => {
|
||||
if (!api) {
|
||||
console.error("API not available on handleProcessTranscript");
|
||||
return;
|
||||
}
|
||||
api
|
||||
.v1TranscriptProcess({ transcriptId })
|
||||
.then((result) => {
|
||||
const status =
|
||||
result && typeof result === "object" && "status" in result
|
||||
? (result as { status: string }).status
|
||||
: undefined;
|
||||
if (status === "already running") {
|
||||
setError(
|
||||
new Error("Processing is already running, please wait"),
|
||||
"Processing is already running, please wait",
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err, "There was an error processing the transcript");
|
||||
});
|
||||
};
|
||||
|
||||
const transcriptToDelete = results?.find(
|
||||
(i) => i.id === transcriptToDeleteId,
|
||||
);
|
||||
const dialogTitle = transcriptToDelete?.title || "Unnamed Transcript";
|
||||
const dialogDate = transcriptToDelete?.created_at
|
||||
? formatLocalDate(transcriptToDelete.created_at)
|
||||
: undefined;
|
||||
const dialogSource =
|
||||
transcriptToDelete?.source_kind === "room" && transcriptToDelete?.room_id
|
||||
? transcriptToDelete.room_name || transcriptToDelete.room_id
|
||||
: transcriptToDelete?.source_kind;
|
||||
|
||||
if (isLoading && results.length === 0) {
|
||||
return (
|
||||
<Flex
|
||||
flexDir="column"
|
||||
@@ -76,82 +343,7 @@ export default function TranscriptBrowser() {
|
||||
<Spinner size="xl" />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (!loading && !response)
|
||||
return (
|
||||
<Flex
|
||||
flexDir="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
h="100%"
|
||||
>
|
||||
<Text>
|
||||
No transcripts found, but you can
|
||||
<Link href="/transcripts/new" className="underline">
|
||||
record a meeting
|
||||
</Link>
|
||||
to get started.
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
const onCloseDeletion = () => setTranscriptToDeleteId(undefined);
|
||||
|
||||
const confirmDeleteTranscript = (transcriptId: string) => {
|
||||
if (!api || deletionLoading) return;
|
||||
setDeletionLoading(true);
|
||||
api
|
||||
.v1TranscriptDelete({ transcriptId })
|
||||
.then(() => {
|
||||
refetch();
|
||||
setDeletionLoading(false);
|
||||
onCloseDeletion();
|
||||
setDeletedItemIds((prev) =>
|
||||
prev ? [...prev, transcriptId] : [transcriptId],
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
setDeletionLoading(false);
|
||||
setError(err, "There was an error deleting the transcript");
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteTranscript = (transcriptId: string) => (e: any) => {
|
||||
e?.stopPropagation?.();
|
||||
setTranscriptToDeleteId(transcriptId);
|
||||
};
|
||||
|
||||
const handleProcessTranscript = (transcriptId) => (e) => {
|
||||
if (api) {
|
||||
api
|
||||
.v1TranscriptProcess({ transcriptId })
|
||||
.then((result) => {
|
||||
const status = (result as any).status;
|
||||
if (status === "already running") {
|
||||
setError(
|
||||
new Error("Processing is already running, please wait"),
|
||||
"Processing is already running, please wait",
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err, "There was an error processing the transcript");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const transcriptToDelete = response?.items?.find(
|
||||
(i) => i.id === transcriptToDeleteId,
|
||||
);
|
||||
const dialogTitle = transcriptToDelete?.title || "Unnamed Transcript";
|
||||
const dialogDate = transcriptToDelete?.created_at
|
||||
? formatLocalDate(transcriptToDelete.created_at)
|
||||
: undefined;
|
||||
const dialogSource = transcriptToDelete
|
||||
? transcriptToDelete.source_kind === "room"
|
||||
? transcriptToDelete.room_name || undefined
|
||||
: transcriptToDelete.source_kind
|
||||
: undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@@ -168,15 +360,15 @@ export default function TranscriptBrowser() {
|
||||
>
|
||||
<Heading size="lg">
|
||||
{userName ? `${userName}'s Transcriptions` : "Your Transcriptions"}{" "}
|
||||
{loading || (deletionLoading && <Spinner size="sm" />)}
|
||||
{(isLoading || deletionLoading) && <Spinner size="sm" />}
|
||||
</Heading>
|
||||
</Flex>
|
||||
|
||||
<Flex flexDir={{ base: "column", md: "row" }}>
|
||||
<FilterSidebar
|
||||
rooms={rooms}
|
||||
selectedSourceKind={selectedSourceKind}
|
||||
selectedRoomId={selectedRoomId}
|
||||
selectedSourceKind={urlSourceKind}
|
||||
selectedRoomId={urlRoomId}
|
||||
onFilterChange={handleFilterTranscripts}
|
||||
/>
|
||||
|
||||
@@ -188,25 +380,37 @@ export default function TranscriptBrowser() {
|
||||
gap={4}
|
||||
px={{ base: 0, md: 4 }}
|
||||
>
|
||||
<SearchBar onSearch={handleSearch} />
|
||||
<Pagination
|
||||
page={page}
|
||||
<SearchForm
|
||||
setPage={setPage}
|
||||
total={response?.total || 0}
|
||||
size={response?.size || 0}
|
||||
/>
|
||||
<TranscriptTable
|
||||
transcripts={response?.items || []}
|
||||
onDelete={handleDeleteTranscript}
|
||||
onReprocess={handleProcessTranscript}
|
||||
loading={loading}
|
||||
sourceKind={urlSourceKind}
|
||||
roomId={urlRoomId}
|
||||
searchQuery={urlSearchQuery}
|
||||
setSearchQuery={setUrlSearchQuery}
|
||||
setSourceKind={setUrlSourceKind}
|
||||
setRoomId={setUrlRoomId}
|
||||
rooms={rooms}
|
||||
/>
|
||||
|
||||
{totalPages > 1 ? (
|
||||
<Pagination
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
total={totalResults}
|
||||
size={pageSize}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<TranscriptCards
|
||||
transcripts={response?.items || []}
|
||||
onDelete={handleDeleteTranscript}
|
||||
results={results}
|
||||
query={urlSearchQuery}
|
||||
isLoading={isLoading}
|
||||
onDelete={setTranscriptToDeleteId}
|
||||
onReprocess={handleProcessTranscript}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{!isLoading && results.length === 0 && (
|
||||
<EmptyResult searchQuery={urlSearchQuery} />
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user