mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat(EE): AI vector search (#1691)
* WIP * AI module - init * WIP * sync * WIP * refactor naming * new columns * sync * sync * fix search bug * stream response * WIP * feat embeddings sync * refine * Add workspaceId to page events * refine * WIP * add translation string * sync * reset ai answer on query change * hide AI search in cloud * capture streaming error * sync
This commit is contained in:
@@ -57,7 +57,7 @@
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||
"zod": "^3.25.56"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.16.0",
|
||||
|
||||
@@ -555,6 +555,18 @@
|
||||
"This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.",
|
||||
"Update API key": "Update API key",
|
||||
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace",
|
||||
"AI settings": "AI settings",
|
||||
"AI search": "AI search",
|
||||
"AI Answer": "AI Answer",
|
||||
"Ask AI": "Ask AI",
|
||||
"AI is thinking...": "AI is thinking...",
|
||||
"Ask a question...": "Ask a question...",
|
||||
"AI-powered search (Ask AI)": "AI-powered search (Ask AI)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
|
||||
"Toggle AI search": "Toggle AI search",
|
||||
"Sources": "Sources",
|
||||
"Ask AI not available for attachments": "Ask AI not available for attachments",
|
||||
"No answer available": "No answer available",
|
||||
"Background color": "Background color",
|
||||
"Highlight color": "Highlight color",
|
||||
"Remove color": "Remove color"
|
||||
|
||||
@@ -37,6 +37,7 @@ import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
|
||||
import SpaceTrash from "@/pages/space/space-trash.tsx";
|
||||
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
|
||||
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
|
||||
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
@@ -107,6 +108,7 @@ export default function App() {
|
||||
<Route path={"spaces"} element={<Spaces />} />
|
||||
<Route path={"sharing"} element={<Shares />} />
|
||||
<Route path={"security"} element={<Security />} />
|
||||
<Route path={"ai"} element={<AiSettings />} />
|
||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||
</Route>
|
||||
|
||||
@@ -12,13 +12,14 @@ import {
|
||||
IconLock,
|
||||
IconKey,
|
||||
IconWorld,
|
||||
IconSparkles,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import classes from "./settings.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useAtom } from "jotai/index";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import {
|
||||
prefetchApiKeyManagement,
|
||||
@@ -109,6 +110,13 @@ const groupedData: DataGroup[] = [
|
||||
isAdmin: true,
|
||||
showDisabledInNonEE: true,
|
||||
},
|
||||
{
|
||||
label: "AI settings",
|
||||
icon: IconSparkles,
|
||||
path: "/settings/ai",
|
||||
isAdmin: true,
|
||||
isSelfhosted: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
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.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { markdownToHtml } from "@docmost/editor-ext";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface AiSearchResultProps {
|
||||
result?: IAiSearchResponse;
|
||||
isLoading?: boolean;
|
||||
streamingAnswer?: string;
|
||||
streamingSources?: any[];
|
||||
}
|
||||
|
||||
export function AiSearchResult({
|
||||
result,
|
||||
isLoading,
|
||||
streamingAnswer = "",
|
||||
streamingSources = [],
|
||||
}: AiSearchResultProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Use streaming data if available, otherwise fall back to result
|
||||
const answer = streamingAnswer || result?.answer || "";
|
||||
const sources =
|
||||
streamingSources.length > 0 ? streamingSources : result?.sources || [];
|
||||
|
||||
// Deduplicate sources by pageId, keeping the one with highest similarity
|
||||
const deduplicatedSources = useMemo(() => {
|
||||
if (!sources || sources.length === 0) return [];
|
||||
|
||||
const pageMap = new Map();
|
||||
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());
|
||||
}, [sources]);
|
||||
|
||||
if (isLoading && !answer) {
|
||||
return (
|
||||
<Paper p="md" radius="md" withBorder>
|
||||
<Group>
|
||||
<Loader size="sm" />
|
||||
<Text size="sm">{t("AI is thinking...")}</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!answer && !isLoading) {
|
||||
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">
|
||||
{t("AI Answer")}
|
||||
</Text>
|
||||
{isLoading && <Loader size="xs" />}
|
||||
</Group>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(markdownToHtml(answer) as string),
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
{deduplicatedSources.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import useLicense from "@/ee/hooks/use-license.tsx";
|
||||
|
||||
export default function EnableAiSearch() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">{t("AI-powered search (Ask AI)")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<AiSearchToggle />
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface AiSearchToggleProps {
|
||||
size?: MantineSize;
|
||||
label?: string;
|
||||
}
|
||||
export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [checked, setChecked] = useState(workspace?.settings?.ai?.search);
|
||||
const { hasLicenseKey } = useLicense();
|
||||
|
||||
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
try {
|
||||
const updatedWorkspace = await updateWorkspace({ aiSearch: value });
|
||||
setChecked(value);
|
||||
setWorkspace(updatedWorkspace);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: err?.response?.data?.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch
|
||||
size={size}
|
||||
label={label}
|
||||
labelPosition="left"
|
||||
defaultChecked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={!hasAccess}
|
||||
aria-label={t("Toggle AI search")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useMutation, UseMutationResult } from "@tanstack/react-query";
|
||||
import { useState, useCallback } from "react";
|
||||
import { askAi, IAiSearchResponse } from "@/ee/ai/services/ai-search-service.ts";
|
||||
import { IPageSearchParams } from "@/features/search/types/search.types.ts";
|
||||
|
||||
// @ts-ignore
|
||||
interface UseAiSearchResult extends UseMutationResult<IAiSearchResponse, Error, IPageSearchParams> {
|
||||
streamingAnswer: string;
|
||||
streamingSources: any[];
|
||||
clearStreaming: () => void;
|
||||
}
|
||||
|
||||
export function useAiSearch(): UseAiSearchResult {
|
||||
const [streamingAnswer, setStreamingAnswer] = useState("");
|
||||
const [streamingSources, setStreamingSources] = useState<any[]>([]);
|
||||
|
||||
const clearStreaming = useCallback(() => {
|
||||
setStreamingAnswer("");
|
||||
setStreamingSources([]);
|
||||
}, []);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (params: IPageSearchParams & { contentType?: string }) => {
|
||||
setStreamingAnswer("");
|
||||
setStreamingSources([]);
|
||||
|
||||
const { contentType, ...apiParams } = params;
|
||||
|
||||
return await askAi(apiParams, (chunk) => {
|
||||
if (chunk.content) {
|
||||
setStreamingAnswer((prev) => prev + chunk.content);
|
||||
}
|
||||
if (chunk.sources) {
|
||||
setStreamingSources(chunk.sources);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...mutation,
|
||||
streamingAnswer,
|
||||
streamingSources,
|
||||
clearStreaming,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useAiGenerateStreamMutation } from "@/ee/ai/queries/ai-query.ts";
|
||||
import { AiGenerateDto } from "@/ee/ai/types/ai.types.ts";
|
||||
|
||||
export function useAiStream() {
|
||||
const [content, setContent] = useState("");
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const mutation = useAiGenerateStreamMutation();
|
||||
|
||||
const startStream = useCallback(
|
||||
async (data: AiGenerateDto) => {
|
||||
setContent("");
|
||||
setIsStreaming(true);
|
||||
|
||||
try {
|
||||
const controller = await mutation.mutateAsync({
|
||||
...data,
|
||||
onChunk: (chunk) => {
|
||||
setContent((prev) => prev + chunk.content);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("AI stream error:", error);
|
||||
setIsStreaming(false);
|
||||
},
|
||||
onComplete: () => {
|
||||
setIsStreaming(false);
|
||||
},
|
||||
});
|
||||
|
||||
abortControllerRef.current = controller;
|
||||
} catch (error) {
|
||||
console.error("Failed to start stream:", error);
|
||||
setIsStreaming(false);
|
||||
}
|
||||
},
|
||||
[mutation]
|
||||
);
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
setIsStreaming(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetContent = useCallback(() => {
|
||||
setContent("");
|
||||
}, []);
|
||||
|
||||
return {
|
||||
content,
|
||||
isStreaming,
|
||||
startStream,
|
||||
stopStream,
|
||||
resetContent,
|
||||
isLoading: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { getAppName, isCloud } from "@/lib/config.ts";
|
||||
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||
import React from "react";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLicense from "@/ee/hooks/use-license.tsx";
|
||||
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
|
||||
import { Alert } from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
export default function AiSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = useUserRole();
|
||||
const { hasLicenseKey } = useLicense();
|
||||
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>AI - {getAppName()}</title>
|
||||
</Helmet>
|
||||
<SettingsTitle title={t("AI settings")} />
|
||||
|
||||
{!hasAccess && (
|
||||
<Alert
|
||||
icon={<IconInfoCircle />}
|
||||
title={t("Enterprise feature")}
|
||||
color="blue"
|
||||
mb="lg"
|
||||
>
|
||||
{t(
|
||||
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<EnableAiSearch />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
useMutation,
|
||||
UseMutationResult,
|
||||
useQuery,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
generateAiContent,
|
||||
generateAiContentStream,
|
||||
} from "@/ee/ai/services/ai-service.ts";
|
||||
import {
|
||||
AiConfigResponse,
|
||||
AiContentResponse,
|
||||
AiGenerateDto,
|
||||
AiStreamChunk,
|
||||
AiStreamError,
|
||||
} from "@/ee/ai/types/ai.types.ts";
|
||||
|
||||
export function useAiGenerateMutation(): UseMutationResult<
|
||||
AiContentResponse,
|
||||
Error,
|
||||
AiGenerateDto
|
||||
> {
|
||||
return useMutation({
|
||||
mutationFn: (data: AiGenerateDto) => generateAiContent(data),
|
||||
});
|
||||
}
|
||||
|
||||
interface StreamCallbacks {
|
||||
onChunk: (chunk: AiStreamChunk) => void;
|
||||
onError?: (error: AiStreamError) => void;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export function useAiGenerateStreamMutation(): UseMutationResult<
|
||||
AbortController,
|
||||
Error,
|
||||
AiGenerateDto & StreamCallbacks
|
||||
> {
|
||||
return useMutation({
|
||||
mutationFn: ({ onChunk, onError, onComplete, ...data }) =>
|
||||
generateAiContentStream(data, onChunk, onError, onComplete),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import api from "@/lib/api-client.ts";
|
||||
import { IPageSearchParams } from "@/features/search/types/search.types.ts";
|
||||
|
||||
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,
|
||||
onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
|
||||
): Promise<IAiSearchResponse> {
|
||||
const response = await fetch("/api/ai/ask", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
let answer = "";
|
||||
let sources: any[] = [];
|
||||
|
||||
if (reader) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6);
|
||||
if (data === "[DONE]") break;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (parsed.error) {
|
||||
throw new Error(parsed.error);
|
||||
}
|
||||
if (parsed.content) {
|
||||
answer += parsed.content;
|
||||
onChunk?.({ content: parsed.content });
|
||||
}
|
||||
if (parsed.sources) {
|
||||
sources = parsed.sources;
|
||||
onChunk?.({ sources: parsed.sources });
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
throw e;
|
||||
}
|
||||
// Skip invalid JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { answer, sources };
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import api from "@/lib/api-client.ts";
|
||||
import {
|
||||
AiGenerateDto,
|
||||
AiContentResponse,
|
||||
AiStreamChunk,
|
||||
AiStreamError,
|
||||
} from "@/ee/ai/types/ai.types.ts";
|
||||
|
||||
export async function generateAiContent(
|
||||
data: AiGenerateDto,
|
||||
): Promise<AiContentResponse> {
|
||||
const req = await api.post<AiContentResponse>("/ai/generate", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function generateAiContentStream(
|
||||
data: AiGenerateDto,
|
||||
onChunk: (chunk: AiStreamChunk) => void,
|
||||
onError?: (error: AiStreamError) => void,
|
||||
onComplete?: () => void,
|
||||
): Promise<AbortController> {
|
||||
const abortController = new AbortController();
|
||||
try {
|
||||
const response = await fetch("/api/ai/generate/stream", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
signal: abortController.signal,
|
||||
credentials: "include", // This ensures cookies are sent, matching axios withCredentials
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (!reader) {
|
||||
throw new Error("Response body is not readable");
|
||||
}
|
||||
|
||||
const processStream = async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6);
|
||||
if (data === "[DONE]") {
|
||||
onComplete?.();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (parsed.error) {
|
||||
onError?.(parsed);
|
||||
} else {
|
||||
onChunk(parsed);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors for incomplete chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name !== "AbortError") {
|
||||
onError?.({ error: error.message });
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
};
|
||||
|
||||
processStream();
|
||||
} catch (error) {
|
||||
onError?.({ error: error.message });
|
||||
}
|
||||
|
||||
return abortController;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export enum AiAction {
|
||||
IMPROVE_WRITING = "improve_writing",
|
||||
FIX_SPELLING_GRAMMAR = "fix_spelling_grammar",
|
||||
MAKE_SHORTER = "make_shorter",
|
||||
MAKE_LONGER = "make_longer",
|
||||
SIMPLIFY = "simplify",
|
||||
CHANGE_TONE = "change_tone",
|
||||
SUMMARIZE = "summarize",
|
||||
CONTINUE_WRITING = "continue_writing",
|
||||
TRANSLATE = "translate",
|
||||
CUSTOM = "custom",
|
||||
}
|
||||
|
||||
export interface AiGenerateDto {
|
||||
action?: AiAction;
|
||||
content: string;
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
export interface AiContentResponse {
|
||||
content: string;
|
||||
usage?: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AiConfigResponse {
|
||||
configured: boolean;
|
||||
availableActions: AiAction[];
|
||||
}
|
||||
|
||||
export interface AiStreamChunk {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface AiStreamError {
|
||||
error: string;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export default function OssDetails() {
|
||||
withTableBorder
|
||||
>
|
||||
<Table.Caption>
|
||||
To unlock enterprise features like SSO, MFA, Resolve comments, contact sales@docmost.com.
|
||||
To unlock enterprise features like AI, SSO, MFA, Resolve comments, contact sales@docmost.com.
|
||||
</Table.Caption>
|
||||
<Table.Tbody>
|
||||
<Table.Tr>
|
||||
|
||||
@@ -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";
|
||||
@@ -24,15 +26,21 @@ import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||
import { useLicense } from "@/ee/hooks/use-license";
|
||||
import classes from "./search-spotlight-filters.module.css";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import { useAtom } from "jotai/index";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
|
||||
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();
|
||||
@@ -42,6 +50,7 @@ export function SearchSpotlightFilters({
|
||||
const [spaceSearchQuery, setSpaceSearchQuery] = useState("");
|
||||
const [debouncedSpaceQuery] = useDebouncedValue(spaceSearchQuery, 300);
|
||||
const [contentType, setContentType] = useState<string | null>("page");
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
|
||||
const { data: spacesData } = useGetSpacesQuery({
|
||||
page: 1,
|
||||
@@ -120,6 +129,31 @@ export function SearchSpotlightFilters({
|
||||
|
||||
return (
|
||||
<div className={classes.filtersContainer}>
|
||||
{workspace?.settings?.ai?.search === true && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
height: "32px",
|
||||
paddingLeft: "8px",
|
||||
paddingRight: "8px",
|
||||
}}
|
||||
>
|
||||
<Switch
|
||||
checked={isAiMode}
|
||||
onChange={(event) => onAskClick()}
|
||||
label={t("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}
|
||||
@@ -231,7 +265,7 @@ export function SearchSpotlightFilters({
|
||||
contentType !== option.value &&
|
||||
handleFilterChange("contentType", option.value)
|
||||
}
|
||||
disabled={option.disabled}
|
||||
disabled={option.disabled || (isAiMode && option.value === "attachment")}
|
||||
>
|
||||
<Group flex="1" gap="xs">
|
||||
<div>
|
||||
@@ -241,6 +275,11 @@ export function SearchSpotlightFilters({
|
||||
{t("Enterprise")}
|
||||
</Badge>
|
||||
)}
|
||||
{!option.disabled && isAiMode && option.value === "attachment" && (
|
||||
<Text size="xs" mt={4}>
|
||||
{t("Ask AI not available for attachments")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{contentType === option.value && <IconCheck size={20} />}
|
||||
</Group>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Spotlight } from "@mantine/spotlight";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { IconSearch, IconSparkles } from "@tabler/icons-react";
|
||||
import { Group, Button } from "@mantine/core";
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { searchSpotlightStore } from "../constants.ts";
|
||||
import { SearchSpotlightFilters } from "./search-spotlight-filters.tsx";
|
||||
import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
|
||||
import { useAiSearch } from "../../../ee/ai/hooks/use-ai-search.ts";
|
||||
import { SearchResultItem } from "./search-result-item.tsx";
|
||||
import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx";
|
||||
import { useLicense } from "@/ee/hooks/use-license.tsx";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
|
||||
@@ -24,6 +28,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
}>({
|
||||
contentType: "page",
|
||||
});
|
||||
const [isAiMode, setIsAiMode] = useState(false);
|
||||
|
||||
// Build unified search params
|
||||
const searchParams = useMemo(() => {
|
||||
@@ -40,7 +45,42 @@ 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 {
|
||||
//@ts-ignore
|
||||
data: aiSearchResult,
|
||||
//@ts-ignore
|
||||
isPending: isAiLoading,
|
||||
//@ts-ignore
|
||||
mutate: triggerAiSearchMutation,
|
||||
//@ts-ignore
|
||||
reset: resetAiMutation,
|
||||
//@ts-ignore
|
||||
error: aiSearchError,
|
||||
streamingAnswer,
|
||||
streamingSources,
|
||||
clearStreaming,
|
||||
} = useAiSearch();
|
||||
|
||||
// Clear streaming state and mutation data when query changes (user is typing a new query)
|
||||
useEffect(() => {
|
||||
clearStreaming();
|
||||
resetAiMutation();
|
||||
}, [query, clearStreaming, resetAiMutation]);
|
||||
|
||||
// Show error notification when AI search fails
|
||||
useEffect(() => {
|
||||
if (aiSearchError) {
|
||||
notifications.show({
|
||||
message: aiSearchError.message || t("AI search failed. Please try again."),
|
||||
color: "red",
|
||||
position: "top-center"
|
||||
});
|
||||
}
|
||||
}, [aiSearchError, t]);
|
||||
|
||||
// Determine result type for rendering
|
||||
const isAttachmentSearch =
|
||||
@@ -59,6 +99,16 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
setFilters(newFilters);
|
||||
};
|
||||
|
||||
const handleAskClick = () => {
|
||||
setIsAiMode(!isAiMode);
|
||||
};
|
||||
|
||||
const handleAiSearchTrigger = () => {
|
||||
if (query.trim() && isAiMode) {
|
||||
triggerAiSearchMutation(searchParams);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spotlight.Root
|
||||
@@ -72,10 +122,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={{
|
||||
@@ -84,20 +154,43 @@ 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 || streamingAnswer) && (
|
||||
<AiSearchResult
|
||||
result={aiSearchResult}
|
||||
isLoading={isAiLoading}
|
||||
streamingAnswer={streamingAnswer}
|
||||
streamingSources={streamingSources}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
</>
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface UseUnifiedSearchParams extends IPageSearchParams {
|
||||
|
||||
export function useUnifiedSearch(
|
||||
params: UseUnifiedSearchParams,
|
||||
enabled: boolean = true,
|
||||
): UseQueryResult<UnifiedSearchResult[], Error> {
|
||||
const { hasLicenseKey } = useLicense();
|
||||
|
||||
@@ -38,6 +39,6 @@ export function useUnifiedSearch(
|
||||
return await searchPage(backendParams);
|
||||
}
|
||||
},
|
||||
enabled: !!params.query,
|
||||
enabled: !!params.query && enabled,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function deleteWorkspaceMember(data: {
|
||||
await api.post("/workspace/members/delete", data);
|
||||
}
|
||||
|
||||
export async function updateWorkspace(data: Partial<IWorkspace>) {
|
||||
export async function updateWorkspace(data: Partial<IWorkspace> & { aiSearch?: boolean }) {
|
||||
const req = await api.post<IWorkspace>("/workspace/update", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface IWorkspace {
|
||||
defaultSpaceId: string;
|
||||
customDomain: string;
|
||||
enableInvite: boolean;
|
||||
settings: any;
|
||||
settings: IWorkspaceSettings;
|
||||
status: string;
|
||||
enforceSso: boolean;
|
||||
stripeCustomerId: string;
|
||||
@@ -24,6 +24,14 @@ export interface IWorkspace {
|
||||
enforceMfa?: boolean;
|
||||
}
|
||||
|
||||
export interface IWorkspaceSettings {
|
||||
ai?: IWorkspaceAiSettings;
|
||||
}
|
||||
|
||||
export interface IWorkspaceAiSettings {
|
||||
search?: boolean;
|
||||
}
|
||||
|
||||
export interface ICreateInvite {
|
||||
role: string;
|
||||
emails: string[];
|
||||
|
||||
@@ -51,7 +51,7 @@ root.render(
|
||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||
<ModalsProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Notifications position="bottom-center" limit={3} />
|
||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||
<HelmetProvider>
|
||||
<PostHogProvider client={posthog}>
|
||||
<App />
|
||||
|
||||
Reference in New Issue
Block a user