This commit is contained in:
Philipinho
2025-09-02 14:49:01 -07:00
parent 5d1c8bb2ff
commit ed07a23e63
7 changed files with 247 additions and 15 deletions

View File

@ -0,0 +1,91 @@
import React, { useMemo } from "react";
import { Paper, Text, Group, Stack, Loader, Box } from "@mantine/core";
import { IconSparkles, IconFileText } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { IAiSearchResponse } from "../services/ai-search-service";
import { buildPageUrl } from "@/features/page/page.utils";
interface AiSearchResultProps {
result: IAiSearchResponse;
isLoading?: boolean;
}
export function AiSearchResult({ result, isLoading }: AiSearchResultProps) {
// Deduplicate sources by pageId, keeping the one with highest similarity
const deduplicatedSources = useMemo(() => {
if (!result?.sources) return [];
const pageMap = new Map();
result.sources.forEach((source) => {
const existing = pageMap.get(source.pageId);
if (!existing || source.similarity > existing.similarity) {
pageMap.set(source.pageId, source);
}
});
return Array.from(pageMap.values());
}, [result?.sources]);
if (isLoading) {
return (
<Paper p="md" radius="md" withBorder>
<Group>
<Loader size="sm" />
<Text size="sm">AI is thinking...</Text>
</Group>
</Paper>
);
}
if (!result) {
return null;
}
return (
<Stack gap="md" p="md">
<Paper p="md" radius="md" withBorder>
<Group gap="xs" mb="sm">
<IconSparkles size={20} color="var(--mantine-color-blue-6)" />
<Text fw={600} size="sm">AI Answer</Text>
</Group>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{result.answer}
</Text>
</Paper>
{deduplicatedSources.length > 0 && (
<Stack gap="xs">
<Text size="xs" fw={600} c="dimmed">
Sources
</Text>
{deduplicatedSources.map((source) => (
<Box
key={source.pageId}
component={Link}
to={buildPageUrl(source.spaceSlug, source.slugId, source.title)}
style={{
textDecoration: "none",
color: "inherit",
display: "block"
}}
>
<Paper
p="xs"
radius="sm"
withBorder
style={{ cursor: "pointer" }}
>
<Group gap="xs">
<IconFileText size={16} />
<Text size="sm" truncate>
{source.title}
</Text>
</Group>
</Paper>
</Box>
))}
</Stack>
)}
</Stack>
);
}

View File

@ -9,6 +9,7 @@ import {
ScrollArea,
Avatar,
Group,
Switch,
getDefaultZIndex,
} from "@mantine/core";
import {
@ -17,6 +18,7 @@ import {
IconFileDescription,
IconSearch,
IconCheck,
IconSparkles,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useDebouncedValue } from "@mantine/hooks";
@ -26,12 +28,16 @@ import classes from "./search-spotlight-filters.module.css";
interface SearchSpotlightFiltersProps {
onFiltersChange?: (filters: any) => void;
onAskClick?: () => void;
spaceId?: string;
isAiMode?: boolean;
}
export function SearchSpotlightFilters({
onFiltersChange,
onAskClick,
spaceId,
isAiMode = false,
}: SearchSpotlightFiltersProps) {
const { t } = useTranslation();
const { hasLicenseKey } = useLicense();
@ -119,6 +125,29 @@ export function SearchSpotlightFilters({
return (
<div className={classes.filtersContainer}>
{hasLicenseKey && (
<div style={{
display: "flex",
alignItems: "center",
height: "32px",
paddingLeft: "8px",
paddingRight: "8px"
}}>
<Switch
checked={isAiMode}
onChange={(event) => onAskClick()}
label="Ask AI"
size="sm"
color="blue"
labelPosition="left"
styles={{
root: { display: "flex", alignItems: "center" },
label: { paddingRight: "8px", fontSize: "13px", fontWeight: 500 }
}}
/>
</div>
)}
<Menu
shadow="md"
width={250}

View File

@ -1,12 +1,15 @@
import { Spotlight } from "@mantine/spotlight";
import { IconSearch } from "@tabler/icons-react";
import { IconSearch, IconSparkles } from "@tabler/icons-react";
import { Group, Button } from "@mantine/core";
import React, { useState, useMemo } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import { searchSpotlightStore } from "../constants.ts";
import { SearchSpotlightFilters } from "./search-spotlight-filters.tsx";
import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
import { useAiSearch } from "../hooks/use-ai-search.ts";
import { SearchResultItem } from "./search-result-item.tsx";
import { AiSearchResult } from "./ai-search-result.tsx";
import { useLicense } from "@/ee/hooks/use-license.tsx";
interface SearchSpotlightProps {
@ -23,6 +26,8 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
}>({
contentType: "page",
});
const [isAiMode, setIsAiMode] = useState(false);
const [triggerAiSearch, setTriggerAiSearch] = useState(false);
// Build unified search params
const searchParams = useMemo(() => {
@ -39,7 +44,14 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
return params;
}, [debouncedSearchQuery, filters]);
const { data: searchResults, isLoading } = useUnifiedSearch(searchParams);
const { data: searchResults, isLoading } = useUnifiedSearch(
searchParams,
!isAiMode // Disable regular search when in AI mode
);
const { data: aiSearchResult, isLoading: isAiLoading, refetch: refetchAiSearch } = useAiSearch(
searchParams,
isAiMode && triggerAiSearch
);
// Determine result type for rendering
const isAttachmentSearch =
@ -58,6 +70,20 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
setFilters(newFilters);
};
const handleAskClick = () => {
const newMode = !isAiMode;
setIsAiMode(newMode);
// Reset AI search state when toggling modes
setTriggerAiSearch(false);
};
const handleAiSearchTrigger = () => {
if (query.trim() && isAiMode) {
setTriggerAiSearch(true);
refetchAiSearch();
}
};
return (
<>
<Spotlight.Root
@ -71,10 +97,30 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
backgroundOpacity: 0.55,
}}
>
<Spotlight.Search
placeholder={t("Search...")}
leftSection={<IconSearch size={20} stroke={1.5} />}
/>
<Group gap="xs" px="sm" pt="sm" pb="xs">
<Spotlight.Search
placeholder={isAiMode ? t("Ask a question...") : t("Search...")}
leftSection={<IconSearch size={20} stroke={1.5} />}
style={{ flex: 1 }}
onKeyDown={(e) => {
if (e.key === "Enter" && isAiMode && query.trim() && !isAiLoading) {
e.preventDefault();
handleAiSearchTrigger();
}
}}
/>
{isAiMode && hasLicenseKey && (
<Button
size="xs"
leftSection={<IconSparkles size={16} />}
onClick={handleAiSearchTrigger}
disabled={!query.trim()}
loading={isAiLoading}
>
Ask
</Button>
)}
</Group>
<div
style={{
@ -83,20 +129,41 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
>
<SearchSpotlightFilters
onFiltersChange={handleFiltersChange}
onAskClick={handleAskClick}
spaceId={spaceId}
isAiMode={isAiMode}
/>
</div>
<Spotlight.ActionsList>
{query.length === 0 && resultItems.length === 0 && (
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
)}
{isAiMode ? (
<>
{query.length === 0 && (
<Spotlight.Empty>{t("Ask a question...")}</Spotlight.Empty>
)}
{query.length > 0 && (isAiLoading || aiSearchResult) && (
<AiSearchResult
result={aiSearchResult}
isLoading={isAiLoading}
/>
)}
{query.length > 0 && !isAiLoading && !aiSearchResult && (
<Spotlight.Empty>{t("No answer available")}</Spotlight.Empty>
)}
</>
) : (
<>
{query.length === 0 && resultItems.length === 0 && (
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
)}
{query.length > 0 && !isLoading && resultItems.length === 0 && (
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{query.length > 0 && !isLoading && resultItems.length === 0 && (
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{resultItems.length > 0 && <>{resultItems}</>}
{resultItems.length > 0 && <>{resultItems}</>}
</>
)}
</Spotlight.ActionsList>
</Spotlight.Root>
</>

View File

@ -0,0 +1,21 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { askAi, IAiSearchResponse } from "@/features/search/services/ai-search-service";
import { IPageSearchParams } from "@/features/search/types/search.types";
import { useLicense } from "@/ee/hooks/use-license";
export function useAiSearch(
params: IPageSearchParams,
enabled: boolean = false,
): UseQueryResult<IAiSearchResponse, Error> {
const { hasLicenseKey } = useLicense();
return useQuery({
queryKey: ["ai-search", params],
queryFn: async () => {
return await askAi(params);
},
enabled: !!params.query && hasLicenseKey && enabled,
staleTime: Infinity, // Don't refetch automatically
gcTime: 0, // Don't cache results when component unmounts
});
}

View File

@ -18,6 +18,7 @@ export interface UseUnifiedSearchParams extends IPageSearchParams {
export function useUnifiedSearch(
params: UseUnifiedSearchParams,
enabled: boolean = true,
): UseQueryResult<UnifiedSearchResult[], Error> {
const { hasLicenseKey } = useLicense();
@ -37,6 +38,6 @@ export function useUnifiedSearch(
return await searchPage(backendParams);
}
},
enabled: !!params.query,
enabled: !!params.query && enabled,
});
}

View File

@ -0,0 +1,23 @@
import api from "@/lib/api-client";
import { IPageSearchParams } from '@/features/search/types/search.types';
export interface IAiSearchResponse {
answer: string;
sources?: Array<{
pageId: string;
title: string;
slugId: string;
spaceSlug: string;
similarity: number;
distance: number;
chunkIndex: number;
excerpt: string;
}>;
}
export async function askAi(
params: IPageSearchParams,
): Promise<IAiSearchResponse> {
const req = await api.post<IAiSearchResponse>("/ai/ask", params);
return req.data;
}