diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 36985540..e15dfecf 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -554,5 +554,16 @@ "Select expiration date": "Select expiration date", "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" + "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" } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 7048f08a..e0df67a7 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -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() { } /> } /> } /> + } /> {!isCloud() && } />} {isCloud() && } />} diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index abd3f962..32b91ed5 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -12,6 +12,7 @@ import { IconLock, IconKey, IconWorld, + IconSparkles, } from "@tabler/icons-react"; import { Link, useLocation } from "react-router-dom"; import classes from "./settings.module.css"; @@ -109,6 +110,12 @@ const groupedData: DataGroup[] = [ isAdmin: true, showDisabledInNonEE: true, }, + { + label: "AI settings", + icon: IconSparkles, + path: "/settings/ai", + isAdmin: true, + }, ], }, { diff --git a/apps/client/src/features/search/components/ai-search-result.tsx b/apps/client/src/ee/ai/components/ai-search-result.tsx similarity index 91% rename from apps/client/src/features/search/components/ai-search-result.tsx rename to apps/client/src/ee/ai/components/ai-search-result.tsx index 928cb1e1..f082f25a 100644 --- a/apps/client/src/features/search/components/ai-search-result.tsx +++ b/apps/client/src/ee/ai/components/ai-search-result.tsx @@ -2,10 +2,11 @@ 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"; +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; @@ -20,6 +21,8 @@ export function AiSearchResult({ streamingAnswer = "", streamingSources = [], }: AiSearchResultProps) { + const { t } = useTranslation(); + // Use streaming data if available, otherwise fall back to result const answer = streamingAnswer || result?.answer || ""; const sources = @@ -45,7 +48,7 @@ export function AiSearchResult({ - AI is thinking... + {t("AI is thinking...")} ); @@ -61,7 +64,7 @@ export function AiSearchResult({ - AI Answer + {t("AI Answer")} {isLoading && } @@ -75,7 +78,7 @@ export function AiSearchResult({ {deduplicatedSources.length > 0 && ( - Sources + {t("Sources")} {deduplicatedSources.map((source) => ( + +
+ {t("AI-powered search (Ask AI)")} + + {t( + "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.", + )} + +
+ + +
+ + ); +} + +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) => { + 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 ( + + ); +} diff --git a/apps/client/src/features/search/hooks/use-ai-search.ts b/apps/client/src/ee/ai/hooks/use-ai-search.ts similarity index 91% rename from apps/client/src/features/search/hooks/use-ai-search.ts rename to apps/client/src/ee/ai/hooks/use-ai-search.ts index 53ca6af5..852e4420 100644 --- a/apps/client/src/features/search/hooks/use-ai-search.ts +++ b/apps/client/src/ee/ai/hooks/use-ai-search.ts @@ -1,7 +1,7 @@ import { useMutation, UseMutationResult } from "@tanstack/react-query"; import { useState } from "react"; -import { askAi, IAiSearchResponse } from "@/features/search/services/ai-search-service"; -import { IPageSearchParams } from "@/features/search/types/search.types"; +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 { diff --git a/apps/client/src/ee/ai/pages/ai-settings.tsx b/apps/client/src/ee/ai/pages/ai-settings.tsx new file mode 100644 index 00000000..b9ab516d --- /dev/null +++ b/apps/client/src/ee/ai/pages/ai-settings.tsx @@ -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 ( + <> + + AI - {getAppName()} + + + + {!hasAccess && ( + } + title={t("Enterprise feature")} + color="blue" + mb="lg" + > + {t( + "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.", + )} + + )} + + + + ); +} diff --git a/apps/client/src/features/search/services/ai-search-service.ts b/apps/client/src/ee/ai/services/ai-search-service.ts similarity index 97% rename from apps/client/src/features/search/services/ai-search-service.ts rename to apps/client/src/ee/ai/services/ai-search-service.ts index 3cdb2815..06c09e8a 100644 --- a/apps/client/src/features/search/services/ai-search-service.ts +++ b/apps/client/src/ee/ai/services/ai-search-service.ts @@ -1,5 +1,5 @@ -import api from "@/lib/api-client"; -import { IPageSearchParams } from "@/features/search/types/search.types"; +import api from "@/lib/api-client.ts"; +import { IPageSearchParams } from "@/features/search/types/search.types.ts"; export interface IAiSearchResponse { answer: string; diff --git a/apps/client/src/ee/licence/components/oss-details.tsx b/apps/client/src/ee/licence/components/oss-details.tsx index 2856c5f8..5a3fd6c4 100644 --- a/apps/client/src/ee/licence/components/oss-details.tsx +++ b/apps/client/src/ee/licence/components/oss-details.tsx @@ -11,7 +11,7 @@ export default function OssDetails() { withTableBorder > - 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. 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 204d9570..87e03c7e 100644 --- a/apps/client/src/features/search/components/search-spotlight-filters.tsx +++ b/apps/client/src/features/search/components/search-spotlight-filters.tsx @@ -26,6 +26,8 @@ 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; @@ -48,6 +50,7 @@ export function SearchSpotlightFilters({ const [spaceSearchQuery, setSpaceSearchQuery] = useState(""); const [debouncedSpaceQuery] = useDebouncedValue(spaceSearchQuery, 300); const [contentType, setContentType] = useState("page"); + const [workspace] = useAtom(workspaceAtom); const { data: spacesData } = useGetSpacesQuery({ page: 1, @@ -126,24 +129,26 @@ export function SearchSpotlightFilters({ return (
- {hasLicenseKey && ( -
+ {workspace?.settings?.ai?.search === true && ( +
onAskClick()} - label="Ask AI" + label={t("Ask AI")} size="sm" color="blue" labelPosition="left" styles={{ root: { display: "flex", alignItems: "center" }, - label: { paddingRight: "8px", fontSize: "13px", fontWeight: 500 } + label: { paddingRight: "8px", fontSize: "13px", fontWeight: 500 }, }} />
@@ -260,7 +265,7 @@ export function SearchSpotlightFilters({ contentType !== option.value && handleFilterChange("contentType", option.value) } - disabled={option.disabled} + disabled={option.disabled || (isAiMode && option.value === "attachment")} >
@@ -270,6 +275,11 @@ export function SearchSpotlightFilters({ {t("Enterprise")} )} + {!option.disabled && isAiMode && option.value === "attachment" && ( + + {t("Ask AI not available for attachments")} + + )}
{contentType === option.value && }
diff --git a/apps/client/src/features/search/components/search-spotlight.tsx b/apps/client/src/features/search/components/search-spotlight.tsx index b5079879..9350e189 100644 --- a/apps/client/src/features/search/components/search-spotlight.tsx +++ b/apps/client/src/features/search/components/search-spotlight.tsx @@ -7,9 +7,9 @@ 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 { useAiSearch } from "../../../ee/ai/hooks/use-ai-search.ts"; import { SearchResultItem } from "./search-result-item.tsx"; -import { AiSearchResult } from "./ai-search-result.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"; diff --git a/apps/client/src/features/workspace/services/workspace-service.ts b/apps/client/src/features/workspace/services/workspace-service.ts index c8ac84a3..e47e2972 100644 --- a/apps/client/src/features/workspace/services/workspace-service.ts +++ b/apps/client/src/features/workspace/services/workspace-service.ts @@ -42,7 +42,7 @@ export async function deleteWorkspaceMember(data: { await api.post("/workspace/members/delete", data); } -export async function updateWorkspace(data: Partial) { +export async function updateWorkspace(data: Partial & { aiSearch?: boolean }) { const req = await api.post("/workspace/update", data); return req.data; } diff --git a/apps/client/src/features/workspace/types/workspace.types.ts b/apps/client/src/features/workspace/types/workspace.types.ts index 600641c9..f7d0b964 100644 --- a/apps/client/src/features/workspace/types/workspace.types.ts +++ b/apps/client/src/features/workspace/types/workspace.types.ts @@ -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[]; diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index ae7f556b..1a5e7f8d 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -33,6 +33,7 @@ import { InjectQueue } from '@nestjs/bullmq'; import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { Queue } from 'bullmq'; import { generateRandomSuffixNumbers } from '../../../common/helpers'; +import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers'; @Injectable() export class WorkspaceService { @@ -321,6 +322,13 @@ export class WorkspaceService { ); if (updateWorkspaceDto.aiSearch) { + const tableExists = await isPageEmbeddingsTableExists(this.db); + if (!tableExists) { + throw new BadRequestException( + 'Failed to activate. Make sure pgvector postgres extension is installed.', + ); + } + await this.aiQueue.add(QueueJob.WORKSPACE_CREATE_EMBEDDINGS, { workspaceId, }); diff --git a/apps/server/src/database/helpers/helpers.ts b/apps/server/src/database/helpers/helpers.ts new file mode 100644 index 00000000..076fc5de --- /dev/null +++ b/apps/server/src/database/helpers/helpers.ts @@ -0,0 +1,22 @@ +import { sql } from 'kysely'; +import { KyselyDB } from '@docmost/db/types/kysely.types'; + +export async function isPageEmbeddingsTableExists(db: KyselyDB) { + return tableExists({ db, tableName: 'page_embeddings' }); +} + +export async function tableExists(opts: { + db: KyselyDB; + tableName: string; +}): Promise { + const { db, tableName } = opts; + const result = await sql<{ exists: boolean }>` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = COALESCE(current_schema(), 'public') + AND table_name = ${tableName} + ) as exists + `.execute(db); + + return result.rows[0]?.exists ?? false; +} diff --git a/apps/server/src/ee b/apps/server/src/ee index 4de95306..d20586e3 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 4de95306c7a8d1393f6fa8952c137d52cd28fdfa +Subproject commit d20586e3eb741e5c51d7e92a8f20b6e7189e47ca