diff --git a/apps/client/src/features/search/components/ai-search-result.tsx b/apps/client/src/features/search/components/ai-search-result.tsx new file mode 100644 index 00000000..cf67fb0b --- /dev/null +++ b/apps/client/src/features/search/components/ai-search-result.tsx @@ -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 ( + + + + AI is thinking... + + + ); + } + + if (!result) { + return null; + } + + return ( + + + + + AI Answer + + + {result.answer} + + + + {deduplicatedSources.length > 0 && ( + + + Sources + + {deduplicatedSources.map((source) => ( + + + + + + {source.title} + + + + + ))} + + )} + + ); +} \ No newline at end of file diff --git a/apps/client/src/features/search/components/search-spotlight-filters.tsx b/apps/client/src/features/search/components/search-spotlight-filters.tsx index d8f770e7..6c3a2477 100644 --- a/apps/client/src/features/search/components/search-spotlight-filters.tsx +++ b/apps/client/src/features/search/components/search-spotlight-filters.tsx @@ -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 (
+ {hasLicenseKey && ( +
+ onAskClick()} + label="Ask AI" + size="sm" + color="blue" + labelPosition="left" + styles={{ + root: { display: "flex", alignItems: "center" }, + label: { paddingRight: "8px", fontSize: "13px", fontWeight: 500 } + }} + /> +
+ )} + ({ 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 ( <> - } - /> + + } + style={{ flex: 1 }} + onKeyDown={(e) => { + if (e.key === "Enter" && isAiMode && query.trim() && !isAiLoading) { + e.preventDefault(); + handleAiSearchTrigger(); + } + }} + /> + {isAiMode && hasLicenseKey && ( + + )} +
- {query.length === 0 && resultItems.length === 0 && ( - {t("Start typing to search...")} - )} + {isAiMode ? ( + <> + {query.length === 0 && ( + {t("Ask a question...")} + )} + {query.length > 0 && (isAiLoading || aiSearchResult) && ( + + )} + {query.length > 0 && !isAiLoading && !aiSearchResult && ( + {t("No answer available")} + )} + + ) : ( + <> + {query.length === 0 && resultItems.length === 0 && ( + {t("Start typing to search...")} + )} - {query.length > 0 && !isLoading && resultItems.length === 0 && ( - {t("No results found...")} - )} + {query.length > 0 && !isLoading && resultItems.length === 0 && ( + {t("No results found...")} + )} - {resultItems.length > 0 && <>{resultItems}} + {resultItems.length > 0 && <>{resultItems}} + + )}
diff --git a/apps/client/src/features/search/hooks/use-ai-search.ts b/apps/client/src/features/search/hooks/use-ai-search.ts new file mode 100644 index 00000000..6c70b428 --- /dev/null +++ b/apps/client/src/features/search/hooks/use-ai-search.ts @@ -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 { + 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 + }); +} \ No newline at end of file diff --git a/apps/client/src/features/search/hooks/use-unified-search.ts b/apps/client/src/features/search/hooks/use-unified-search.ts index 2adccc25..c2c5f43c 100644 --- a/apps/client/src/features/search/hooks/use-unified-search.ts +++ b/apps/client/src/features/search/hooks/use-unified-search.ts @@ -18,6 +18,7 @@ export interface UseUnifiedSearchParams extends IPageSearchParams { export function useUnifiedSearch( params: UseUnifiedSearchParams, + enabled: boolean = true, ): UseQueryResult { const { hasLicenseKey } = useLicense(); @@ -37,6 +38,6 @@ export function useUnifiedSearch( return await searchPage(backendParams); } }, - enabled: !!params.query, + enabled: !!params.query && enabled, }); } diff --git a/apps/client/src/features/search/services/ai-search-service.ts b/apps/client/src/features/search/services/ai-search-service.ts new file mode 100644 index 00000000..1610a48e --- /dev/null +++ b/apps/client/src/features/search/services/ai-search-service.ts @@ -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 { + const req = await api.post("/ai/ask", params); + return req.data; +} \ No newline at end of file diff --git a/apps/server/src/ee b/apps/server/src/ee index c1c5d992..6ee9fafe 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit c1c5d9920e547ca7abc2832c75dfa3b8b9e63e46 +Subproject commit 6ee9fafebd11fb3ccf35aa80ea25d33ded62e7de