diff --git a/apps/client/package.json b/apps/client/package.json index 93e4da1c..071404e9 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -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", diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 38f5433a..8cb33378 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -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" 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..75e5a7af 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -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, + }, ], }, { diff --git a/apps/client/src/ee/ai/components/ai-search-result.tsx b/apps/client/src/ee/ai/components/ai-search-result.tsx new file mode 100644 index 00000000..f082f25a --- /dev/null +++ b/apps/client/src/ee/ai/components/ai-search-result.tsx @@ -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 ( + + + + {t("AI is thinking...")} + + + ); + } + + if (!answer && !isLoading) { + return null; + } + + return ( + + + + + + {t("AI Answer")} + + {isLoading && } + +
+ + + {deduplicatedSources.length > 0 && ( + + + {t("Sources")} + + {deduplicatedSources.map((source) => ( + + + + + + {source.title} + + + + + ))} + + )} + + ); +} diff --git a/apps/client/src/ee/ai/components/enable-ai-search.tsx b/apps/client/src/ee/ai/components/enable-ai-search.tsx new file mode 100644 index 00000000..53b0a9bd --- /dev/null +++ b/apps/client/src/ee/ai/components/enable-ai-search.tsx @@ -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 ( + <> + +
+ {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/ee/ai/hooks/use-ai-search.ts b/apps/client/src/ee/ai/hooks/use-ai-search.ts new file mode 100644 index 00000000..f9c5aa88 --- /dev/null +++ b/apps/client/src/ee/ai/hooks/use-ai-search.ts @@ -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 { + streamingAnswer: string; + streamingSources: any[]; + clearStreaming: () => void; +} + +export function useAiSearch(): UseAiSearchResult { + const [streamingAnswer, setStreamingAnswer] = useState(""); + const [streamingSources, setStreamingSources] = useState([]); + + 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, + }; +} diff --git a/apps/client/src/ee/ai/hooks/use-ai.ts b/apps/client/src/ee/ai/hooks/use-ai.ts new file mode 100644 index 00000000..40c1ca12 --- /dev/null +++ b/apps/client/src/ee/ai/hooks/use-ai.ts @@ -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(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, + }; +} \ No newline at end of file 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/ee/ai/queries/ai-query.ts b/apps/client/src/ee/ai/queries/ai-query.ts new file mode 100644 index 00000000..076de9c7 --- /dev/null +++ b/apps/client/src/ee/ai/queries/ai-query.ts @@ -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), + }); +} diff --git a/apps/client/src/ee/ai/services/ai-search-service.ts b/apps/client/src/ee/ai/services/ai-search-service.ts new file mode 100644 index 00000000..0254f5b2 --- /dev/null +++ b/apps/client/src/ee/ai/services/ai-search-service.ts @@ -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 { + 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 }; +} diff --git a/apps/client/src/ee/ai/services/ai-service.ts b/apps/client/src/ee/ai/services/ai-service.ts new file mode 100644 index 00000000..f3634d59 --- /dev/null +++ b/apps/client/src/ee/ai/services/ai-service.ts @@ -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 { + const req = await api.post("/ai/generate", data); + return req.data; +} + +export async function generateAiContentStream( + data: AiGenerateDto, + onChunk: (chunk: AiStreamChunk) => void, + onError?: (error: AiStreamError) => void, + onComplete?: () => void, +): Promise { + 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; +} diff --git a/apps/client/src/ee/ai/types/ai.types.ts b/apps/client/src/ee/ai/types/ai.types.ts new file mode 100644 index 00000000..a5fbc253 --- /dev/null +++ b/apps/client/src/ee/ai/types/ai.types.ts @@ -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; +} 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 fe0b9e7c..87e03c7e 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"; @@ -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("page"); + const [workspace] = useAtom(workspaceAtom); const { data: spacesData } = useGetSpacesQuery({ page: 1, @@ -120,6 +129,31 @@ export function SearchSpotlightFilters({ return (
+ {workspace?.settings?.ai?.search === true && ( +
+ onAskClick()} + label={t("Ask AI")} + size="sm" + color="blue" + labelPosition="left" + styles={{ + root: { display: "flex", alignItems: "center" }, + label: { paddingRight: "8px", fontSize: "13px", fontWeight: 500 }, + }} + /> +
+ )} +
@@ -241,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 045c7477..0351c1e2 100644 --- a/apps/client/src/features/search/components/search-spotlight.tsx +++ b/apps/client/src/features/search/components/search-spotlight.tsx @@ -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 ( <> - } - /> + + } + 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 || streamingAnswer) && ( + + )} + {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-unified-search.ts b/apps/client/src/features/search/hooks/use-unified-search.ts index dbc5563f..06663efd 100644 --- a/apps/client/src/features/search/hooks/use-unified-search.ts +++ b/apps/client/src/features/search/hooks/use-unified-search.ts @@ -19,6 +19,7 @@ export interface UseUnifiedSearchParams extends IPageSearchParams { export function useUnifiedSearch( params: UseUnifiedSearchParams, + enabled: boolean = true, ): UseQueryResult { const { hasLicenseKey } = useLicense(); @@ -38,6 +39,6 @@ export function useUnifiedSearch( return await searchPage(backendParams); } }, - enabled: !!params.query, + enabled: !!params.query && enabled, }); } 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/client/src/main.tsx b/apps/client/src/main.tsx index f084b089..63a775de 100644 --- a/apps/client/src/main.tsx +++ b/apps/client/src/main.tsx @@ -51,7 +51,7 @@ root.render( - + diff --git a/apps/server/package.json b/apps/server/package.json index 6eb14d65..b64a2a52 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -30,6 +30,9 @@ "test:e2e": "jest --config test/jest-e2e.json" }, "dependencies": { + "@ai-sdk/azure": "^2.0.47", + "@ai-sdk/google": "^2.0.18", + "@ai-sdk/openai": "^2.0.46", "@aws-sdk/client-s3": "3.701.0", "@aws-sdk/lib-storage": "3.701.0", "@aws-sdk/s3-request-presigner": "3.701.0", @@ -37,6 +40,7 @@ "@fastify/cookie": "^11.0.2", "@fastify/multipart": "^9.0.3", "@fastify/static": "^8.2.0", + "@langchain/textsplitters": "^0.1.0", "@nestjs-labs/nestjs-ioredis": "^11.0.4", "@nestjs/bullmq": "^11.0.4", "@nestjs/common": "^11.1.9", @@ -55,6 +59,8 @@ "@react-email/components": "0.0.28", "@react-email/render": "1.0.2", "@socket.io/redis-adapter": "^8.3.0", + "ai": "^5.0.65", + "ai-sdk-ollama": "^0.12.0", "bcrypt": "^6.0.0", "bullmq": "^5.65.0", "cache-manager": "^6.4.3", @@ -82,6 +88,7 @@ "pdfjs-dist": "^5.4.394", "pg": "^8.16.3", "pg-tsquery": "^8.4.2", + "pgvector": "^0.2.1", "postmark": "^4.0.5", "react": "^18.3.1", "reflect-metadata": "^0.2.2", diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts index 88284fd2..54c4a89e 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.ts @@ -35,6 +35,7 @@ export class PersistenceExtension implements Extension { @InjectKysely() private readonly db: KyselyDB, private eventEmitter: EventEmitter2, @InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue, + @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, ) {} async onLoadDocument(data: onLoadDocumentPayload) { @@ -168,6 +169,11 @@ export class PersistenceExtension implements Extension { workspaceId: page.workspaceId, mentions: pageMentions, } as IPageBacklinkJob); + + await this.aiQueue.add(QueueJob.PAGE_CONTENT_UPDATED, { + pageIds: [pageId], + workspaceId: page.workspaceId, + }); } } diff --git a/apps/server/src/common/events/event.contants.ts b/apps/server/src/common/events/event.contants.ts index 7adeb043..c766fe59 100644 --- a/apps/server/src/common/events/event.contants.ts +++ b/apps/server/src/common/events/event.contants.ts @@ -2,7 +2,17 @@ export enum EventName { COLLAB_PAGE_UPDATED = 'collab.page.updated', PAGE_CREATED = 'page.created', PAGE_UPDATED = 'page.updated', + PAGE_CONTENT_UPDATED = 'page-content-updated', + PAGE_MOVED_TO_SPACE = 'page-moved-to-space', PAGE_DELETED = 'page.deleted', PAGE_SOFT_DELETED = 'page.soft_deleted', PAGE_RESTORED = 'page.restored', + + SPACE_CREATED = 'space.created', + SPACE_UPDATED = 'space.updated', + SPACE_DELETED = 'space.deleted', + + WORKSPACE_CREATED = 'workspace.created', + WORKSPACE_UPDATED = 'workspace.updated', + WORKSPACE_DELETED = 'workspace.deleted', } diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index 450874f7..97275440 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -1,23 +1,23 @@ import { - Controller, - Post, + BadRequestException, Body, + Controller, + ForbiddenException, HttpCode, HttpStatus, - UseGuards, - ForbiddenException, NotFoundException, - BadRequestException, + Post, + UseGuards, } from '@nestjs/common'; import { PageService } from './services/page.service'; import { CreatePageDto } from './dto/create-page.dto'; import { UpdatePageDto } from './dto/update-page.dto'; import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto'; import { + DeletePageDto, PageHistoryIdDto, PageIdDto, PageInfoDto, - DeletePageDto, } from './dto/page.dto'; import { PageHistoryService } from './services/page-history.service'; import { AuthUser } from '../../common/decorators/auth-user.decorator'; @@ -106,7 +106,11 @@ export class PageController { @HttpCode(HttpStatus.OK) @Post('delete') - async delete(@Body() deletePageDto: DeletePageDto, @AuthUser() user: User) { + async delete( + @Body() deletePageDto: DeletePageDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { const page = await this.pageRepo.findById(deletePageDto.pageId); if (!page) { @@ -122,19 +126,27 @@ export class PageController { 'Only space admins can permanently delete pages', ); } - await this.pageService.forceDelete(deletePageDto.pageId); + await this.pageService.forceDelete(deletePageDto.pageId, workspace.id); } else { // Soft delete requires page manage permissions if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } - await this.pageService.remove(deletePageDto.pageId, user.id); + await this.pageService.removePage( + deletePageDto.pageId, + user.id, + workspace.id, + ); } } @HttpCode(HttpStatus.OK) @Post('restore') - async restore(@Body() pageIdDto: PageIdDto, @AuthUser() user: User) { + async restore( + @Body() pageIdDto: PageIdDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { const page = await this.pageRepo.findById(pageIdDto.pageId); if (!page) { @@ -146,13 +158,11 @@ export class PageController { throw new ForbiddenException(); } - await this.pageRepo.restorePage(pageIdDto.pageId); + await this.pageRepo.restorePage(pageIdDto.pageId, workspace.id); - // Return the restored page data with hasChildren info - const restoredPage = await this.pageRepo.findById(pageIdDto.pageId, { + return this.pageRepo.findById(pageIdDto.pageId, { includeHasChildren: true, }); - return restoredPage; } @HttpCode(HttpStatus.OK) diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index ee90e52e..9bfb5e1c 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -51,6 +51,7 @@ export class PageService { @InjectKysely() private readonly db: KyselyDB, private readonly storageService: StorageService, @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, + @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, private eventEmitter: EventEmitter2, ) {} @@ -255,6 +256,11 @@ export class PageService { pageIds, trx, ); + + await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, { + pageId: pageIds, + workspaceId: rootPage.workspaceId + }); } }); } @@ -393,6 +399,7 @@ export class PageService { const insertedPageIds = insertablePages.map((page) => page.id); this.eventEmitter.emit(EventName.PAGE_CREATED, { pageIds: insertedPageIds, + workspaceId: authUser.workspaceId, }); //TODO: best to handle this in a queue @@ -580,7 +587,7 @@ export class PageService { return await this.pageRepo.getDeletedPagesInSpace(spaceId, pagination); } - async forceDelete(pageId: string): Promise { + async forceDelete(pageId: string, workspaceId: string): Promise { // Get all descendant IDs (including the page itself) using recursive CTE const descendants = await this.db .withRecursive('page_descendants', (db) => @@ -623,11 +630,16 @@ export class PageService { await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute(); this.eventEmitter.emit(EventName.PAGE_DELETED, { pageIds: pageIds, + workspaceId, }); } } - async remove(pageId: string, userId: string): Promise { - await this.pageRepo.removePage(pageId, userId); + async removePage( + pageId: string, + userId: string, + workspaceId: string, + ): Promise { + await this.pageRepo.removePage(pageId, userId, workspaceId); } } diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index 0f8dbb90..29508797 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -62,7 +62,7 @@ export class SearchService { ) .where('deletedAt', 'is', null) .orderBy('rank', 'desc') - .limit(searchParams.limit | 20) + .limit(searchParams.limit | 25) .offset(searchParams.offset || 0); if (!searchParams.shareId) { diff --git a/apps/server/src/core/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts index 8e4df4e1..2b61c7ee 100644 --- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts +++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts @@ -22,4 +22,12 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) { @IsOptional() @IsBoolean() restrictApiToAdmins: boolean; + + @IsOptional() + @IsBoolean() + aiSearch: boolean; + + @IsOptional() + @IsBoolean() + generativeAi: boolean; } diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index a3db431e..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 { @@ -50,6 +51,7 @@ export class WorkspaceService { @InjectKysely() private readonly db: KyselyDB, @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, @InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue, + @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, ) {} async findById(workspaceId: string) { @@ -312,6 +314,51 @@ export class WorkspaceService { delete updateWorkspaceDto.restrictApiToAdmins; } + if (typeof updateWorkspaceDto.aiSearch !== 'undefined') { + await this.workspaceRepo.updateAiSettings( + workspaceId, + 'search', + updateWorkspaceDto.aiSearch, + ); + + 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, + }); + } else { + // Schedule deletion after 24 hours + const deleteJobId = `ai-search-disabled-${workspaceId}`; + await this.aiQueue.add( + QueueJob.WORKSPACE_DELETE_EMBEDDINGS, + { workspaceId }, + { + jobId: deleteJobId, + delay: 24 * 60 * 60 * 1000, + removeOnComplete: true, + removeOnFail: true, + }, + ); + } + + delete updateWorkspaceDto.aiSearch; + } + + if (typeof updateWorkspaceDto.generativeAi !== 'undefined') { + await this.workspaceRepo.updateAiSettings( + workspaceId, + 'generative', + updateWorkspaceDto.generativeAi, + ); + delete updateWorkspaceDto.generativeAi; + } + await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId); const workspace = await this.workspaceRepo.findById(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/database/listeners/page.listener.ts b/apps/server/src/database/listeners/page.listener.ts index 7e2d97e2..705fd102 100644 --- a/apps/server/src/database/listeners/page.listener.ts +++ b/apps/server/src/database/listeners/page.listener.ts @@ -4,9 +4,11 @@ import { EventName } from '../../common/events/event.contants'; import { InjectQueue } from '@nestjs/bullmq'; import { QueueJob, QueueName } from '../../integrations/queue/constants'; import { Queue } from 'bullmq'; +import { EnvironmentService } from '../../integrations/environment/environment.service'; export class PageEvent { pageIds: string[]; + workspaceId: string; } @Injectable() @@ -14,36 +16,65 @@ export class PageListener { private readonly logger = new Logger(PageListener.name); constructor( + private readonly environmentService: EnvironmentService, @InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue, + @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, ) {} @OnEvent(EventName.PAGE_CREATED) async handlePageCreated(event: PageEvent) { - const { pageIds } = event; - await this.searchQueue.add(QueueJob.PAGE_CREATED, { pageIds }); + const { pageIds, workspaceId } = event; + if (this.isTypesense()) { + await this.searchQueue.add(QueueJob.PAGE_CREATED, { + pageIds, + }); + } + + await this.aiQueue.add(QueueJob.PAGE_CREATED, { pageIds, workspaceId }); } @OnEvent(EventName.PAGE_UPDATED) async handlePageUpdated(event: PageEvent) { const { pageIds } = event; + await this.searchQueue.add(QueueJob.PAGE_UPDATED, { pageIds }); } @OnEvent(EventName.PAGE_DELETED) async handlePageDeleted(event: PageEvent) { - const { pageIds } = event; - await this.searchQueue.add(QueueJob.PAGE_DELETED, { pageIds }); + const { pageIds, workspaceId } = event; + if (this.isTypesense()) { + await this.searchQueue.add(QueueJob.PAGE_DELETED, { pageIds }); + } + + await this.aiQueue.add(QueueJob.PAGE_DELETED, { pageIds, workspaceId }); } @OnEvent(EventName.PAGE_SOFT_DELETED) async handlePageSoftDeleted(event: PageEvent) { - const { pageIds } = event; - await this.searchQueue.add(QueueJob.PAGE_SOFT_DELETED, { pageIds }); + const { pageIds, workspaceId } = event; + + if (this.isTypesense()) { + await this.searchQueue.add(QueueJob.PAGE_SOFT_DELETED, { pageIds }); + } + + await this.aiQueue.add(QueueJob.PAGE_SOFT_DELETED, { + pageIds, + workspaceId, + }); } @OnEvent(EventName.PAGE_RESTORED) async handlePageRestored(event: PageEvent) { - const { pageIds } = event; - await this.searchQueue.add(QueueJob.PAGE_RESTORED, { pageIds }); + const { pageIds, workspaceId } = event; + if (this.isTypesense()) { + await this.searchQueue.add(QueueJob.PAGE_RESTORED, { pageIds }); + } + + await this.aiQueue.add(QueueJob.PAGE_RESTORED, { pageIds, workspaceId }); + } + + isTypesense(): boolean { + return this.environmentService.getSearchDriver() === 'typesense'; } } diff --git a/apps/server/src/database/listeners/space.listener.ts b/apps/server/src/database/listeners/space.listener.ts new file mode 100644 index 00000000..af6e3a22 --- /dev/null +++ b/apps/server/src/database/listeners/space.listener.ts @@ -0,0 +1,36 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { EventName } from '../../common/events/event.contants'; +import { InjectQueue } from '@nestjs/bullmq'; +import { QueueJob, QueueName } from '../../integrations/queue/constants'; +import { Queue } from 'bullmq'; +import { EnvironmentService } from '../../integrations/environment/environment.service'; + +export class SpaceEvent { + spaceId: string; +} + +@Injectable() +export class SpaceListener { + private readonly logger = new Logger(SpaceListener.name); + + constructor( + private readonly environmentService: EnvironmentService, + @InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue, + @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, + ) {} + + @OnEvent(EventName.SPACE_DELETED) + async handleSpaceDeleted(event: SpaceEvent) { + const { spaceId } = event; + if (this.isTypesense()) { + await this.searchQueue.add(QueueJob.SPACE_DELETED, { spaceId }); + } + + await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId }); + } + + isTypesense(): boolean { + return this.environmentService.getSearchDriver() === 'typesense'; + } +} diff --git a/apps/server/src/database/listeners/workspace.listener.ts b/apps/server/src/database/listeners/workspace.listener.ts new file mode 100644 index 00000000..6e7d3155 --- /dev/null +++ b/apps/server/src/database/listeners/workspace.listener.ts @@ -0,0 +1,36 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { EventName } from '../../common/events/event.contants'; +import { InjectQueue } from '@nestjs/bullmq'; +import { QueueJob, QueueName } from '../../integrations/queue/constants'; +import { Queue } from 'bullmq'; +import { EnvironmentService } from '../../integrations/environment/environment.service'; + +export class WorkspaceEvent { + workspaceId: string; +} + +@Injectable() +export class WorkspaceListener { + private readonly logger = new Logger(WorkspaceListener.name); + + constructor( + private readonly environmentService: EnvironmentService, + @InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue, + @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, + ) {} + + @OnEvent(EventName.WORKSPACE_DELETED) + async handlePageDeleted(event: WorkspaceEvent) { + const { workspaceId } = event; + if (this.isTypesense()) { + await this.searchQueue.add(QueueJob.WORKSPACE_DELETED, { workspaceId }); + } + + await this.aiQueue.add(QueueJob.WORKSPACE_DELETED, { workspaceId }); + } + + isTypesense(): boolean { + return this.environmentService.getSearchDriver() === 'typesense'; + } +} diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index ca46ddc9..3b948a48 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -125,6 +125,7 @@ export class PageRepo { this.eventEmitter.emit(EventName.PAGE_UPDATED, { pageIds: pageIds, + workspaceId: updatePageData.workspaceId, }); return result; @@ -143,6 +144,7 @@ export class PageRepo { this.eventEmitter.emit(EventName.PAGE_CREATED, { pageIds: [result.id], + workspaceId: result.workspaceId, }); return result; @@ -160,7 +162,11 @@ export class PageRepo { await query.execute(); } - async removePage(pageId: string, deletedById: string): Promise { + async removePage( + pageId: string, + deletedById: string, + workspaceId: string, + ): Promise { const currentDate = new Date(); const descendants = await this.db @@ -195,13 +201,15 @@ export class PageRepo { await trx.deleteFrom('shares').where('pageId', 'in', pageIds).execute(); }); + this.eventEmitter.emit(EventName.PAGE_SOFT_DELETED, { pageIds: pageIds, + workspaceId, }); } } - async restorePage(pageId: string): Promise { + async restorePage(pageId: string, workspaceId: string): Promise { // First, check if the page being restored has a deleted parent const pageToRestore = await this.db .selectFrom('pages') @@ -263,6 +271,7 @@ export class PageRepo { } this.eventEmitter.emit(EventName.PAGE_RESTORED, { pageIds: pageIds, + workspaceId: workspaceId, }); } diff --git a/apps/server/src/database/repos/space/space.repo.ts b/apps/server/src/database/repos/space/space.repo.ts index d92f9828..ed0d6b1e 100644 --- a/apps/server/src/database/repos/space/space.repo.ts +++ b/apps/server/src/database/repos/space/space.repo.ts @@ -12,10 +12,15 @@ import { PaginationOptions } from '../../pagination/pagination-options'; import { executeWithPagination } from '@docmost/db/pagination/pagination'; import { DB } from '@docmost/db/types/db'; import { validate as isValidUUID } from 'uuid'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { EventName } from '../../../common/events/event.contants'; @Injectable() export class SpaceRepo { - constructor(@InjectKysely() private readonly db: KyselyDB) {} + constructor( + @InjectKysely() private readonly db: KyselyDB, + private eventEmitter: EventEmitter2, + ) {} async findById( spaceId: string, @@ -110,7 +115,11 @@ export class SpaceRepo { if (pagination.query) { query = query.where((eb) => - eb(sql`f_unaccent(name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`).or( + eb( + sql`f_unaccent(name)`, + 'ilike', + sql`f_unaccent(${'%' + pagination.query + '%'})`, + ).or( sql`f_unaccent(description)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`, @@ -155,5 +164,9 @@ export class SpaceRepo { .where('id', '=', spaceId) .where('workspaceId', '=', workspaceId) .execute(); + + this.eventEmitter.emit(EventName.SPACE_DELETED, { + spaceId, + }); } } diff --git a/apps/server/src/database/repos/workspace/workspace.repo.ts b/apps/server/src/database/repos/workspace/workspace.repo.ts index 790d6402..d17db49b 100644 --- a/apps/server/src/database/repos/workspace/workspace.repo.ts +++ b/apps/server/src/database/repos/workspace/workspace.repo.ts @@ -175,4 +175,22 @@ export class WorkspaceRepo { .returning(this.baseFields) .executeTakeFirst(); } + + async updateAiSettings( + workspaceId: string, + prefKey: string, + prefValue: string | boolean, + ) { + return this.db + .updateTable('workspaces') + .set({ + settings: sql`COALESCE(settings, '{}'::jsonb) + || jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb) + || jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`, + updatedAt: new Date(), + }) + .where('id', '=', workspaceId) + .returning(this.baseFields) + .executeTakeFirst(); + } } diff --git a/apps/server/src/database/types/db.interface.ts b/apps/server/src/database/types/db.interface.ts new file mode 100644 index 00000000..969e2059 --- /dev/null +++ b/apps/server/src/database/types/db.interface.ts @@ -0,0 +1,47 @@ +import { + ApiKeys, + Attachments, + AuthAccounts, + AuthProviders, + Backlinks, + Billing, + Comments, + FileTasks, + Groups, + GroupUsers, + PageHistory, + Pages, + Shares, + SpaceMembers, + Spaces, + UserMfa, + Users, + UserTokens, + WorkspaceInvitations, + Workspaces, +} from '@docmost/db/types/db'; +import { PageEmbeddings } from '@docmost/db/types/embeddings.types'; + +export interface DbInterface { + attachments: Attachments; + authAccounts: AuthAccounts; + authProviders: AuthProviders; + backlinks: Backlinks; + billing: Billing; + comments: Comments; + fileTasks: FileTasks; + groups: Groups; + groupUsers: GroupUsers; + pageEmbeddings: PageEmbeddings; + pageHistory: PageHistory; + pages: Pages; + shares: Shares; + spaceMembers: SpaceMembers; + spaces: Spaces; + userMfa: UserMfa; + users: Users; + userTokens: UserTokens; + workspaceInvitations: WorkspaceInvitations; + workspaces: Workspaces; + apiKeys: ApiKeys; +} diff --git a/apps/server/src/database/types/embeddings.types.ts b/apps/server/src/database/types/embeddings.types.ts new file mode 100644 index 00000000..2f4e1509 --- /dev/null +++ b/apps/server/src/database/types/embeddings.types.ts @@ -0,0 +1,20 @@ +import { Json, Timestamp, Generated } from '@docmost/db/types/db'; + +// embeddings type +export interface PageEmbeddings { + id: Generated; + pageId: string; + spaceId: string; + modelName: string; + modelDimensions: number; + workspaceId: string; + attachmentId: string; + embedding: number[]; + chunkIndex: Generated; + chunkStart: Generated; + chunkLength: Generated; + metadata: Generated; + createdAt: Generated; + updatedAt: Generated; + deletedAt: Timestamp | null; +} diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index b85c8c54..7f273dce 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -21,6 +21,7 @@ import { UserMfa as _UserMFA, ApiKeys, } from './db'; +import { PageEmbeddings } from '@docmost/db/types/embeddings.types'; // Workspace export type Workspace = Selectable; @@ -125,3 +126,8 @@ export type UpdatableUserMFA = Updateable>; export type ApiKey = Selectable; export type InsertableApiKey = Insertable; export type UpdatableApiKey = Updateable>; + +// Page Embedding +export type PageEmbedding = Selectable; +export type InsertablePageEmbedding = Insertable; +export type UpdatablePageEmbedding = Updateable>; diff --git a/apps/server/src/database/types/kysely.types.ts b/apps/server/src/database/types/kysely.types.ts index 39dae715..d1bf8adc 100644 --- a/apps/server/src/database/types/kysely.types.ts +++ b/apps/server/src/database/types/kysely.types.ts @@ -1,5 +1,5 @@ -import { DB } from './db'; import { Kysely, Transaction } from 'kysely'; +import { DbInterface } from '@docmost/db/types/db.interface'; -export type KyselyDB = Kysely; -export type KyselyTransaction = Transaction; +export type KyselyDB = Kysely; +export type KyselyTransaction = Transaction; diff --git a/apps/server/src/ee b/apps/server/src/ee index 7489dc9a..5f9e2e28 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 7489dc9a68f15fb89c7d538c0fe5756b49e76269 +Subproject commit 5f9e2e28d581195e2591460fe8f08a4b45f8edec diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts index e41a5ec3..30624f58 100644 --- a/apps/server/src/integrations/environment/environment.service.ts +++ b/apps/server/src/integrations/environment/environment.service.ts @@ -10,6 +10,10 @@ export class EnvironmentService { return this.configService.get('NODE_ENV', 'development'); } + isDevelopment(): boolean { + return this.getNodeEnv() === 'development'; + } + getAppUrl(): string { const rawUrl = this.configService.get('APP_URL') || @@ -231,6 +235,46 @@ export class EnvironmentService { } getTypesenseLocale(): string { - return this.configService.get('TYPESENSE_LOCALE', 'en').toLowerCase(); + return this.configService + .get('TYPESENSE_LOCALE', 'en') + .toLowerCase(); + } + + getAiDriver(): string { + return this.configService.get('AI_DRIVER'); + } + + getAiEmbeddingModel(): string { + return this.configService.get('AI_EMBEDDING_MODEL'); + } + + getAiCompletionModel(): string { + return this.configService.get('AI_COMPLETION_MODEL'); + } + + getAiEmbeddingDimension(): number { + return parseInt( + this.configService.get('AI_EMBEDDING_DIMENSION'), + 10, + ); + } + + getOpenAiApiKey(): string { + return this.configService.get('OPENAI_API_KEY'); + } + + getOpenAiApiUrl(): string { + return this.configService.get('OPENAI_API_URL'); + } + + getGeminiApiKey(): string { + return this.configService.get('GEMINI_API_KEY'); + } + + getOllamaApiUrl(): string { + return this.configService.get( + 'OLLAMA_API_URL', + 'http://localhost:11434', + ); } } diff --git a/apps/server/src/integrations/environment/environment.validation.ts b/apps/server/src/integrations/environment/environment.validation.ts index d59558f8..752e3d41 100644 --- a/apps/server/src/integrations/environment/environment.validation.ts +++ b/apps/server/src/integrations/environment/environment.validation.ts @@ -93,6 +93,7 @@ export class EnvironmentVariables { @IsOptional() @ValidateIf((obj) => obj.SEARCH_DRIVER === 'typesense') + @IsNotEmpty() @IsString() TYPESENSE_API_KEY: string; @@ -101,6 +102,53 @@ export class EnvironmentVariables { @IsISO6391() @IsString() TYPESENSE_LOCALE: string; + + @IsOptional() + @ValidateIf((obj) => obj.AI_DRIVER) + @IsIn(['openai', 'gemini', 'ollama']) + @IsString() + AI_DRIVER: string; + + @IsOptional() + @ValidateIf((obj) => obj.AI_DRIVER) + @IsString() + @IsNotEmpty() + AI_EMBEDDING_MODEL: string; + + @IsOptional() + @ValidateIf((obj) => obj.AI_EMBEDDING_DIMENSION) + @IsIn(['768', '1024', '1536']) + @IsString() + AI_EMBEDDING_DIMENSION: string; + + + @IsOptional() + @ValidateIf((obj) => obj.AI_DRIVER) + @IsString() + @IsNotEmpty() + AI_COMPLETION_MODEL: string; + + @IsOptional() + @ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'openai') + @IsString() + @IsNotEmpty() + OPENAI_API_KEY: string; + + @IsOptional() + @ValidateIf((obj) => obj.AI_DRIVER && obj.OPENAI_API_URL && obj.AI_DRIVER === 'openai') + @IsUrl({ protocols: ['http', 'https'], require_tld: false }) + OPENAI_API_URL: string; + + @IsOptional() + @ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'gemini') + @IsString() + @IsNotEmpty() + GEMINI_API_KEY: string; + + @IsOptional() + @ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'ollama') + @IsUrl({ protocols: ['http', 'https'], require_tld: false }) + OLLAMA_API_URL: string; } export function validate(config: Record) { diff --git a/apps/server/src/integrations/import/services/file-import-task.service.ts b/apps/server/src/integrations/import/services/file-import-task.service.ts index 66dab4a2..0e7773cd 100644 --- a/apps/server/src/integrations/import/services/file-import-task.service.ts +++ b/apps/server/src/integrations/import/services/file-import-task.service.ts @@ -473,6 +473,7 @@ export class FileImportTaskService { if (validPageIds.size > 0) { this.eventEmitter.emit(EventName.PAGE_CREATED, { pageIds: Array.from(validPageIds), + workspaceId: fileTask.workspaceId, }); } diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts index 122d2a76..5c7aa29a 100644 --- a/apps/server/src/integrations/queue/constants/queue.constants.ts +++ b/apps/server/src/integrations/queue/constants/queue.constants.ts @@ -5,6 +5,7 @@ export enum QueueName { BILLING_QUEUE = '{billing-queue}', FILE_TASK_QUEUE = '{file-task-queue}', SEARCH_QUEUE = '{search-queue}', + AI_QUEUE = '{ai-queue}', } export enum QueueJob { @@ -13,7 +14,6 @@ export enum QueueJob { ATTACHMENT_INDEX_CONTENT = 'attachment-index-content', ATTACHMENT_INDEXING = 'attachment-indexing', DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments', - PAGE_CONTENT_UPDATE = 'page-content-update', DELETE_USER_AVATARS = 'delete-user-avatars', @@ -39,8 +39,23 @@ export enum QueueJob { TYPESENSE_FLUSH = 'typesense-flush', PAGE_CREATED = 'page-created', + PAGE_CONTENT_UPDATED = 'page-content-updated', + PAGE_MOVED_TO_SPACE = 'page-moved-to-space', PAGE_UPDATED = 'page-updated', PAGE_SOFT_DELETED = 'page-soft-deleted', PAGE_RESTORED = 'page-restored', PAGE_DELETED = 'page-deleted', + + SPACE_CREATED = 'space-created', + SPACE_UPDATED = 'space-updated', + SPACE_DELETED = 'space-deleted', + + WORKSPACE_CREATED = 'workspace-created', + WORKSPACE_SPACE_UPDATED = 'workspace-updated', + WORKSPACE_DELETED = 'workspace-deleted', + WORKSPACE_CREATE_EMBEDDINGS = 'workspace-create-embeddings', + WORKSPACE_DELETE_EMBEDDINGS = 'workspace-delete-embeddings', + + GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings', + DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings', } diff --git a/apps/server/src/integrations/queue/queue.module.ts b/apps/server/src/integrations/queue/queue.module.ts index 32d009ad..6787e010 100644 --- a/apps/server/src/integrations/queue/queue.module.ts +++ b/apps/server/src/integrations/queue/queue.module.ts @@ -65,6 +65,14 @@ import { BacklinksProcessor } from './processors/backlinks.processor'; attempts: 2, }, }), + BullModule.registerQueue({ + name: QueueName.AI_QUEUE, + defaultJobOptions: { + removeOnComplete: true, + removeOnFail: true, + attempts: 1, + }, + }), ], exports: [BullModule], providers: [BacklinksProcessor], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01ae0538..e17f2773 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -307,7 +307,7 @@ importers: version: 3.3.0 mantine-form-zod-resolver: specifier: ^1.3.0 - version: 1.3.0(@mantine/form@8.1.3(react@18.3.1))(zod@3.25.56) + version: 1.3.0(@mantine/form@8.1.3(react@18.3.1))(zod@3.25.76) mermaid: specifier: ^11.11.0 version: 11.11.0 @@ -357,8 +357,8 @@ importers: specifier: ^0.1.18 version: 0.1.18 zod: - specifier: ^3.25.56 - version: 3.25.56 + specifier: ^3.25.76 + version: 3.25.76 devDependencies: '@eslint/js': specifier: ^9.16.0 @@ -429,6 +429,15 @@ importers: apps/server: dependencies: + '@ai-sdk/azure': + specifier: ^2.0.47 + version: 2.0.47(zod@3.25.76) + '@ai-sdk/google': + specifier: ^2.0.18 + version: 2.0.18(zod@3.25.76) + '@ai-sdk/openai': + specifier: ^2.0.46 + version: 2.0.46(zod@3.25.76) '@aws-sdk/client-s3': specifier: 3.701.0 version: 3.701.0 @@ -450,6 +459,9 @@ importers: '@fastify/static': specifier: ^8.2.0 version: 8.2.0 + '@langchain/textsplitters': + specifier: ^0.1.0 + version: 0.1.0(@langchain/core@0.3.72(@opentelemetry/api@1.9.0)(openai@6.2.0(ws@8.18.3)(zod@3.25.76))) '@nestjs-labs/nestjs-ioredis': specifier: ^11.0.4 version: 11.0.4(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(ioredis@5.4.1) @@ -504,6 +516,12 @@ importers: '@socket.io/redis-adapter': specifier: ^8.3.0 version: 8.3.0(socket.io-adapter@2.5.4) + ai: + specifier: ^5.0.65 + version: 5.0.65(zod@3.25.76) + ai-sdk-ollama: + specifier: ^0.12.0 + version: 0.12.0(ai@5.0.65(zod@3.25.76))(zod@3.25.76) bcrypt: specifier: ^6.0.0 version: 6.0.0 @@ -585,6 +603,9 @@ importers: pg-tsquery: specifier: ^8.4.2 version: 8.4.2 + pgvector: + specifier: ^0.2.1 + version: 0.2.1 postmark: specifier: ^4.0.5 version: 4.0.5 @@ -730,6 +751,40 @@ packages: '@adobe/css-tools@4.3.3': resolution: {integrity: sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==} + '@ai-sdk/azure@2.0.47': + resolution: {integrity: sha512-rPvjnBWVTVRCDs47qfBWxXxx4i4h7itemyKux21qibB7y24rubqmZGx9lYcI5pyBL057uROhBa9Y5VVHf/ESYw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/gateway@1.0.36': + resolution: {integrity: sha512-G/CLHzyOy9mhbimSBmV+o59M7ao/NfRFrrhC+eHGp+0qT0diP3IDW5VdkPHKFmDp4Iq7wb4/yOCe7Yk2fQtSrg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/google@2.0.18': + resolution: {integrity: sha512-ycGAqouueHjU0hB6JHYmUhXYCnN67PqI8+9jCv13MbuE0g+b9w78HiPuab5ResakY0cq3ynFDvbiu8jAGo1RZQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/openai@2.0.46': + resolution: {integrity: sha512-3FHZdiTLbjnHw0rbu1yOPW8FruHrzN6SlJYsaLSQgbxYfE5y+60Nj4Xp8/k7rtD3FmrjkKcp/XTMSbAJWfoJig==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@3.0.11': + resolution: {integrity: sha512-4hgHj89VqyOHzGaV85TkcgvO8WjecVF35TOUVg+C56vnzpWSgdIZu/ZWZNdZ6BTrv8y0N1toBWW7XcWiRRicLg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@2.0.0': + resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==} + engines: {node: '>=18'} + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -1805,6 +1860,9 @@ packages: '@casl/ability': ^3.0.0 || ^4.0.0 || ^5.1.0 || ^6.0.0 react: ^16.0.0 || ^17.0.0 || ^18.0.0 + '@cfworker/json-schema@4.1.1': + resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + '@chevrotain/cst-dts-gen@11.0.3': resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==} @@ -2754,6 +2812,16 @@ packages: '@keyv/serialize@1.0.3': resolution: {integrity: sha512-qnEovoOp5Np2JDGonIDL6Ayihw0RhnRh6vxPuHo4RDn1UOzwEo4AeIfpL6UGIrsceWrCMiVPgwRjbHu4vYFc3g==} + '@langchain/core@0.3.72': + resolution: {integrity: sha512-WsGWVZYnlKffj2eEfDocPNiaTRoxyYiLSQdQ7oxZvxGZBqo/90vpjbC33UGK1uPNBM4kT+pkdaol/MnvKUh8TQ==} + engines: {node: '>=18'} + + '@langchain/textsplitters@0.1.0': + resolution: {integrity: sha512-djI4uw9rlkAb5iMhtLED+xJebDdAG935AdP4eRTB02R7OB/act55Bj9wsskhZsvuyQRpO4O1wQOp85s6T6GWmw==} + engines: {node: '>=18'} + peerDependencies: + '@langchain/core': '>=0.2.21 <0.4.0' + '@lifeomic/attempt@3.0.3': resolution: {integrity: sha512-GlM2AbzrErd/TmLL3E8hAHmb5Q7VhDJp35vIbyPVA5Rz55LZuRr8pwL3qrwwkVNo05gMX1J44gURKb4MHQZo7w==} @@ -4047,6 +4115,9 @@ packages: peerDependencies: socket.io-adapter: ^2.5.4 + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@swc/core-darwin-arm64@1.5.25': resolution: {integrity: sha512-YbD0SBgVJS2DM0vwJTU5m7+wOyCjHPBDMf3nCBJQzFZzOLzK11eRW7SzU2jhJHr9HI9sKcNFfN4lIC2Sj+4inA==} engines: {node: '>=10'} @@ -4734,6 +4805,9 @@ packages: '@types/react@18.3.12': resolution: {integrity: sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==} + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/send@0.17.4': resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} @@ -4909,6 +4983,10 @@ packages: '@ucast/mongo@2.4.3': resolution: {integrity: sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==} + '@vercel/oidc@3.0.2': + resolution: {integrity: sha512-JekxQ0RApo4gS4un/iMGsIL1/k4KUBe3HmnGcDvzHuFBdQdudEJgTqcsJC7y6Ul4Yw5CeykgvQbX2XeEJd0+DA==} + engines: {node: '>= 20'} + '@vitejs/plugin-react@5.1.1': resolution: {integrity: sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5028,6 +5106,18 @@ packages: resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} engines: {node: '>= 14'} + ai-sdk-ollama@0.12.0: + resolution: {integrity: sha512-EEKIfIpkyAavrlEKlZ7nZCxTUPq4yBThBLLU3kTD4l7htpdqMjhOEyqm5DlKdQvLEW0MgCMsptw7yXbevRSfIQ==} + engines: {node: '>=22'} + peerDependencies: + ai: ^5.0.60 + + ai@5.0.65: + resolution: {integrity: sha512-orwsNKAoAmTwHkoy7TG/7nc65SD3hy7k+x8xVHIzfw8CibZm/U2cdbR1ZUex6H2Rpf+uoZpvyQ05FWBJNw7V8A==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -5573,6 +5663,9 @@ packages: resolution: {integrity: sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==} engines: {node: ^14.18.0 || >=16.10.0} + console-table-printer@2.14.6: + resolution: {integrity: sha512-MCBl5HNVaFuuHW6FGbL/4fB7N/ormCy+tQ+sxTrF6QtSbSNETvPuOVbkJBhzDgYhvjWGrTma4eYJa37ZuoQsPw==} + content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -6319,10 +6412,17 @@ packages: eventemitter2@6.4.9: resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -7228,6 +7328,9 @@ packages: resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} engines: {node: '>=14'} + js-tiktoken@1.0.21: + resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -7277,6 +7380,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -7389,6 +7495,23 @@ packages: resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==} engines: {node: '>=16.0.0'} + langsmith@0.3.61: + resolution: {integrity: sha512-b7Cpfj3xpWQO41G3xXeG6uzPzBcWfkEo5cK62WOcTqsKCchN2i42z7q45QQrbU6mdLwXp6pjRfnvr7wFu+Y5iQ==} + peerDependencies: + '@opentelemetry/api': '*' + '@opentelemetry/exporter-trace-otlp-proto': '*' + '@opentelemetry/sdk-trace-base': '*' + openai: '*' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/exporter-trace-otlp-proto': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + openai: + optional: true + layout-base@1.0.2: resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} @@ -7811,6 +7934,10 @@ packages: multimath@2.0.0: resolution: {integrity: sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==} + mustache@4.2.0: + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + hasBin: true + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -8003,6 +8130,9 @@ packages: resolution: {integrity: sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==} engines: {node: ^10.13.0 || >=12.0.0} + ollama@0.6.0: + resolution: {integrity: sha512-FHjdU2Ok5x2HZsxPui/MBJZ5J+HzmxoWYa/p9wk736eT+uAhS8nvIICar5YgwlG5MFNjDR6UA5F3RSKq+JseOA==} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -8021,6 +8151,18 @@ packages: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} + openai@6.2.0: + resolution: {integrity: sha512-qqjzHls7F5xkXNGy9P1Ei1rorI5LWupUUFWP66zPU8FlZbiITX8SFcHMKNZg/NATJ0LpIZcMUFxSwQmdeQPwSw==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + openid-client@5.7.1: resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==} @@ -8052,6 +8194,10 @@ packages: otpauth@9.4.0: resolution: {integrity: sha512-fHIfzIG5RqCkK9cmV8WU+dPQr9/ebR5QOwGZn2JAr1RQF+lmAuLL2YdtdqvmBjNmgJlYk3KZ4a0XokaEhg1Jsw==} + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -8076,6 +8222,18 @@ packages: resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} engines: {node: '>=6'} + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -8239,6 +8397,10 @@ packages: pgpass@1.0.5: resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + pgvector@0.2.1: + resolution: {integrity: sha512-nKaQY9wtuiidwLMdVIce1O3kL0d+FxrigCVzsShnoqzOSaWWWOvuctb/sYwlai5cTwwzRSNa+a/NtN2kVZGNJw==} + engines: {node: '>= 18'} + pica@7.1.1: resolution: {integrity: sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ==} @@ -8856,6 +9018,10 @@ packages: resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} engines: {node: '>=10'} + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -9043,6 +9209,9 @@ packages: simple-swizzle@0.2.4: resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + simple-wcswidth@1.1.2: + resolution: {integrity: sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -9666,6 +9835,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true @@ -9817,6 +9990,9 @@ packages: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} @@ -10057,8 +10233,13 @@ packages: resolution: {integrity: sha512-dtZ0aQSFyZmoJS0m06/xBN1SazUBPL5HpzlAcs/KcRW0rzadYw12deQBjeMhGKMMeGEp7bA9vmikMLaO4exBcg==} engines: {node: '>=14.13.1'} - zod@3.25.56: - resolution: {integrity: sha512-rd6eEF3BTNvQnR2e2wwolfTmUTnp70aUTqr0oaGbHifzC3BKJsoV+Gat8vxUMR1hwOKBs6El+qWehrHbCpW6SQ==} + zod-to-json-schema@3.24.6: + resolution: {integrity: sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==} + peerDependencies: + zod: ^3.24.1 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} zustand@4.5.6: resolution: {integrity: sha512-ibr/n1hBzLLj5Y+yUcU7dYw8p6WnIVzdJbnX+1YpaScvZVF2ziugqHs+LAmHw4lWO9c/zRj+K1ncgWDQuthEdQ==} @@ -10081,6 +10262,43 @@ snapshots: '@adobe/css-tools@4.3.3': {} + '@ai-sdk/azure@2.0.47(zod@3.25.76)': + dependencies: + '@ai-sdk/openai': 2.0.46(zod@3.25.76) + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.11(zod@3.25.76) + zod: 3.25.76 + + '@ai-sdk/gateway@1.0.36(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.11(zod@3.25.76) + '@vercel/oidc': 3.0.2 + zod: 3.25.76 + + '@ai-sdk/google@2.0.18(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.11(zod@3.25.76) + zod: 3.25.76 + + '@ai-sdk/openai@2.0.46(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.11(zod@3.25.76) + zod: 3.25.76 + + '@ai-sdk/provider-utils@3.0.11(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 2.0.0 + '@standard-schema/spec': 1.0.0 + eventsource-parser: 3.0.6 + zod: 3.25.76 + + '@ai-sdk/provider@2.0.0': + dependencies: + json-schema: 0.4.0 + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.5 @@ -11940,6 +12158,8 @@ snapshots: '@casl/ability': 6.7.2 react: 18.3.1 + '@cfworker/json-schema@4.1.1': {} + '@chevrotain/cst-dts-gen@11.0.3': dependencies: '@chevrotain/gast': 11.0.3 @@ -12926,6 +13146,31 @@ snapshots: dependencies: buffer: 6.0.3 + '@langchain/core@0.3.72(@opentelemetry/api@1.9.0)(openai@6.2.0(ws@8.18.3)(zod@3.25.76))': + dependencies: + '@cfworker/json-schema': 4.1.1 + ansi-styles: 5.2.0 + camelcase: 6.3.0 + decamelize: 1.2.0 + js-tiktoken: 1.0.21 + langsmith: 0.3.61(@opentelemetry/api@1.9.0)(openai@6.2.0(ws@8.18.3)(zod@3.25.76)) + mustache: 4.2.0 + p-queue: 6.6.2 + p-retry: 4.6.2 + uuid: 10.0.0 + zod: 3.25.76 + zod-to-json-schema: 3.24.6(zod@3.25.76) + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - openai + + '@langchain/textsplitters@0.1.0(@langchain/core@0.3.72(@opentelemetry/api@1.9.0)(openai@6.2.0(ws@8.18.3)(zod@3.25.76)))': + dependencies: + '@langchain/core': 0.3.72(@opentelemetry/api@1.9.0)(openai@6.2.0(ws@8.18.3)(zod@3.25.76)) + js-tiktoken: 1.0.21 + '@lifeomic/attempt@3.0.3': {} '@lukeed/csprng@1.1.0': {} @@ -13423,8 +13668,7 @@ snapshots: '@one-ini/wasm@0.1.1': {} - '@opentelemetry/api@1.9.0': - optional: true + '@opentelemetry/api@1.9.0': {} '@pinojs/redact@0.4.0': {} @@ -14222,6 +14466,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@standard-schema/spec@1.0.0': {} + '@swc/core-darwin-arm64@1.5.25': optional: true @@ -14959,6 +15205,8 @@ snapshots: '@types/prop-types': 15.7.11 csstype: 3.1.3 + '@types/retry@0.12.0': {} + '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 @@ -15195,6 +15443,8 @@ snapshots: dependencies: '@ucast/core': 1.10.2 + '@vercel/oidc@3.0.2': {} + '@vitejs/plugin-react@5.1.1(vite@7.2.4(@types/node@22.19.1)(jiti@1.21.0)(less@4.2.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0))': dependencies: '@babel/core': 7.28.5 @@ -15331,6 +15581,23 @@ snapshots: transitivePeerDependencies: - supports-color + ai-sdk-ollama@0.12.0(ai@5.0.65(zod@3.25.76))(zod@3.25.76): + dependencies: + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.11(zod@3.25.76) + ai: 5.0.65(zod@3.25.76) + ollama: 0.6.0 + transitivePeerDependencies: + - zod + + ai@5.0.65(zod@3.25.76): + dependencies: + '@ai-sdk/gateway': 1.0.36(zod@3.25.76) + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.11(zod@3.25.76) + '@opentelemetry/api': 1.9.0 + zod: 3.25.76 + ajv-formats@2.1.1(ajv@8.12.0): optionalDependencies: ajv: 8.12.0 @@ -15993,6 +16260,10 @@ snapshots: consola@3.4.0: {} + console-table-printer@2.14.6: + dependencies: + simple-wcswidth: 1.1.2 + content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 @@ -16925,8 +17196,12 @@ snapshots: eventemitter2@6.4.9: {} + eventemitter3@4.0.7: {} + events@3.3.0: {} + eventsource-parser@3.0.6: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -18112,6 +18387,10 @@ snapshots: js-cookie@3.0.5: {} + js-tiktoken@1.0.21: + dependencies: + base64-js: 1.5.1 + js-tokens@4.0.0: {} js-yaml@3.14.1: @@ -18169,6 +18448,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -18273,6 +18554,19 @@ snapshots: vscode-languageserver-textdocument: 1.0.12 vscode-uri: 3.0.8 + langsmith@0.3.61(@opentelemetry/api@1.9.0)(openai@6.2.0(ws@8.18.3)(zod@3.25.76)): + dependencies: + '@types/uuid': 10.0.0 + chalk: 4.1.2 + console-table-printer: 2.14.6 + p-queue: 6.6.2 + p-retry: 4.6.2 + semver: 7.7.2 + uuid: 10.0.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + openai: 6.2.0(ws@8.18.3)(zod@3.25.76) + layout-base@1.0.2: {} layout-base@2.0.1: {} @@ -18470,10 +18764,10 @@ snapshots: underscore: 1.13.7 xmlbuilder: 10.1.1 - mantine-form-zod-resolver@1.3.0(@mantine/form@8.1.3(react@18.3.1))(zod@3.25.56): + mantine-form-zod-resolver@1.3.0(@mantine/form@8.1.3(react@18.3.1))(zod@3.25.76): dependencies: '@mantine/form': 8.1.3(react@18.3.1) - zod: 3.25.56 + zod: 3.25.76 markdown-it@14.1.0: dependencies: @@ -18806,6 +19100,8 @@ snapshots: glur: 1.1.2 object-assign: 4.1.1 + mustache@4.2.0: {} + mute-stream@2.0.0: {} nanoid@3.3.11: {} @@ -19007,6 +19303,10 @@ snapshots: oidc-token-hash@5.0.3: {} + ollama@0.6.0: + dependencies: + whatwg-fetch: 3.6.20 + on-exit-leak-free@2.1.2: {} once@1.4.0: @@ -19025,6 +19325,12 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openai@6.2.0(ws@8.18.3)(zod@3.25.76): + optionalDependencies: + ws: 8.18.3 + zod: 3.25.76 + optional: true + openid-client@5.7.1: dependencies: jose: 4.15.9 @@ -19076,6 +19382,8 @@ snapshots: dependencies: '@noble/hashes': 1.7.1 + p-finally@1.0.0: {} + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -19098,6 +19406,20 @@ snapshots: p-map@2.1.0: {} + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + p-try@2.2.0: {} package-json-from-dist@1.0.0: {} @@ -19258,6 +19580,8 @@ snapshots: dependencies: split2: 4.2.0 + pgvector@0.2.1: {} + pica@7.1.1: dependencies: glur: 1.1.2 @@ -19919,6 +20243,8 @@ snapshots: ret@0.5.0: {} + retry@0.13.1: {} + reusify@1.0.4: {} rfdc@1.3.1: {} @@ -20158,6 +20484,8 @@ snapshots: dependencies: is-arrayish: 0.3.4 + simple-wcswidth@1.1.2: {} + sisteransi@1.0.5: {} slash@3.0.0: {} @@ -20814,6 +21142,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@10.0.0: {} + uuid@11.1.0: {} uuid@9.0.1: {} @@ -20944,6 +21274,8 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-fetch@3.6.20: {} + whatwg-mimetype@3.0.0: {} whatwg-mimetype@4.0.0: {} @@ -21164,7 +21496,11 @@ snapshots: css-what: 6.1.0 entities: 5.0.0 - zod@3.25.56: {} + zod-to-json-schema@3.24.6(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} zustand@4.5.6(@types/react@18.3.12)(react@18.3.1): dependencies: