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,
|
||||
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}
|
||||
|
||||
@ -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,
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" px="sm" pt="sm" pb="xs">
|
||||
<Spotlight.Search
|
||||
placeholder={t("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,11 +129,30 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
>
|
||||
<SearchSpotlightFilters
|
||||
onFiltersChange={handleFiltersChange}
|
||||
onAskClick={handleAskClick}
|
||||
spaceId={spaceId}
|
||||
isAiMode={isAiMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Spotlight.ActionsList>
|
||||
{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>
|
||||
)}
|
||||
@ -97,6 +162,8 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
)}
|
||||
|
||||
{resultItems.length > 0 && <>{resultItems}</>}
|
||||
</>
|
||||
)}
|
||||
</Spotlight.ActionsList>
|
||||
</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(
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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