mirror of
https://github.com/docmost/docmost.git
synced 2025-11-19 03:21:08 +10:00
WIP
This commit is contained in:
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import {
|
|||||||
ScrollArea,
|
ScrollArea,
|
||||||
Avatar,
|
Avatar,
|
||||||
Group,
|
Group,
|
||||||
|
Switch,
|
||||||
getDefaultZIndex,
|
getDefaultZIndex,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
@ -17,6 +18,7 @@ import {
|
|||||||
IconFileDescription,
|
IconFileDescription,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
|
IconSparkles,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
@ -26,12 +28,16 @@ import classes from "./search-spotlight-filters.module.css";
|
|||||||
|
|
||||||
interface SearchSpotlightFiltersProps {
|
interface SearchSpotlightFiltersProps {
|
||||||
onFiltersChange?: (filters: any) => void;
|
onFiltersChange?: (filters: any) => void;
|
||||||
|
onAskClick?: () => void;
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
|
isAiMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchSpotlightFilters({
|
export function SearchSpotlightFilters({
|
||||||
onFiltersChange,
|
onFiltersChange,
|
||||||
|
onAskClick,
|
||||||
spaceId,
|
spaceId,
|
||||||
|
isAiMode = false,
|
||||||
}: SearchSpotlightFiltersProps) {
|
}: SearchSpotlightFiltersProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { hasLicenseKey } = useLicense();
|
const { hasLicenseKey } = useLicense();
|
||||||
@ -119,6 +125,29 @@ export function SearchSpotlightFilters({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.filtersContainer}>
|
<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
|
<Menu
|
||||||
shadow="md"
|
shadow="md"
|
||||||
width={250}
|
width={250}
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
import { Spotlight } from "@mantine/spotlight";
|
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 React, { useState, useMemo } from "react";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { searchSpotlightStore } from "../constants.ts";
|
import { searchSpotlightStore } from "../constants.ts";
|
||||||
import { SearchSpotlightFilters } from "./search-spotlight-filters.tsx";
|
import { SearchSpotlightFilters } from "./search-spotlight-filters.tsx";
|
||||||
import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
|
import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
|
||||||
|
import { useAiSearch } from "../hooks/use-ai-search.ts";
|
||||||
import { SearchResultItem } from "./search-result-item.tsx";
|
import { SearchResultItem } from "./search-result-item.tsx";
|
||||||
|
import { AiSearchResult } from "./ai-search-result.tsx";
|
||||||
import { useLicense } from "@/ee/hooks/use-license.tsx";
|
import { useLicense } from "@/ee/hooks/use-license.tsx";
|
||||||
|
|
||||||
interface SearchSpotlightProps {
|
interface SearchSpotlightProps {
|
||||||
@ -23,6 +26,8 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
|||||||
}>({
|
}>({
|
||||||
contentType: "page",
|
contentType: "page",
|
||||||
});
|
});
|
||||||
|
const [isAiMode, setIsAiMode] = useState(false);
|
||||||
|
const [triggerAiSearch, setTriggerAiSearch] = useState(false);
|
||||||
|
|
||||||
// Build unified search params
|
// Build unified search params
|
||||||
const searchParams = useMemo(() => {
|
const searchParams = useMemo(() => {
|
||||||
@ -39,7 +44,14 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
|||||||
return params;
|
return params;
|
||||||
}, [debouncedSearchQuery, filters]);
|
}, [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
|
// Determine result type for rendering
|
||||||
const isAttachmentSearch =
|
const isAttachmentSearch =
|
||||||
@ -58,6 +70,20 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
|||||||
setFilters(newFilters);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Spotlight.Root
|
<Spotlight.Root
|
||||||
@ -71,10 +97,30 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
|||||||
backgroundOpacity: 0.55,
|
backgroundOpacity: 0.55,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Spotlight.Search
|
<Group gap="xs" px="sm" pt="sm" pb="xs">
|
||||||
placeholder={t("Search...")}
|
<Spotlight.Search
|
||||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -83,20 +129,41 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
|||||||
>
|
>
|
||||||
<SearchSpotlightFilters
|
<SearchSpotlightFilters
|
||||||
onFiltersChange={handleFiltersChange}
|
onFiltersChange={handleFiltersChange}
|
||||||
|
onAskClick={handleAskClick}
|
||||||
spaceId={spaceId}
|
spaceId={spaceId}
|
||||||
|
isAiMode={isAiMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Spotlight.ActionsList>
|
<Spotlight.ActionsList>
|
||||||
{query.length === 0 && resultItems.length === 0 && (
|
{isAiMode ? (
|
||||||
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
|
<>
|
||||||
)}
|
{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 && (
|
{query.length > 0 && !isLoading && resultItems.length === 0 && (
|
||||||
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
|
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{resultItems.length > 0 && <>{resultItems}</>}
|
{resultItems.length > 0 && <>{resultItems}</>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Spotlight.ActionsList>
|
</Spotlight.ActionsList>
|
||||||
</Spotlight.Root>
|
</Spotlight.Root>
|
||||||
</>
|
</>
|
||||||
|
|||||||
21
apps/client/src/features/search/hooks/use-ai-search.ts
Normal file
21
apps/client/src/features/search/hooks/use-ai-search.ts
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -18,6 +18,7 @@ export interface UseUnifiedSearchParams extends IPageSearchParams {
|
|||||||
|
|
||||||
export function useUnifiedSearch(
|
export function useUnifiedSearch(
|
||||||
params: UseUnifiedSearchParams,
|
params: UseUnifiedSearchParams,
|
||||||
|
enabled: boolean = true,
|
||||||
): UseQueryResult<UnifiedSearchResult[], Error> {
|
): UseQueryResult<UnifiedSearchResult[], Error> {
|
||||||
const { hasLicenseKey } = useLicense();
|
const { hasLicenseKey } = useLicense();
|
||||||
|
|
||||||
@ -37,6 +38,6 @@ export function useUnifiedSearch(
|
|||||||
return await searchPage(backendParams);
|
return await searchPage(backendParams);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled: !!params.query,
|
enabled: !!params.query && enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
Submodule apps/server/src/ee updated: c1c5d9920e...6ee9fafebd
Reference in New Issue
Block a user