diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 3ecf103b..67713f28 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -627,6 +627,7 @@ "AI Answer": "AI Answer", "Ask AI": "Ask AI", "AI is thinking...": "AI is thinking...", + "Thinking": "Thinking", "Ask a question...": "Ask a question...", "AI Answers": "AI Answers", "AI-powered search (AI Answers)": "AI-powered search (AI Answers)", @@ -751,5 +752,32 @@ "Publish": "Publish", "Security": "Security", "Enforce SSO": "Enforce SSO", - "Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to login with email and password." + "Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to login with email and password.", + "AI-generated content may not be accurate.": "AI-generated content may not be accurate.", + "AI Chat": "AI Chat", + "Analyze for insights": "Analyze for insights", + "Ask anything...": "Ask anything...", + "Chat history": "Chat history", + "Chat name": "Chat name", + "Close": "Close", + "Docmost AI": "Docmost AI", + "Failed to load chat. An error occurred.": "Failed to load chat. An error occurred.", + "Failed to render this message.": "Failed to render this message.", + "How can I help you today?": "How can I help you today?", + "New chat": "New chat", + "No chat history": "No chat history", + "No chats found": "No chats found", + "No conversations yet": "No conversations yet", + "Open full page": "Open full page", + "Previous 7 days": "Previous 7 days", + "Previous 30 days": "Previous 30 days", + "Search chats...": "Search chats...", + "Start a new chat to see it here.": "Start a new chat to see it here.", + "Summarize this page": "Summarize this page", + "Toggle AI Chat": "Toggle AI Chat", + "Translate this page": "Translate this page", + "Try a different search term.": "Try a different search term.", + "Try again": "Try again", + "Untitled chat": "Untitled chat", + "What can I help you with?": "What can I help you with?" } diff --git a/apps/client/src/components/common/copy.tsx b/apps/client/src/components/common/copy.tsx index 81a70771..745fc4ba 100644 --- a/apps/client/src/components/common/copy.tsx +++ b/apps/client/src/components/common/copy.tsx @@ -1,4 +1,4 @@ -import { ActionIcon, Tooltip } from "@mantine/core"; +import { ActionIcon, MantineColor, MantineSize, Tooltip } from "@mantine/core"; import { CopyButton } from "@/components/common/copy-button"; import { IconCheck, IconCopy } from "@tabler/icons-react"; import React from "react"; @@ -6,8 +6,10 @@ import { useTranslation } from "react-i18next"; interface CopyProps { text: string; + size?: MantineSize; + color?: MantineColor; } -export default function CopyTextButton({ text }: CopyProps) { +export default function CopyTextButton({ text, size }: CopyProps) { const { t } = useTranslation(); return ( @@ -22,6 +24,7 @@ export default function CopyTextButton({ text }: CopyProps) { color={copied ? "teal" : "gray"} variant="subtle" onClick={copy} + size={size} > {copied ? : } diff --git a/apps/client/src/components/layouts/global/app-header.module.css b/apps/client/src/components/layouts/global/app-header.module.css index b2298f13..a70b57e7 100644 --- a/apps/client/src/components/layouts/global/app-header.module.css +++ b/apps/client/src/components/layouts/global/app-header.module.css @@ -7,6 +7,19 @@ padding-right: var(--mantine-spacing-md); } +.brand { + display: flex; + align-items: center; + text-decoration: none; + color: inherit; + cursor: pointer; +} + +.brandIcon { + display: flex; + align-items: center; +} + .link { display: block; line-height: 1; @@ -16,6 +29,9 @@ color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0)); font-size: var(--mantine-font-size-sm); font-weight: 500; + user-select: none; + white-space: nowrap; + flex-shrink: 0; @mixin hover { background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); diff --git a/apps/client/src/components/layouts/global/app-header.tsx b/apps/client/src/components/layouts/global/app-header.tsx index 49c2ba64..9add5d7a 100644 --- a/apps/client/src/components/layouts/global/app-header.tsx +++ b/apps/client/src/components/layouts/global/app-header.tsx @@ -1,8 +1,18 @@ -import { Badge, Group, Text, Tooltip } from "@mantine/core"; +import { + ActionIcon, + Badge, + Box, + Group, + Text, + Tooltip, + UnstyledButton, +} from "@mantine/core"; import classes from "./app-header.module.css"; import React from "react"; import TopMenu from "@/components/layouts/global/top-menu.tsx"; -import { Link } from "react-router-dom"; +import { Link, useLocation } from "react-router-dom"; +import { IconSparkles } from "@tabler/icons-react"; +import useToggleAside from "@/hooks/use-toggle-aside.tsx"; import APP_ROUTE from "@/lib/app-route.ts"; import { useAtom } from "jotai"; import { @@ -23,10 +33,10 @@ import { shareSearchSpotlight, } from "@/features/search/constants.ts"; import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; const links = [ { link: APP_ROUTE.HOME, label: "Home" }, - { link: "/ai", label: "AI Chat" }, ]; export function AppHeader() { @@ -37,9 +47,14 @@ export function AppHeader() { const [desktopOpened] = useAtom(desktopSidebarAtom); const toggleDesktop = useToggleSidebar(desktopSidebarAtom); const { isTrial, trialDaysLeft } = useTrial(); + const location = useLocation(); + const toggleAside = useToggleAside(); + const [workspace] = useAtom(workspaceAtom); + const aiChatEnabled = workspace?.settings?.ai?.chat === true; const isHomeRoute = location.pathname.startsWith("/home"); const isSpacesRoute = location.pathname === "/spaces"; + const isPageRoute = location.pathname.includes("/p/"); const hideSidebar = isHomeRoute || isSpacesRoute; const items = links.map((link) => ( @@ -76,15 +91,24 @@ export function AppHeader() { )} - - Docmost - + + + Docmost + + + Docmost + + {items} @@ -101,6 +125,49 @@ export function AppHeader() { + {aiChatEnabled && ( + <> + { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) { + return; + } + if (isPageRoute) { + e.preventDefault(); + toggleAside("chat"); + } + }} + > + {t("AI Chat")} + + + { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) { + return; + } + if (isPageRoute) { + e.preventDefault(); + toggleAside("chat"); + } + }} + > + + + + + )} {isCloud() && isTrial && trialDaysLeft !== 0 && ( ; title = "Table of contents"; break; + case "chat": + component = ; + title = "AI Chat"; + break; default: component = null; title = null; @@ -34,12 +39,14 @@ export default function Aside() { {component && ( <> - - {t(title)} - + {tab !== "chat" && ( + + {t(title)} + + )} - {tab === "comments" ? ( - + {tab === "comments" || tab === "chat" ? ( + component ) : ( * { + scroll-snap-align: start; + flex: 0 0 auto; +} + +.arrow { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6)); + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); + cursor: pointer; + padding: 0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease, background-color 120ms ease, transform 120ms ease; + z-index: 2; +} + +.root:hover .arrow.visible, +.arrow.visible:focus-visible { + opacity: 1; + pointer-events: auto; +} + +.arrow:hover { + background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5)); +} + +.arrow:active { + transform: translateY(-50%) scale(0.95); +} + +.arrowLeft { + left: -14px; +} + +.arrowRight { + right: -14px; +} diff --git a/apps/client/src/components/ui/card-carousel.tsx b/apps/client/src/components/ui/card-carousel.tsx new file mode 100644 index 00000000..60d13aba --- /dev/null +++ b/apps/client/src/components/ui/card-carousel.tsx @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import classes from "./card-carousel.module.css"; + +type Props = { + children: ReactNode; + ariaLabel?: string; +}; + +export default function CardCarousel({ children, ariaLabel }: Props) { + const { t } = useTranslation(); + const trackRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + const updateScrollState = useCallback(() => { + const el = trackRef.current; + if (!el) return; + const maxScroll = el.scrollWidth - el.clientWidth; + setCanScrollLeft(el.scrollLeft > 1); + setCanScrollRight(el.scrollLeft < maxScroll - 1); + }, []); + + useEffect(() => { + updateScrollState(); + const el = trackRef.current; + if (!el) return; + + const observer = new ResizeObserver(updateScrollState); + observer.observe(el); + for (const child of Array.from(el.children)) { + observer.observe(child); + } + + return () => observer.disconnect(); + }, [updateScrollState, children]); + + const scrollBy = (direction: 1 | -1) => { + const el = trackRef.current; + if (!el) return; + el.scrollBy({ left: direction * el.clientWidth * 0.85, behavior: "smooth" }); + }; + + return ( +
+
+ {children} +
+ + + + +
+ ); +} diff --git a/apps/client/src/ee/ai-chat/components/ai-chat-layout.tsx b/apps/client/src/ee/ai-chat/components/ai-chat-layout.tsx index 01ac3206..bbd1fb6b 100644 --- a/apps/client/src/ee/ai-chat/components/ai-chat-layout.tsx +++ b/apps/client/src/ee/ai-chat/components/ai-chat-layout.tsx @@ -1,14 +1,17 @@ -import { useEffect } from "react"; -import { useParams } from "react-router-dom"; +import { useEffect, useRef } from "react"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; import { useChatInfoQuery } from "../queries/ai-chat-query"; import { useChatStream } from "../hooks/use-chat-stream"; import ChatMessageList from "./chat-message-list"; import ChatEmptyState from "./chat-empty-state"; import ChatInput from "./chat-input"; +import type { HomeAiPromptInitialState } from "@/features/home/components/home-ai-prompt"; import classes from "../styles/ai-chat.module.css"; export default function AiChatLayout() { const { chatId } = useParams<{ chatId: string }>(); + const location = useLocation(); + const navigate = useNavigate(); const chatInfoQuery = useChatInfoQuery(chatId); const { messages, @@ -18,48 +21,39 @@ export default function AiChatLayout() { error, sendMessage, stopGeneration, - initMessages, + hydrateFromServer, } = useChatStream(chatId); + const autoSentRef = useRef(false); + useEffect(() => { if (chatInfoQuery.data?.messages) { - initMessages(chatInfoQuery.data.messages); + hydrateFromServer(chatInfoQuery.data.messages); } - }, [chatInfoQuery.data, initMessages]); + }, [chatInfoQuery.data, hydrateFromServer]); useEffect(() => { - if (!chatId) { - initMessages([]); - } - }, [chatId, initMessages]); + if (autoSentRef.current || chatId) return; + const state = location.state as HomeAiPromptInitialState | null; + if (!state?.initialContent && !state?.initialAttachments?.length) return; + + autoSentRef.current = true; + sendMessage( + state.initialContent ?? "", + state.initialMentions ?? [], + state.initialAttachments ?? [], + ); + navigate(location.pathname, { replace: true, state: null }); + }, [chatId, location, navigate, sendMessage]); const hasMessages = messages.length > 0 || isStreaming; - if (!hasMessages) { - return ( -
- -
- ); - } - return (
- - {error && (
)} -
- + +
+ +
+ + ) : ( + -
+ )}
); } diff --git a/apps/client/src/ee/ai-chat/components/ai-chat-sidebar-item.tsx b/apps/client/src/ee/ai-chat/components/ai-chat-sidebar-item.tsx index 3f76c80b..f59b740e 100644 --- a/apps/client/src/ee/ai-chat/components/ai-chat-sidebar-item.tsx +++ b/apps/client/src/ee/ai-chat/components/ai-chat-sidebar-item.tsx @@ -1,7 +1,8 @@ -import { useState, useRef, useEffect } from "react"; -import { ActionIcon, Menu, Popover, TextInput } from "@mantine/core"; +import { useState, useRef, useEffect, useMemo, useCallback } from "react"; +import { ActionIcon, Menu, TextInput } from "@mantine/core"; import { IconDots, IconTrash, IconEdit } from "@tabler/icons-react"; import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import type { AiChat } from "../types/ai-chat.types"; import classes from "../styles/chat-sidebar.module.css"; @@ -12,106 +13,154 @@ type Props = { onRename: (chatId: string, title: string) => void; }; +function formatChatDate( + isoString: string | Date, + locale: string | undefined, +): string { + const date = new Date(isoString); + if (Number.isNaN(date.getTime())) return ""; + + const now = new Date(); + const startOfToday = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ).getTime(); + const ts = date.getTime(); + const sameYear = date.getFullYear() === now.getFullYear(); + + if (ts >= startOfToday) { + return date.toLocaleTimeString(locale, { + hour: "numeric", + minute: "2-digit", + }); + } + + if (sameYear) { + return date.toLocaleDateString(locale, { + month: "short", + day: "numeric", + }); + } + + return date.toLocaleDateString(locale, { + month: "short", + day: "numeric", + year: "numeric", + }); +} + export default function AiChatSidebarItem({ chat, isActive, onDelete, onRename, }: Props) { + const { t, i18n } = useTranslation(); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(""); const inputRef = useRef(null); + const formattedDate = useMemo( + () => formatChatDate(chat.updatedAt, i18n.language), + [chat.updatedAt, i18n.language], + ); + useEffect(() => { if (renaming) { - inputRef.current?.select(); + // Wait for the input to be mounted before selecting. + const id = window.setTimeout(() => inputRef.current?.select(), 0); + return () => window.clearTimeout(id); } }, [renaming]); - const startRename = () => { + const startRename = useCallback(() => { setRenameValue(chat.title || ""); setRenaming(true); - }; + }, [chat.title]); - const submitRename = () => { + const submitRename = useCallback(() => { const trimmed = renameValue.trim(); if (trimmed && trimmed !== chat.title) { onRename(chat.id, trimmed); } setRenaming(false); - }; + }, [renameValue, chat.id, chat.title, onRename]); - return ( - setRenaming(false)} - position="bottom-start" - withinPortal - trapFocus - > - - - - {chat.title || "Untitled chat"} - -
- - - e.preventDefault()} - > - - - - - } - onClick={(e) => { - e.preventDefault(); - startRename(); - }} - > - Rename - - } - color="red" - onClick={(e) => { - e.preventDefault(); - onDelete(chat.id); - }} - > - Delete - - - -
- -
- + if (renaming) { + return ( +
setRenameValue(e.currentTarget.value)} onKeyDown={(e) => { if (e.key === "Enter") { + e.preventDefault(); submitRename(); } else if (e.key === "Escape") { + e.preventDefault(); setRenaming(false); } }} onBlur={submitRename} + classNames={{ input: classes.chatItemRenameInput }} + style={{ flex: 1 }} /> - - +
+ ); + } + + return ( + + + {chat.title || t("Untitled chat")} + + {formattedDate} +
+ + + e.preventDefault()} + > + + + + + } + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + startRename(); + }} + > + {t("Rename")} + + } + color="red" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + onDelete(chat.id); + }} + > + {t("Delete")} + + + +
+ ); } diff --git a/apps/client/src/ee/ai-chat/components/ai-chat-sidebar.tsx b/apps/client/src/ee/ai-chat/components/ai-chat-sidebar.tsx index 5e75a53b..e3df3e52 100644 --- a/apps/client/src/ee/ai-chat/components/ai-chat-sidebar.tsx +++ b/apps/client/src/ee/ai-chat/components/ai-chat-sidebar.tsx @@ -1,8 +1,9 @@ -import { useState, useCallback, useMemo } from "react"; -import { useNavigate, useParams } from "react-router-dom"; -import { ActionIcon, TextInput, Loader } from "@mantine/core"; +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { ActionIcon, Center, TextInput, Loader, Tooltip } from "@mantine/core"; import { useDebouncedValue } from "@mantine/hooks"; -import { IconPlus, IconSearch } from "@tabler/icons-react"; +import { IconPlus, IconSearch, IconMessageCircle2 } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; import { useChatsQuery, useDeleteChatMutation, @@ -13,7 +14,52 @@ import AiChatSidebarItem from "./ai-chat-sidebar-item"; import type { AiChat } from "../types/ai-chat.types"; import classes from "../styles/chat-sidebar.module.css"; +type ChatGroup = { key: string; label: string; chats: AiChat[] }; + +function groupChatsByAge( + chats: AiChat[], + t: (key: string) => string, +): ChatGroup[] { + if (chats.length === 0) return []; + + const now = new Date(); + const startOfToday = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ).getTime(); + const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000; + const startOfLast7 = startOfToday - 7 * 24 * 60 * 60 * 1000; + const startOfLast30 = startOfToday - 30 * 24 * 60 * 60 * 1000; + + const buckets: Record = { + today: { key: "today", label: t("Today"), chats: [] }, + yesterday: { key: "yesterday", label: t("Yesterday"), chats: [] }, + last7: { key: "last7", label: t("Previous 7 days"), chats: [] }, + last30: { key: "last30", label: t("Previous 30 days"), chats: [] }, + older: { key: "older", label: t("Older"), chats: [] }, + }; + + for (const chat of chats) { + const ts = new Date(chat.updatedAt).getTime(); + if (ts >= startOfToday) buckets.today.chats.push(chat); + else if (ts >= startOfYesterday) buckets.yesterday.chats.push(chat); + else if (ts >= startOfLast7) buckets.last7.chats.push(chat); + else if (ts >= startOfLast30) buckets.last30.chats.push(chat); + else buckets.older.chats.push(chat); + } + + return [ + buckets.today, + buckets.yesterday, + buckets.last7, + buckets.last30, + buckets.older, + ].filter((b) => b.chats.length > 0); +} + export default function AiChatSidebar() { + const { t } = useTranslation(); const navigate = useNavigate(); const { chatId } = useParams<{ chatId: string }>(); const [search, setSearch] = useState(""); @@ -30,9 +76,45 @@ export default function AiChatSidebar() { return chatsQuery.data?.pages.flatMap((p) => p.items) || []; }, [debouncedSearch, searchQuery.data, chatsQuery.data]); - const handleNewChat = useCallback(() => { - navigate("/ai"); - }, [navigate]); + const groupedChats = useMemo(() => groupChatsByAge(chats, t), [chats, t]); + + const sentinelRef = useRef(null); + const { hasNextPage, fetchNextPage, isFetchingNextPage } = chatsQuery; + const isSearching = Boolean(debouncedSearch); + + useEffect(() => { + if (isSearching) return; + const sentinel = sentinelRef.current; + if (!sentinel) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { threshold: 0.1 }, + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [isSearching, hasNextPage, isFetchingNextPage, fetchNextPage]); + + const handleNewChat = useCallback( + (event: React.MouseEvent) => { + if ( + event.button !== 0 || + event.ctrlKey || + event.metaKey || + event.shiftKey + ) { + return; + } + event.preventDefault(); + navigate("/ai"); + }, + [navigate], + ); const handleDelete = useCallback( (id: string) => { @@ -59,15 +141,19 @@ export default function AiChatSidebar() { return (
- AI Chat - - - + {t("AI Chat")} + + + + +
{isLoading && } - {chats.map((chat) => ( - - ))} + {!isLoading && chats.length === 0 && ( +
+ +
+ {isSearching ? t("No chats found") : t("No conversations yet")} +
+
+ {isSearching + ? t("Try a different search term.") + : t("Start a new chat to see it here.")} +
+
+ )} + {isSearching + ? chats.map((chat) => ( + + )) + : groupedChats.map((group) => ( +
+
{group.label}
+ {group.chats.map((chat) => ( + + ))} +
+ ))} + {!isSearching && ( + <> +
+ {isFetchingNextPage && ( +
+ +
+ )} + + )}
); diff --git a/apps/client/src/ee/ai-chat/components/aside-chat-history.tsx b/apps/client/src/ee/ai-chat/components/aside-chat-history.tsx new file mode 100644 index 00000000..c31e5b10 --- /dev/null +++ b/apps/client/src/ee/ai-chat/components/aside-chat-history.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import { TextInput, Loader, Text, ScrollArea } from "@mantine/core"; +import { IconSearch } from "@tabler/icons-react"; +import { useChatsQuery, useSearchChatsQuery } from "../queries/ai-chat-query"; +import { useDebouncedValue } from "@mantine/hooks"; +import { useTranslation } from "react-i18next"; +import classes from "../styles/aside-chat-panel.module.css"; + +type Props = { + activeChatId: string | undefined; + onSelect: (chatId: string) => void; +}; + +export default function AsideChatHistory({ activeChatId, onSelect }: Props) { + const { t } = useTranslation(); + const [searchValue, setSearchValue] = useState(""); + const [debouncedSearch] = useDebouncedValue(searchValue, 300); + + const chatsQuery = useChatsQuery(); + const searchQuery = useSearchChatsQuery(debouncedSearch); + + const isSearching = debouncedSearch.length > 0; + const chats = isSearching + ? (searchQuery.data ?? []) + : (chatsQuery.data?.pages.flatMap((p) => p.items) ?? []); + const isLoading = isSearching ? searchQuery.isLoading : chatsQuery.isLoading; + + return ( +
+ } + size="xs" + mb="xs" + value={searchValue} + onChange={(e) => setSearchValue(e.currentTarget.value)} + /> + + {isLoading ? ( +
+ +
+ ) : chats.length === 0 ? ( + + {isSearching ? t("No chats found") : t("No chat history")} + + ) : ( + +
+ {chats.map((chat) => ( +
onSelect(chat.id)} + > + + {chat.title || t("Untitled chat")} + +
+ ))} +
+
+ )} +
+ ); +} diff --git a/apps/client/src/ee/ai-chat/components/aside-chat-panel.tsx b/apps/client/src/ee/ai-chat/components/aside-chat-panel.tsx new file mode 100644 index 00000000..11c0d0c9 --- /dev/null +++ b/apps/client/src/ee/ai-chat/components/aside-chat-panel.tsx @@ -0,0 +1,249 @@ +import { useState, useEffect, useCallback } from "react"; +import { ActionIcon, Popover, Tooltip, UnstyledButton } from "@mantine/core"; +import { + IconPlus, + IconChevronDown, + IconArrowsDiagonal, + IconX, + IconSparkles, + IconFileText, + IconLanguage, + IconSearch, +} from "@tabler/icons-react"; +import { useAtom } from "jotai"; +import { useNavigate, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom"; +import { usePageQuery } from "@/features/page/queries/page-query"; +import { extractPageSlugId } from "@/lib"; +import { useChatStream } from "../hooks/use-chat-stream"; +import { useChatInfoQuery } from "../queries/ai-chat-query"; +import ChatMessageList from "./chat-message-list"; +import ChatInput from "./chat-input"; +import AsideChatHistory from "./aside-chat-history"; +import type { ChatAttachment, PageMention } from "../types/ai-chat.types"; +import classes from "../styles/aside-chat-panel.module.css"; + +type QuickAction = { + icon: React.ReactNode; + label: string; + prompt: string; +}; + +export default function AsideChatPanel() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [, setAsideState] = useAtom(asideStateAtom); + const [chatId, setChatId] = useState(undefined); + const [historyOpen, setHistoryOpen] = useState(false); + const [contextPages, setContextPages] = useState([]); + const { pageSlug } = useParams(); + const slugId = extractPageSlugId(pageSlug); + const { data: page } = usePageQuery({ pageId: slugId }); + + const chatInfoQuery = useChatInfoQuery(chatId); + const { + messages, + streamingContent, + streamingToolCalls, + isStreaming, + error, + sendMessage, + stopGeneration, + hydrateFromServer, + } = useChatStream(chatId, { + onChatCreated: (newChatId) => { + setChatId(newChatId); + }, + }); + + useEffect(() => { + if (page && !chatId) { + setContextPages([{ id: page.id, title: page.title || "", slugId: page.slugId }]); + } + }, [page, chatId]); + + const handleRemoveContextPage = useCallback((pageId: string) => { + setContextPages((prev) => prev.filter((p) => p.id !== pageId)); + }, []); + + useEffect(() => { + if (chatInfoQuery.data?.messages) { + hydrateFromServer(chatInfoQuery.data.messages); + } + }, [chatInfoQuery.data, hydrateFromServer]); + + const handleNewChat = useCallback( + (event: React.MouseEvent) => { + if ( + event.button !== 0 || + event.ctrlKey || + event.metaKey || + event.shiftKey + ) { + return; + } + event.preventDefault(); + setChatId(undefined); + if (page) { + setContextPages([ + { id: page.id, title: page.title || "", slugId: page.slugId }, + ]); + } + }, + [page], + ); + + const handleSelectChat = useCallback((selectedChatId: string) => { + setChatId(selectedChatId); + setHistoryOpen(false); + }, []); + + const handleExpand = useCallback(() => { + if (chatId) { + navigate(`/ai/chat/${chatId}`); + } else { + navigate("/ai"); + } + setAsideState({ tab: "", isAsideOpen: false }); + }, [chatId, navigate, setAsideState]); + + const handleClose = useCallback(() => { + setAsideState({ tab: "", isAsideOpen: false }); + }, [setAsideState]); + + const handleSend = useCallback( + (content: string, mentions: PageMention[], attachments: ChatAttachment[]) => { + const contextPageId = contextPages.length > 0 ? contextPages[0].id : undefined; + sendMessage(content, mentions, attachments, contextPageId); + }, + [sendMessage, contextPages], + ); + + const handleQuickAction = useCallback( + (prompt: string) => { + handleSend(prompt, [], []); + }, + [handleSend], + ); + + const hasMessages = messages.length > 0 || isStreaming; + + const quickActions: QuickAction[] = [ + { icon: , label: t("Summarize this page"), prompt: "Summarize this page" }, + { icon: , label: t("Translate this page"), prompt: "Translate this page" }, + { icon: , label: t("Analyze for insights"), prompt: "Analyze this page for insights" }, + ]; + + return ( +
+
+ + + setHistoryOpen((o) => !o)} + > + + {chatInfoQuery.data?.chat?.title || t("New chat")} + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + +
+ + {error && ( +
+ {error} +
+ )} + + {hasMessages ? ( + <> +
+ +
+ + ) : ( +
+ +
{t("How can I help you today?")}
+
+ {quickActions.map((action) => ( + + ))} +
+
+ )} + +
+ +
+
+ ); +} diff --git a/apps/client/src/ee/ai-chat/components/chat-empty-state.tsx b/apps/client/src/ee/ai-chat/components/chat-empty-state.tsx index 70f3a4e1..d7bacf18 100644 --- a/apps/client/src/ee/ai-chat/components/chat-empty-state.tsx +++ b/apps/client/src/ee/ai-chat/components/chat-empty-state.tsx @@ -5,6 +5,7 @@ import { IconEdit, IconFileText, } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; import ChatInput from "./chat-input"; import type { ChatAttachment, PageMention } from "../types/ai-chat.types"; import classes from "../styles/ai-chat.module.css"; @@ -45,6 +46,8 @@ type Props = { }; export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) { + const { t } = useTranslation(); + const handleSuggestionClick = (prompt: string) => { onSend(prompt, [], []); }; @@ -52,7 +55,10 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) { return (
-
What can I help you with?
+
{t("Docmost AI")}
+
+ {t("What can I help you with?")} +
void; placeholder?: string; autofocus?: boolean; + contextPages?: PageMention[]; + onRemoveContextPage?: (pageId: string) => void; + variant?: "card" | "flat"; + showDisclaimer?: boolean; + chatId?: string; }; function extractMentions(json: any): PageMention[] { @@ -84,9 +95,18 @@ export default function ChatInput({ onStop, placeholder, autofocus = true, + contextPages, + onRemoveContextPage, + variant = "card", + showDisclaimer = true, + chatId, }: Props) { + const chatIdRef = useRef(chatId); + chatIdRef.current = chatId; + const { t } = useTranslation(); const [isEmpty, setIsEmpty] = useState(true); const [pendingAttachments, setPendingAttachments] = useState([]); + const [plusMenuOpen, setPlusMenuOpen] = useState(false); const fileInputRef = useRef(null); const onSendRef = useRef(onSend); onSendRef.current = onSend; @@ -94,7 +114,32 @@ export default function ChatInput({ const handleFileSelect = useCallback(async (files: FileList | null) => { if (!files?.length) return; - for (const file of Array.from(files)) { + const room = MAX_ATTACHMENTS_PER_MESSAGE - pendingAttachments.length; + if (room <= 0) { + notifications.show({ + color: "yellow", + message: t("You can attach up to {{max}} files per message.", { + max: MAX_ATTACHMENTS_PER_MESSAGE, + }), + }); + if (fileInputRef.current) fileInputRef.current.value = ""; + return; + } + + const incoming = Array.from(files); + const accepted = incoming.slice(0, room); + + if (incoming.length > accepted.length) { + notifications.show({ + color: "yellow", + message: t( + "Only the first {{n}} file(s) were added (max {{max}} per message).", + { n: accepted.length, max: MAX_ATTACHMENTS_PER_MESSAGE }, + ), + }); + } + + for (const file of accepted) { const tempId = `uploading-${Date.now()}-${Math.random()}`; const ext = file.name.split(".").pop()?.toLowerCase() || ""; @@ -110,7 +155,7 @@ export default function ChatInput({ setPendingAttachments((prev) => [...prev, placeholder]); try { - const uploaded = await uploadChatFile(file); + const uploaded = await uploadChatFile(file, chatIdRef.current); setPendingAttachments((prev) => prev.map((a) => a.id === tempId ? { ...uploaded, uploading: false } : a, @@ -124,7 +169,7 @@ export default function ChatInput({ if (fileInputRef.current) { fileInputRef.current.value = ""; } - }, []); + }, [pendingAttachments.length, t]); const removeAttachment = useCallback((id: string) => { setPendingAttachments((prev) => prev.filter((a) => a.id !== id)); @@ -157,6 +202,9 @@ export default function ChatInput({ Placeholder.configure({ placeholder: placeholder || "Ask anything... Use @ to mention pages", }), + CharacterCount.configure({ + limit: 50000, + }), LinkExtension, EmojiCommand, Mention.configure({ @@ -215,10 +263,13 @@ export default function ChatInput({ } }, [editor]); - const hasContent = !isEmpty || pendingAttachments.some((a) => !a.uploading); + const hasContent = !isEmpty || pendingAttachments.some((a) => !a.uploading) || (contextPages?.length ?? 0) > 0; + + const wrapperClass = variant === "flat" ? classes.inputWrapperFlat : classes.inputWrapper; return ( -
+ <> +
handleFileSelect(e.target.files)} /> - {pendingAttachments.length > 0 && ( + {((contextPages?.length ?? 0) > 0 || pendingAttachments.length > 0) && (
+ {contextPages?.map((page) => ( +
+ + + {page.title || "Untitled"} + + {onRemoveContextPage && ( + + )} +
+ ))} {pendingAttachments.map((attachment) => (
- + + + + + + + + +
@@ -293,5 +399,11 @@ export default function ChatInput({ )}
+ {showDisclaimer && ( +
+ {t("AI-generated content may not be accurate.")} +
+ )} + ); } diff --git a/apps/client/src/ee/ai-chat/components/chat-message-list.tsx b/apps/client/src/ee/ai-chat/components/chat-message-list.tsx index 9cf66b63..3a6fffef 100644 --- a/apps/client/src/ee/ai-chat/components/chat-message-list.tsx +++ b/apps/client/src/ee/ai-chat/components/chat-message-list.tsx @@ -1,8 +1,21 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useCallback, useState } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { IconArrowDown, IconAlertTriangle } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types"; import ChatMessage from "./chat-message"; import classes from "../styles/ai-chat.module.css"; +function ChatMessageErrorFallback() { + const { t } = useTranslation(); + return ( +
+ + {t("Failed to render this message.")} +
+ ); +} + type Props = { messages: AiChatMessage[]; isStreaming: boolean; @@ -10,40 +23,152 @@ type Props = { streamingToolCalls: AiChatToolCall[]; }; +const BOTTOM_THRESHOLD_PX = 32; +const SCROLL_UP_THRESHOLD_PX = 5; +const SMOOTH_SCROLL_SETTLE_MS = 600; + export default function ChatMessageList({ messages, isStreaming, streamingContent, streamingToolCalls, }: Props) { + const containerRef = useRef(null); const bottomRef = useRef(null); + const isAtBottomRef = useRef(true); + const isAutoScrollingRef = useRef(false); + const prevScrollTopRef = useRef(0); + const [showScrollButton, setShowScrollButton] = useState(false); + + const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { + const container = containerRef.current; + if (!container) return; + + isAutoScrollingRef.current = true; + const target = container.scrollHeight - container.clientHeight; + container.scrollTo({ top: target, behavior }); + prevScrollTopRef.current = target; + isAtBottomRef.current = true; + setShowScrollButton(false); + + if (behavior === "smooth") { + setTimeout(() => { + isAutoScrollingRef.current = false; + if (containerRef.current) { + prevScrollTopRef.current = containerRef.current.scrollTop; + } + }, SMOOTH_SCROLL_SETTLE_MS); + } else { + isAutoScrollingRef.current = false; + } + }, []); + + const handleScroll = useCallback(() => { + if (isAutoScrollingRef.current) return; + + const container = containerRef.current; + if (!container) return; + + const currentScrollTop = container.scrollTop; + const scrolledUp = + currentScrollTop < prevScrollTopRef.current - SCROLL_UP_THRESHOLD_PX; + prevScrollTopRef.current = currentScrollTop; + + const distanceFromBottom = + container.scrollHeight - currentScrollTop - container.clientHeight; + const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX; + + if (scrolledUp) { + isAtBottomRef.current = atBottom; + } else if (atBottom) { + isAtBottomRef.current = true; + } + + setShowScrollButton(!atBottom); + }, []); useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages.length, streamingContent, streamingToolCalls.length]); + const container = containerRef.current; + if (!container) return; + + container.addEventListener("scroll", handleScroll, { passive: true }); + return () => container.removeEventListener("scroll", handleScroll); + }, [handleScroll]); + + // Instant scroll during streaming to keep up with rapid updates + useEffect(() => { + if (isAtBottomRef.current) { + scrollToBottom("instant"); + } + }, [streamingContent, streamingToolCalls.length, scrollToBottom]); + + // Smooth scroll for new messages. Always force-scroll when the latest + // message is from the user (they just sent it), even if they were reading + // scrollback. + useEffect(() => { + const lastMessage = messages[messages.length - 1]; + const lastIsUser = lastMessage?.role === "user"; + if (lastIsUser || isAtBottomRef.current) { + scrollToBottom("smooth"); + return; + } + + // No auto-scroll: recompute from actual layout so that chat switches to + // content that doesn't overflow correctly hide the button even when no + // scroll event fires. + const container = containerRef.current; + if (!container) return; + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX; + isAtBottomRef.current = atBottom; + setShowScrollButton(!atBottom); + }, [messages, scrollToBottom]); return ( -
- {messages.map((msg) => ( - - ))} - {isStreaming && ( - +
+
+ {messages.map((msg) => ( + } + > + + + ))} + {isStreaming && ( + } + > + + + )} +
+
+ {showScrollButton && ( + )} -
); } diff --git a/apps/client/src/ee/ai-chat/components/chat-message.tsx b/apps/client/src/ee/ai-chat/components/chat-message.tsx index fa3eb11c..d0e9443a 100644 --- a/apps/client/src/ee/ai-chat/components/chat-message.tsx +++ b/apps/client/src/ee/ai-chat/components/chat-message.tsx @@ -1,11 +1,31 @@ import { useCallback } from "react"; import { useNavigate } from "react-router"; import DOMPurify from "dompurify"; -import { IconFile, IconLoader2, IconPhoto } from "@tabler/icons-react"; +import { ActionIcon, Tooltip } from "@mantine/core"; +import { + IconCheck, + IconCopy, + IconFile, + IconLoader2, + IconPhoto, +} from "@tabler/icons-react"; import { markdownToHtml } from "@docmost/editor-ext"; +import { CopyButton } from "@/components/common/copy-button"; import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types"; -import ChatToolResult from "./chat-tool-result"; +import ChatToolGroup from "./chat-tool-group"; import classes from "../styles/chat-message.module.css"; +import CopyTextButton from "@/components/common/copy.tsx"; + +const chatSanitizer = DOMPurify(); +chatSanitizer.addHook("afterSanitizeAttributes", (node) => { + if (node.tagName === "A") { + const href = node.getAttribute("href") || ""; + if (href.startsWith("http://") || href.startsWith("https://")) { + node.setAttribute("target", "_blank"); + node.setAttribute("rel", "noopener noreferrer"); + } + } +}); const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"]; @@ -50,7 +70,12 @@ export default function ChatMessage({ /\n\n[\s\S]*<\/referenced_pages>$/, "", ); - const attachments = (message.metadata?.attachments as { id: string; fileName: string; fileExt: string }[]) || []; + const attachments = + (message.metadata?.attachments as { + id: string; + fileName: string; + fileExt: string; + }[]) || []; return (
@@ -78,15 +103,16 @@ export default function ChatMessage({ return (
- {toolCalls?.map((tc) => ( - - ))} + {toolCalls && toolCalls.length > 0 && ( + + )} {content && (
@@ -103,6 +129,11 @@ export default function ChatMessage({ )}
+ {!isStreaming && message.content && ( +
+ +
+ )}
); } diff --git a/apps/client/src/ee/ai-chat/components/chat-tool-group.tsx b/apps/client/src/ee/ai-chat/components/chat-tool-group.tsx new file mode 100644 index 00000000..b4e002f6 --- /dev/null +++ b/apps/client/src/ee/ai-chat/components/chat-tool-group.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import { + IconChevronRight, + IconChevronDown, + IconLoader2, +} from "@tabler/icons-react"; +import type { AiChatToolCall } from "../types/ai-chat.types"; +import ChatToolResult, { TOOL_LABELS } from "./chat-tool-result"; +import classes from "../styles/chat-message.module.css"; + +type Props = { + toolCalls: AiChatToolCall[]; + isStreaming?: boolean; +}; + +export default function ChatToolGroup({ toolCalls, isStreaming }: Props) { + const [expanded, setExpanded] = useState(false); + + if (!toolCalls || toolCalls.length === 0) return null; + + const activeCall = + isStreaming && toolCalls.length > 0 + ? [...toolCalls].reverse().find((tc) => tc.result === undefined) + : undefined; + + const activeLabel = activeCall + ? TOOL_LABELS[activeCall.name] || activeCall.name + : null; + + return ( +
+
setExpanded((prev) => !prev)} + > + {activeLabel ? ( + + ) : expanded ? ( + + ) : ( + + )} + + {activeLabel ? `${activeLabel}…` : `Steps ${toolCalls.length}`} + +
+ {expanded && ( +
+ {toolCalls.map((tc) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/client/src/ee/ai-chat/components/chat-tool-result.tsx b/apps/client/src/ee/ai-chat/components/chat-tool-result.tsx index d21565b8..55f20f91 100644 --- a/apps/client/src/ee/ai-chat/components/chat-tool-result.tsx +++ b/apps/client/src/ee/ai-chat/components/chat-tool-result.tsx @@ -1,9 +1,9 @@ import { useState } from "react"; -import { IconChevronRight, IconChevronDown, IconTool } from "@tabler/icons-react"; +import { IconChevronRight, IconChevronDown } from "@tabler/icons-react"; import type { AiChatToolCall } from "../types/ai-chat.types"; import classes from "../styles/chat-message.module.css"; -const TOOL_LABELS: Record = { +export const TOOL_LABELS: Record = { list_spaces: "Listed spaces", search_pages: "Searched pages", get_page: "Read page", @@ -20,21 +20,21 @@ export default function ChatToolResult({ toolCall }: Props) { const label = TOOL_LABELS[toolCall.name] || toolCall.name; return ( -
+
setExpanded((prev) => !prev)} > - - {label} + · {expanded ? ( - + ) : ( - + )} + {label}
{expanded && ( -
+
             {JSON.stringify(
               { args: toolCall.args, result: toolCall.result },
diff --git a/apps/client/src/ee/ai-chat/components/enable-ai-chat.tsx b/apps/client/src/ee/ai-chat/components/enable-ai-chat.tsx
index c91e045f..f4200e5b 100644
--- a/apps/client/src/ee/ai-chat/components/enable-ai-chat.tsx
+++ b/apps/client/src/ee/ai-chat/components/enable-ai-chat.tsx
@@ -1,12 +1,13 @@
-import { Group, Text, Switch } from "@mantine/core";
+import { Badge, Group, Text, Switch, Tooltip } from "@mantine/core";
 import { useAtom } from "jotai";
 import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
 import { 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";
+import { useHasFeature } from "@/ee/hooks/use-feature";
+import { Feature } from "@/ee/features";
+import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
 
 export default function EnableAiChat() {
   const { t } = useTranslation();
@@ -14,7 +15,12 @@ export default function EnableAiChat() {
   return (
     
       
- {t("AI Chat")} + + {t("AI Chat")} + + {t("Beta")} + + {t( "Enable AI Chat to allow users to have multi-turn conversations with AI about your workspace content.", @@ -31,9 +37,8 @@ function AiChatToggle() { const { t } = useTranslation(); const [workspace, setWorkspace] = useAtom(workspaceAtom); const [checked, setChecked] = useState(workspace?.settings?.ai?.chat); - const { hasLicenseKey } = useLicense(); - - const hasAccess = isCloud() || (!isCloud() && hasLicenseKey); + const hasAccess = useHasFeature(Feature.AI); + const upgradeLabel = useUpgradeLabel(); const handleChange = async (event: React.ChangeEvent) => { const value = event.currentTarget.checked; @@ -50,11 +55,13 @@ function AiChatToggle() { }; return ( - + + + ); } diff --git a/apps/client/src/ee/ai-chat/hooks/use-chat-stream.ts b/apps/client/src/ee/ai-chat/hooks/use-chat-stream.ts index 0a23b713..65a93a97 100644 --- a/apps/client/src/ee/ai-chat/hooks/use-chat-stream.ts +++ b/apps/client/src/ee/ai-chat/hooks/use-chat-stream.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "react-router-dom"; import { sendChatMessage } from "../services/ai-chat-service"; @@ -10,7 +10,14 @@ import type { PageMention, } from "../types/ai-chat.types"; -export function useChatStream(chatId: string | undefined) { +type ChatStreamOptions = { + onChatCreated?: (chatId: string) => void; +}; + +export function useChatStream( + chatId: string | undefined, + options?: ChatStreamOptions, +) { const [messages, setMessages] = useState([]); const [streamingContent, setStreamingContent] = useState(""); const [streamingToolCalls, setStreamingToolCalls] = useState( @@ -18,21 +25,46 @@ export function useChatStream(chatId: string | undefined) { ); const [isStreaming, setIsStreaming] = useState(false); const [error, setError] = useState(null); + const [errorCode, setErrorCode] = useState(null); + const [isRetryable, setIsRetryable] = useState(false); const abortRef = useRef(null); const queryClient = useQueryClient(); const navigate = useNavigate(); const currentChatIdRef = useRef(chatId); currentChatIdRef.current = chatId; + // Tracks which chatId the local `messages` state currently represents. + // Set when we seed from a server fetch AND when we optimistically own a + // freshly-created chat after `chat_created`. This is the single authority + // marker that keeps server-state effects from clobbering in-flight streams. + const hydratedChatIdRef = useRef(undefined); - const initMessages = useCallback((msgs: AiChatMessage[]) => { + // Reset local state when the consumer switches to a different chat. + // Skip the reset if the new chatId is one the hook itself already claimed + // during a new-chat flow — in that case our optimistic state is the truth. + useEffect(() => { + if (chatId && chatId === hydratedChatIdRef.current) return; + hydratedChatIdRef.current = undefined; + setMessages([]); + setError(null); + setErrorCode(null); + setIsRetryable(false); + }, [chatId]); + + const hydrateFromServer = useCallback((msgs: AiChatMessage[]) => { + const forId = currentChatIdRef.current; + if (!forId) return; + if (hydratedChatIdRef.current === forId) return; + hydratedChatIdRef.current = forId; setMessages(msgs); }, []); const sendMessage = useCallback( - (content: string, mentions: PageMention[] = [], attachments: ChatAttachment[] = []) => { + (content: string, mentions: PageMention[] = [], attachments: ChatAttachment[] = [], contextPageId?: string) => { if (isStreaming || (!content.trim() && attachments.length === 0)) return; setError(null); + setErrorCode(null); + setIsRetryable(false); setIsStreaming(true); setStreamingContent(""); setStreamingToolCalls([]); @@ -68,13 +100,22 @@ export function useChatStream(chatId: string | undefined) { chatId: currentChatIdRef.current, content, mentionedPageIds: mentions.map((m) => m.id), + ...(contextPageId && { contextPageId }), ...(attachmentIds.length && { attachmentIds }), }, (event: AiChatStreamEvent) => { switch (event.type) { case "chat_created": currentChatIdRef.current = event.chatId; - navigate(`/ai/chat/${event.chatId}`, { replace: true }); + // Claim authority over this new chatId so when the consumer's + // prop catches up via navigation/onChatCreated, the reset effect + // sees a match and preserves our optimistic messages. + hydratedChatIdRef.current = event.chatId; + if (options?.onChatCreated) { + options.onChatCreated(event.chatId); + } else { + navigate(`/ai/chat/${event.chatId}`, { replace: true }); + } queryClient.invalidateQueries({ queryKey: ["ai-chats"] }); break; case "content": @@ -125,6 +166,8 @@ export function useChatStream(chatId: string | undefined) { } case "error": setError(event.message); + setErrorCode(event.code || null); + setIsRetryable(event.retryable || false); setIsStreaming(false); break; } @@ -175,8 +218,10 @@ export function useChatStream(chatId: string | undefined) { streamingToolCalls, isStreaming, error, + errorCode, + isRetryable, sendMessage, stopGeneration, - initMessages, + hydrateFromServer, }; } diff --git a/apps/client/src/ee/ai-chat/pages/ai-chat.tsx b/apps/client/src/ee/ai-chat/pages/ai-chat.tsx index f9b751d0..bb264b73 100644 --- a/apps/client/src/ee/ai-chat/pages/ai-chat.tsx +++ b/apps/client/src/ee/ai-chat/pages/ai-chat.tsx @@ -1,10 +1,39 @@ +import { useParams } from "react-router-dom"; +import { ErrorBoundary } from "react-error-boundary"; +import { Button } from "@mantine/core"; +import { IconAlertTriangle } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; import AiChatLayout from "../components/ai-chat-layout"; +import { EmptyState } from "@/components/ui/empty-state.tsx"; import classes from "../styles/ai-chat.module.css"; export default function AiChat() { + const { t } = useTranslation(); + const { chatId } = useParams<{ chatId: string }>(); + return (
- + ( + + {t("Try again")} + + } + /> + )} + > + +
); } diff --git a/apps/client/src/ee/ai-chat/services/ai-chat-service.ts b/apps/client/src/ee/ai-chat/services/ai-chat-service.ts index 70faa4e7..2932372e 100644 --- a/apps/client/src/ee/ai-chat/services/ai-chat-service.ts +++ b/apps/client/src/ee/ai-chat/services/ai-chat-service.ts @@ -8,7 +8,7 @@ import type { import { IPagination } from "@/lib/types.ts"; export async function createChat(): Promise { - const req = await api.post("/ai-chat/create"); + const req = await api.post("/ai/chats/create"); return req.data; } @@ -16,37 +16,43 @@ export async function listChats(params?: { limit?: number; cursor?: string; }): Promise> { - const req = await api.post("/ai-chat/list", params); + const req = await api.post("/ai/chats", params); return req.data; } export async function getChatInfo( chatId: string, ): Promise<{ chat: AiChat; messages: AiChatMessage[] }> { - const req = await api.post("/ai-chat/info", { chatId }); + const req = await api.post("/ai/chats/info", { chatId }); return req.data; } export async function deleteChat(chatId: string): Promise { - await api.post("/ai-chat/delete", { chatId }); + await api.post("/ai/chats/delete", { chatId }); } export async function updateChatTitle( chatId: string, title: string, ): Promise { - await api.post("/ai-chat/update", { chatId, title }); + await api.post("/ai/chats/update", { chatId, title }); } export async function searchChats(query: string): Promise { - const req = await api.post("/ai-chat/search", { query }); + const req = await api.post("/ai/chats/search", { query }); return req.data; } -export async function uploadChatFile(file: File): Promise { +export async function uploadChatFile( + file: File, + chatId?: string, +): Promise { const formData = new FormData(); formData.append("file", file); - return await api.post("/ai-chat/upload", formData, { + if (chatId) { + formData.append("chatId", chatId); + } + return await api.post("/ai/chats/upload", formData, { headers: { "Content-Type": "multipart/form-data" }, }); } @@ -56,6 +62,7 @@ export function sendChatMessage( chatId?: string; content: string; mentionedPageIds?: string[]; + contextPageId?: string; attachmentIds?: string[]; }, onEvent: (event: AiChatStreamEvent) => void, @@ -66,7 +73,7 @@ export function sendChatMessage( (async () => { try { - const response = await fetch("/api/ai-chat/send", { + const response = await fetch("/api/ai/chats/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params), diff --git a/apps/client/src/ee/ai-chat/styles/ai-chat.module.css b/apps/client/src/ee/ai-chat/styles/ai-chat.module.css index b4ca5a16..27b0f0c0 100644 --- a/apps/client/src/ee/ai-chat/styles/ai-chat.module.css +++ b/apps/client/src/ee/ai-chat/styles/ai-chat.module.css @@ -14,11 +14,62 @@ width: 100%; } +.messageListWrapper { + flex: 1; + position: relative; + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + width: 100%; +} + .messageList { flex: 1; overflow-y: auto; padding: var(--mantine-spacing-md) var(--mantine-spacing-lg); - scroll-behavior: smooth; +} + +.messageErrorFallback { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + margin-bottom: var(--mantine-spacing-lg); + border-radius: var(--mantine-radius-sm); + background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); + color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); + font-size: var(--mantine-font-size-xs); +} + +.scrollToBottomButton { + position: absolute; + bottom: 12px; + left: 50%; + transform: translateX(-50%); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6)); + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); + cursor: pointer; + padding: 0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + transition: background-color 120ms ease, border-color 120ms ease, transform 120ms ease; + z-index: 2; +} + +.scrollToBottomButton:hover { + background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5)); + border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3)); +} + +.scrollToBottomButton:active { + transform: translateX(-50%) scale(0.95); } .inputArea { @@ -38,10 +89,19 @@ .emptyStateIcon { width: 48px; height: 48px; - margin-bottom: var(--mantine-spacing-lg); + margin-bottom: var(--mantine-spacing-sm); color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); } +.emptyStateBrand { + font-size: var(--mantine-font-size-xs); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); + margin-bottom: var(--mantine-spacing-xs); +} + .emptyStateTitle { font-size: 1.5rem; font-weight: 600; diff --git a/apps/client/src/ee/ai-chat/styles/aside-chat-panel.module.css b/apps/client/src/ee/ai-chat/styles/aside-chat-panel.module.css new file mode 100644 index 00000000..f8958615 --- /dev/null +++ b/apps/client/src/ee/ai-chat/styles/aside-chat-panel.module.css @@ -0,0 +1,139 @@ +.panel { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 0 0 var(--mantine-spacing-sm) 0; + border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); +} + +.toolbarSpacer { + flex: 1; +} + +.titleButton { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: var(--mantine-radius-sm); + font-size: var(--mantine-font-size-sm); + font-weight: 500; + color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0)); + max-width: 60%; + min-width: 0; +} + +.titleButton:hover { + background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6)); +} + +.titleText { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.messages { + flex: 1; + overflow-y: auto; + padding: var(--mantine-spacing-sm) 0; + scroll-behavior: smooth; +} + +.inputArea { + padding-top: var(--mantine-spacing-sm); +} + +.emptyState { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--mantine-spacing-md); + padding: var(--mantine-spacing-xl) var(--mantine-spacing-sm); +} + +.emptyStateIcon { + color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); +} + +.emptyStateTitle { + font-size: var(--mantine-font-size-lg); + font-weight: 600; + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); + text-align: center; +} + +.quickActions { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; +} + +.quickAction { + display: flex; + align-items: center; + gap: var(--mantine-spacing-sm); + padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm); + border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); + border-radius: var(--mantine-radius-md); + cursor: pointer; + background: transparent; + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); + font-size: var(--mantine-font-size-sm); + text-align: left; + width: 100%; + transition: background-color 150ms, border-color 150ms; + + @mixin hover { + background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); + border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + } +} + +.quickActionIcon { + flex-shrink: 0; + color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); +} + +.historyList { + max-height: 300px; + overflow-y: auto; +} + +.historyItem { + display: flex; + align-items: center; + padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm); + cursor: pointer; + border-radius: var(--mantine-radius-sm); + font-size: var(--mantine-font-size-sm); + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); + transition: background-color 150ms; + + @mixin hover { + background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); + } + + &[data-active] { + background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5)); + } +} + +.historyItemTitle { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/apps/client/src/ee/ai-chat/styles/chat-input.module.css b/apps/client/src/ee/ai-chat/styles/chat-input.module.css index 6f6d8f53..20b287c1 100644 --- a/apps/client/src/ee/ai-chat/styles/chat-input.module.css +++ b/apps/client/src/ee/ai-chat/styles/chat-input.module.css @@ -1,21 +1,51 @@ .inputWrapper { position: relative; - border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + overflow: hidden; + border: 1px solid + light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); border-radius: 16px; background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7)); - box-shadow: - 0 1px 3px rgba(0, 0, 0, 0.04), - 0 4px 12px rgba(0, 0, 0, 0.06); - transition: border-color 150ms, box-shadow 150ms; + box-shadow: light-dark( + 0 2px 40px 4px rgba(0, 0, 0, 0.07), + 0 2px 40px 4px rgba(0, 0, 0, 0.5) + ); + transition: + border-color 150ms, + box-shadow 150ms; &:focus-within { - border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3)); - box-shadow: - 0 1px 3px rgba(0, 0, 0, 0.04), - 0 4px 16px rgba(0, 0, 0, 0.1); + border-color: light-dark( + var(--mantine-color-gray-3), + var(--mantine-color-dark-4) + ); + box-shadow: light-dark( + 0 4px 48px 6px rgba(0, 0, 0, 0.09), + 0 4px 48px 6px rgba(0, 0, 0, 0.6) + ); } } +.inputWrapperFlat { + position: relative; + overflow: hidden; + border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); + border-radius: 12px; + background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7)); + box-shadow: none; + transition: border-color 150ms; + + &:focus-within { + border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + } +} + +.disclaimer { + margin-top: 6px; + text-align: center; + font-size: var(--mantine-font-size-xs); + color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); +} + .attachmentChips { display: flex; flex-wrap: wrap; @@ -68,6 +98,7 @@ :global(.ProseMirror) { outline: none; border: none; + background-color: transparent; padding: 14px 18px 8px; font-size: 15px; line-height: 1.6; @@ -142,6 +173,54 @@ } } +.plusButton { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 50%; + border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + background: none; + cursor: pointer; + color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); + transition: color 150ms, background-color 150ms; + + @mixin hover { + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); + background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); + } +} + +.plusMenuItem { + display: flex; + align-items: center; + gap: var(--mantine-spacing-sm); + padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm); + border: none; + background: none; + cursor: pointer; + width: 100%; + font-size: var(--mantine-font-size-sm); + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); + border-radius: var(--mantine-radius-sm); + transition: background-color 150ms; + + @mixin hover { + background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + background: none; + } +} + +.plusMenuIcon { + color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); +} + .stopButton { width: 28px; height: 28px; diff --git a/apps/client/src/ee/ai-chat/styles/chat-message.module.css b/apps/client/src/ee/ai-chat/styles/chat-message.module.css index 2390d45b..33e39dd6 100644 --- a/apps/client/src/ee/ai-chat/styles/chat-message.module.css +++ b/apps/client/src/ee/ai-chat/styles/chat-message.module.css @@ -20,6 +20,11 @@ overflow-wrap: break-word; } +[data-aside-chat] .userBubble { + background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7)); + border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); +} + .userBubble p { margin: 0; } @@ -165,32 +170,82 @@ margin: 1em 0; } -.toolCallCard { - margin: var(--mantine-spacing-xs) 0; - padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm); - border-radius: var(--mantine-radius-sm); - border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5)); - background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7)); +.toolGroup { + margin: 6px 0; font-size: var(--mantine-font-size-xs); } -.toolCallHeader { - display: flex; +.toolGroupHeader { + display: inline-flex; align-items: center; - gap: var(--mantine-spacing-xs); + gap: 6px; cursor: pointer; user-select: none; + color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); + line-height: 1.4; + transition: color 120ms ease; } -.toolCallName { - font-weight: 600; - color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-4)); +.toolGroupHeader:hover { + color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0)); } -.toolCallDetails { - margin-top: var(--mantine-spacing-xs); - padding-top: var(--mantine-spacing-xs); - border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); +.toolGroupLabel { + font-weight: 500; +} + +.toolGroupSteps { + margin-top: 4px; + padding-left: 14px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.toolStep { + font-size: var(--mantine-font-size-xs); +} + +.toolStepRow { + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + user-select: none; + color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); + line-height: 1.5; + transition: color 120ms ease; +} + +.toolStepRow:hover { + color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0)); +} + +.toolStepBullet { + display: inline-block; + width: 8px; + text-align: center; + opacity: 0.6; +} + +.toolStepDetails { + margin-top: 4px; + margin-left: 18px; + padding: 6px 10px; + border-radius: var(--mantine-radius-sm); + background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7)); + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); + font-size: 11px; + line-height: 1.5; + overflow-x: auto; +} + +.messageActions { + display: flex; + align-items: center; + gap: 4px; + margin-top: 4px; + color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); } .processingIndicator { diff --git a/apps/client/src/ee/ai-chat/styles/chat-sidebar.module.css b/apps/client/src/ee/ai-chat/styles/chat-sidebar.module.css index d0848001..c7991147 100644 --- a/apps/client/src/ee/ai-chat/styles/chat-sidebar.module.css +++ b/apps/client/src/ee/ai-chat/styles/chat-sidebar.module.css @@ -28,6 +28,46 @@ overflow-y: auto; } +.chatGroup + .chatGroup { + margin-top: var(--mantine-spacing-sm); +} + +.chatGroupLabel { + padding: 4px var(--mantine-spacing-xs); + font-size: var(--mantine-font-size-xs); + font-weight: 600; + color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); + user-select: none; +} + +.chatListEmpty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--mantine-spacing-xl) var(--mantine-spacing-md); + text-align: center; + gap: 4px; + user-select: none; +} + +.chatListEmptyIcon { + color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); + margin-bottom: var(--mantine-spacing-xs); +} + +.chatListEmptyTitle { + font-size: var(--mantine-font-size-sm); + font-weight: 600; + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); +} + +.chatListEmptyHint { + font-size: var(--mantine-font-size-xs); + color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3)); + line-height: 1.4; +} + .chatItem { display: flex; align-items: center; @@ -66,13 +106,33 @@ font-size: var(--mantine-font-size-xs); color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); white-space: nowrap; + transition: opacity 150ms; +} + +.chatItemRenameInput { + font-size: var(--mantine-font-size-sm); + padding: 0; + height: auto; + min-height: 0; + background: transparent; + color: inherit; +} + +.chatItem:hover .chatItemDate { + opacity: 0; } .chatItemActions { + position: absolute; + right: var(--mantine-spacing-xs); opacity: 0; transition: opacity 150ms; } +.chatItem { + position: relative; +} + .chatItem:hover .chatItemActions { opacity: 1; } diff --git a/apps/client/src/ee/ai-chat/types/ai-chat.types.ts b/apps/client/src/ee/ai-chat/types/ai-chat.types.ts index ac601b11..89754d26 100644 --- a/apps/client/src/ee/ai-chat/types/ai-chat.types.ts +++ b/apps/client/src/ee/ai-chat/types/ai-chat.types.ts @@ -30,7 +30,7 @@ export type AiChatStreamEvent = | { type: 'tool_call'; id: string; name: string; args: Record } | { type: 'tool_result'; id: string; result: unknown } | { type: 'done'; messageId: string; usage?: Record } - | { type: 'error'; message: string }; + | { type: 'error'; message: string; code?: string; retryable?: boolean }; export type PageMention = { id: string; diff --git a/apps/client/src/features/home/components/home-ai-prompt.module.css b/apps/client/src/features/home/components/home-ai-prompt.module.css new file mode 100644 index 00000000..e6d81606 --- /dev/null +++ b/apps/client/src/features/home/components/home-ai-prompt.module.css @@ -0,0 +1,28 @@ +.wrapper { + display: flex; + flex-direction: column; + align-items: center; + padding: var(--mantine-spacing-xl) var(--mantine-spacing-md) var(--mantine-spacing-lg); +} + +.heading { + font-size: 1.75rem; + font-weight: 600; + color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0)); + text-align: center; + margin: 0; + line-height: 1.2; +} + +.subtitle { + font-size: var(--mantine-font-size-sm); + color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); + text-align: center; + margin-top: 6px; + margin-bottom: var(--mantine-spacing-lg); +} + +.inputContainer { + width: 100%; + max-width: 640px; +} diff --git a/apps/client/src/features/home/components/home-ai-prompt.tsx b/apps/client/src/features/home/components/home-ai-prompt.tsx new file mode 100644 index 00000000..c3c69398 --- /dev/null +++ b/apps/client/src/features/home/components/home-ai-prompt.tsx @@ -0,0 +1,60 @@ +import { useAtomValue } from "jotai"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import ChatInput from "@/ee/ai-chat/components/chat-input"; +import type { + ChatAttachment, + PageMention, +} from "@/ee/ai-chat/types/ai-chat.types"; +import classes from "./home-ai-prompt.module.css"; + +export type HomeAiPromptInitialState = { + initialContent: string; + initialMentions: PageMention[]; + initialAttachments: ChatAttachment[]; +}; + +export default function HomeAiPrompt() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const workspace = useAtomValue(workspaceAtom); + + const aiChatEnabled = workspace?.settings?.ai?.chat === true; + if (!aiChatEnabled) return null; + + const handleSend = ( + content: string, + mentions: PageMention[], + attachments: ChatAttachment[], + ) => { + if (!content.trim() && attachments.length === 0) return; + const state: HomeAiPromptInitialState = { + initialContent: content, + initialMentions: mentions, + initialAttachments: attachments, + }; + navigate("/ai", { state }); + }; + + return ( +
+

+ {t("Welcome to {{name}}", { name: workspace?.name ?? "Docmost" })} +

+
+ {t("Ask anything or search your workspace")} +
+ +
+ {}} + placeholder={t("Ask anything... Use @ to mention pages")} + autofocus={false} + /> +
+
+ ); +} diff --git a/apps/client/src/features/search/components/search-control.module.css b/apps/client/src/features/search/components/search-control.module.css index 5e5a9c26..80be81d9 100644 --- a/apps/client/src/features/search/components/search-control.module.css +++ b/apps/client/src/features/search/components/search-control.module.css @@ -29,6 +29,8 @@ border-radius: var(--mantine-radius-sm); border: 1px solid; font-weight: bold; + white-space: nowrap; + flex-shrink: 0; @mixin light { color: var(--mantine-color-gray-7); diff --git a/apps/client/src/features/space/components/space-carousel.module.css b/apps/client/src/features/space/components/space-carousel.module.css new file mode 100644 index 00000000..87b9f1de --- /dev/null +++ b/apps/client/src/features/space/components/space-carousel.module.css @@ -0,0 +1,22 @@ +.card { + background-color: var(--mantine-color-body); + width: 220px; + + @mixin hover { + box-shadow: var(--mantine-shadow-xs); + transform: scale(1.02); + } +} + +.cardSection { + background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6)); +} + +.title { + font-family: + Greycliff CF, + var(--mantine-font-family); + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} diff --git a/apps/client/src/features/space/components/space-carousel.tsx b/apps/client/src/features/space/components/space-carousel.tsx new file mode 100644 index 00000000..f2932675 --- /dev/null +++ b/apps/client/src/features/space/components/space-carousel.tsx @@ -0,0 +1,77 @@ +import { Text, Card, rem, Group, Button } from "@mantine/core"; +import { + prefetchSpace, + useGetSpacesQuery, +} from "@/features/space/queries/space-query.ts"; +import { getSpaceUrl } from "@/lib/config.ts"; +import { Link } from "react-router-dom"; +import classes from "./space-carousel.module.css"; +import { formatMemberCount } from "@/lib"; +import { useTranslation } from "react-i18next"; +import { IconArrowRight } from "@tabler/icons-react"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; +import CardCarousel from "@/components/ui/card-carousel"; + +export default function SpaceCarousel() { + const { t } = useTranslation(); + const { data } = useGetSpacesQuery({ limit: 20 }); + + const cards = data?.items.map((space) => ( + prefetchSpace(space.slug, space.id)} + className={classes.card} + withBorder + > + + + + + {space.name} + + + + {formatMemberCount(space.memberCount, t)} + + + )); + + return ( + <> + + + {t("Spaces you belong to")} + + + + {cards} + + {data?.items && data.items.length >= 20 && ( + + + + )} + + ); +} diff --git a/apps/client/src/pages/dashboard/home.tsx b/apps/client/src/pages/dashboard/home.tsx index 900afa40..cefff053 100644 --- a/apps/client/src/pages/dashboard/home.tsx +++ b/apps/client/src/pages/dashboard/home.tsx @@ -1,6 +1,7 @@ import { Container, Space } from "@mantine/core"; import HomeTabs from "@/features/home/components/home-tabs"; -import SpaceGrid from "@/features/space/components/space-grid.tsx"; +import HomeAiPrompt from "@/features/home/components/home-ai-prompt"; +import SpaceCarousel from "@/features/space/components/space-carousel.tsx"; import { getAppName } from "@/lib/config.ts"; import { Helmet } from "react-helmet-async"; import { useTranslation } from "react-i18next"; @@ -16,7 +17,11 @@ export default function Home() { - + + + + + diff --git a/apps/server/package.json b/apps/server/package.json index 9994ba43..a1a061db 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -75,10 +75,12 @@ "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "cookie": "^1.1.1", + "fast-bm25": "0.0.5", "fastify-ip": "^2.0.0", "fs-extra": "^11.3.4", "happy-dom": "20.8.9", "ioredis": "^5.10.1", + "js-tiktoken": "^1.0.21", "jsonwebtoken": "^9.0.3", "kysely": "^0.28.14", "kysely-migration-cli": "^0.4.2", diff --git a/apps/server/src/core/attachment/attachment.constants.ts b/apps/server/src/core/attachment/attachment.constants.ts index a9bce5c1..a5d90692 100644 --- a/apps/server/src/core/attachment/attachment.constants.ts +++ b/apps/server/src/core/attachment/attachment.constants.ts @@ -3,6 +3,7 @@ export enum AttachmentType { WorkspaceIcon = 'workspace-icon', SpaceIcon = 'space-icon', File = 'file', + Chat = 'chat', } export const validImageExtensions = ['.jpg', '.png', '.jpeg']; diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts index 6382b34e..7b24cc35 100644 --- a/apps/server/src/core/attachment/attachment.controller.ts +++ b/apps/server/src/core/attachment/attachment.controller.ts @@ -178,21 +178,29 @@ export class AttachmentController { } const attachment = await this.attachmentRepo.findById(fileId); - if ( - !attachment || - attachment.workspaceId !== workspace.id || - !attachment.pageId || - !attachment.spaceId - ) { + if (!attachment || attachment.workspaceId !== workspace.id) { throw new NotFoundException(); } - const page = await this.pageRepo.findById(attachment.pageId); - if (!page) { - throw new NotFoundException(); - } + if (attachment.aiChatId) { + // Chat-owned attachment: only the user who uploaded (and therefore + // owns the chat, per AttachmentRepo.claimAttachmentsForChat) can + // read it back. + if (attachment.creatorId !== user.id) { + throw new NotFoundException(); + } + } else { + if (!attachment.pageId || !attachment.spaceId) { + throw new NotFoundException(); + } - await this.pageAccessService.validateCanView(page, user); + const page = await this.pageRepo.findById(attachment.pageId); + if (!page) { + throw new NotFoundException(); + } + + await this.pageAccessService.validateCanView(page, user); + } try { return await this.sendFileResponse(req, res, attachment, 'private'); diff --git a/apps/server/src/core/attachment/attachment.utils.ts b/apps/server/src/core/attachment/attachment.utils.ts index 88edb2af..616fed53 100644 --- a/apps/server/src/core/attachment/attachment.utils.ts +++ b/apps/server/src/core/attachment/attachment.utils.ts @@ -71,6 +71,8 @@ export function getAttachmentFolderPath( return `${workspaceId}/space-logos`; case AttachmentType.File: return `${workspaceId}/files`; + case AttachmentType.Chat: + return `${workspaceId}/chat-files`; default: return `${workspaceId}/files`; } diff --git a/apps/server/src/core/attachment/processors/attachment.processor.ts b/apps/server/src/core/attachment/processors/attachment.processor.ts index bcf8a08a..7249a9fa 100644 --- a/apps/server/src/core/attachment/processors/attachment.processor.ts +++ b/apps/server/src/core/attachment/processors/attachment.processor.ts @@ -28,6 +28,11 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy { job.data.pageId, ); } + if (job.name === QueueJob.DELETE_AI_CHAT_ATTACHMENTS) { + await this.attachmentService.handleDeleteAiChatAttachments( + job.data.aiChatId, + ); + } if ( job.name === QueueJob.ATTACHMENT_INDEX_CONTENT || job.name === QueueJob.ATTACHMENT_INDEXING diff --git a/apps/server/src/core/attachment/services/attachment.service.ts b/apps/server/src/core/attachment/services/attachment.service.ts index 6419ed58..766c9f65 100644 --- a/apps/server/src/core/attachment/services/attachment.service.ts +++ b/apps/server/src/core/attachment/services/attachment.service.ts @@ -289,6 +289,31 @@ export class AttachmentService { ); } + async handleDeleteAiChatAttachments(aiChatId: string) { + try { + const attachments = await this.attachmentRepo.findByAiChatId(aiChatId); + if (!attachments || attachments.length === 0) { + return; + } + + await Promise.all( + attachments.map(async (attachment) => { + try { + await this.storageService.delete(attachment.filePath); + await this.attachmentRepo.deleteAttachmentById(attachment.id); + } catch (err) { + this.logger.log( + `DeleteAiChatAttachments: failed to delete attachment ${attachment.id}:`, + err, + ); + } + }), + ); + } catch (err) { + throw err; + } + } + async handleDeleteSpaceAttachments(spaceId: string) { try { const attachments = await this.attachmentRepo.findBySpaceId(spaceId); diff --git a/apps/server/src/core/auth/auth.controller.ts b/apps/server/src/core/auth/auth.controller.ts index 441bfc1c..89bb9e1b 100644 --- a/apps/server/src/core/auth/auth.controller.ts +++ b/apps/server/src/core/auth/auth.controller.ts @@ -11,6 +11,10 @@ import { Logger, } from '@nestjs/common'; import { SkipThrottle, ThrottlerGuard } from '@nestjs/throttler'; +import { + AI_CHAT_THROTTLER, + AUTH_THROTTLER, +} from '../../integrations/throttle/throttler-names'; import { LoginDto } from './dto/login.dto'; import { AuthService } from './services/auth.service'; import { SessionService } from '../session/session.service'; @@ -34,6 +38,7 @@ import { IAuditService, } from '../../integrations/audit/audit.service'; +@SkipThrottle({ [AI_CHAT_THROTTLER]: true }) @UseGuards(ThrottlerGuard) @Controller('auth') export class AuthController { @@ -113,7 +118,7 @@ export class AuthController { return workspace; } - @SkipThrottle() + @SkipThrottle({ [AUTH_THROTTLER]: true }) @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @Post('change-password') @@ -176,7 +181,7 @@ export class AuthController { return this.authService.verifyUserToken(verifyUserTokenDto, workspace.id); } - @SkipThrottle() + @SkipThrottle({ [AUTH_THROTTLER]: true }) @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @Post('collab-token') @@ -187,7 +192,7 @@ export class AuthController { return this.authService.getCollabToken(user, workspace.id); } - @SkipThrottle() + @SkipThrottle({ [AUTH_THROTTLER]: true }) @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @Post('logout') diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index 6d500a34..c6d822ff 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -142,7 +142,7 @@ export class WorkspaceService { status = WorkspaceStatus.Active; plan = 'standard'; billingEmail = user.email; - settings = { ai: { generative: true } }; + settings = { ai: { generative: true, chat: true } }; } // create workspace diff --git a/apps/server/src/database/migrations/20260305T120000-ai-chat.ts b/apps/server/src/database/migrations/20260409T132415-ai-chat.ts similarity index 52% rename from apps/server/src/database/migrations/20260305T120000-ai-chat.ts rename to apps/server/src/database/migrations/20260409T132415-ai-chat.ts index ddd5fa75..28b595f1 100644 --- a/apps/server/src/database/migrations/20260305T120000-ai-chat.ts +++ b/apps/server/src/database/migrations/20260409T132415-ai-chat.ts @@ -3,6 +3,7 @@ import { type Kysely, sql } from 'kysely'; export async function up(db: Kysely): Promise { await db.schema .createTable('ai_chats') + .ifNotExists() .addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_uuid_v7()`), ) @@ -19,16 +20,19 @@ export async function up(db: Kysely): Promise { .addColumn('updated_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`), ) + .addColumn('deleted_at', 'timestamptz', (col) => col) .execute(); await db.schema .createIndex('idx_ai_chats_workspace_creator') + .ifNotExists() .on('ai_chats') .columns(['workspace_id', 'creator_id', 'id']) .execute(); await db.schema .createTable('ai_chat_messages') + .ifNotExists() .addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_uuid_v7()`), ) @@ -38,23 +42,77 @@ export async function up(db: Kysely): Promise { .addColumn('workspace_id', 'uuid', (col) => col.references('workspaces.id').onDelete('cascade').notNull(), ) + .addColumn('user_id', 'uuid', (col) => + col.references('users.id').onDelete('set null'), + ) .addColumn('role', 'varchar', (col) => col.notNull()) .addColumn('content', 'text', (col) => col) .addColumn('tool_calls', 'jsonb', (col) => col) .addColumn('metadata', 'jsonb', (col) => col) + .addColumn('tsv', sql`tsvector`, (col) => col) .addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`), ) + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('deleted_at', 'timestamptz', (col) => col) .execute(); await db.schema .createIndex('idx_ai_chat_messages_chat_id') + .ifNotExists() .on('ai_chat_messages') .columns(['chat_id', 'id']) .execute(); + + await db.schema + .createIndex('idx_ai_chat_messages_tsv') + .ifNotExists() + .on('ai_chat_messages') + .using('GIN') + .column('tsv') + .execute(); + + //ts-vector + await sql` + CREATE OR REPLACE FUNCTION ai_chat_messages_tsvector_trigger() RETURNS trigger AS $$ + BEGIN + NEW.tsv := to_tsvector('english', f_unaccent(substring(coalesce(NEW.content, ''), 1, 100000))); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `.execute(db); + + await sql` + CREATE OR REPLACE TRIGGER ai_chat_messages_tsvector_update + BEFORE INSERT OR UPDATE ON ai_chat_messages + FOR EACH ROW EXECUTE FUNCTION ai_chat_messages_tsvector_trigger(); + `.execute(db); + + await db.schema + .alterTable('attachments') + .addColumn('ai_chat_id', 'uuid', (col) => col) + .execute(); + + await db.schema + .createIndex('idx_attachments_ai_chat_id') + .ifNotExists() + .on('attachments') + .column('ai_chat_id') + .execute(); } export async function down(db: Kysely): Promise { + await db.schema.dropIndex('idx_attachments_ai_chat_id').execute(); + await db.schema.alterTable('attachments').dropColumn('ai_chat_id').execute(); + + await sql`DROP TRIGGER IF EXISTS ai_chat_messages_tsvector_update ON ai_chat_messages`.execute( + db, + ); + await sql`DROP FUNCTION IF EXISTS ai_chat_messages_tsvector_trigger`.execute( + db, + ); await db.schema.dropTable('ai_chat_messages').execute(); await db.schema.dropTable('ai_chats').execute(); } diff --git a/apps/server/src/database/repos/attachment/attachment.repo.ts b/apps/server/src/database/repos/attachment/attachment.repo.ts index fcc5caee..bf2b5ecb 100644 --- a/apps/server/src/database/repos/attachment/attachment.repo.ts +++ b/apps/server/src/database/repos/attachment/attachment.repo.ts @@ -7,6 +7,7 @@ import { InsertableAttachment, UpdatableAttachment, } from '@docmost/db/types/entity.types'; +import { AttachmentType } from '../../../core/attachment/attachment.constants'; @Injectable() export class AttachmentRepo { @@ -23,6 +24,7 @@ export class AttachmentRepo { 'creatorId', 'pageId', 'spaceId', + 'aiChatId', 'workspaceId', 'createdAt', 'updatedAt', @@ -87,6 +89,21 @@ export class AttachmentRepo { .execute(); } + async findByAiChatId( + aiChatId: string, + opts?: { + trx?: KyselyTransaction; + }, + ): Promise { + const db = dbOrTx(this.db, opts?.trx); + + return db + .selectFrom('attachments') + .select(this.baseFields) + .where('aiChatId', '=', aiChatId) + .execute(); + } + updateAttachmentsByPageId( updatableAttachment: UpdatableAttachment, pageIds: string[], @@ -112,6 +129,25 @@ export class AttachmentRepo { .executeTakeFirst(); } + async claimAttachmentsForChat( + attachmentIds: string[], + aiChatId: string, + creatorId: string, + workspaceId: string, + ): Promise { + if (attachmentIds.length === 0) return; + + await this.db + .updateTable('attachments') + .set({ aiChatId }) + .where('id', 'in', attachmentIds) + .where('creatorId', '=', creatorId) + .where('workspaceId', '=', workspaceId) + .where('type', '=', AttachmentType.Chat) + .where('aiChatId', 'is', null) + .execute(); + } + async deleteAttachmentById(attachmentId: string): Promise { await this.db .deleteFrom('attachments') diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 42c46eae..3f4081e1 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -43,6 +43,7 @@ export interface ApiKeys { } export interface Attachments { + aiChatId: string | null; createdAt: Generated; creatorId: string; deletedAt: Timestamp | null; @@ -436,17 +437,22 @@ export interface AiChats { title: string | null; createdAt: Generated; updatedAt: Generated; + deletedAt: Timestamp | null; } export interface AiChatMessages { id: Generated; chatId: string; workspaceId: string; + userId: string | null; role: string; content: string | null; toolCalls: Json | null; metadata: Json | null; + tsv: string | null; createdAt: Generated; + updatedAt: Generated; + deletedAt: Timestamp | null; } export interface UserSessions { diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index cb2857b6..8d4f482a 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -37,8 +37,14 @@ export type InsertableAiChat = Insertable; export type UpdatableAiChat = Updateable>; // AI Chat Message -export type AiChatMessage = Selectable; -export type InsertableAiChatMessage = Insertable; +// `tsv` is an internal tsvector column maintained by a trigger for +// full-text search. It is omitted from the public type so it never leaks +// into HTTP responses or the chat history fed to the language model. +export type AiChatMessage = Omit, 'tsv'>; +export type InsertableAiChatMessage = Omit< + Insertable, + 'tsv' +>; // Workspace export type Workspace = Selectable; diff --git a/apps/server/src/ee b/apps/server/src/ee index a9d3d468..a3e4e9c7 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit a9d3d4686965a6df57bb94e300231329ca9fc1ae +Subproject commit a3e4e9c72c2e004e3b7db39064fc447ce65411f0 diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts index d93f9ba0..3f8da1bf 100644 --- a/apps/server/src/integrations/export/export.service.ts +++ b/apps/server/src/integrations/export/export.service.ts @@ -353,7 +353,7 @@ export class ExportService { if (attachmentIds.length > 0) { const attachments = await this.db .selectFrom('attachments') - .selectAll() + .select(['id', 'fileName', 'filePath']) .where('id', 'in', attachmentIds) .where('spaceId', '=', spaceId) .execute(); diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts index 92d15426..6d92f09c 100644 --- a/apps/server/src/integrations/queue/constants/queue.constants.ts +++ b/apps/server/src/integrations/queue/constants/queue.constants.ts @@ -17,6 +17,7 @@ export enum QueueJob { ATTACHMENT_INDEX_CONTENT = 'attachment-index-content', ATTACHMENT_INDEXING = 'attachment-indexing', DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments', + DELETE_AI_CHAT_ATTACHMENTS = 'delete-ai-chat-attachments', DELETE_USER_AVATARS = 'delete-user-avatars', diff --git a/apps/server/src/integrations/throttle/throttle.module.ts b/apps/server/src/integrations/throttle/throttle.module.ts index 8f080e1d..42dd0ec4 100644 --- a/apps/server/src/integrations/throttle/throttle.module.ts +++ b/apps/server/src/integrations/throttle/throttle.module.ts @@ -4,6 +4,7 @@ import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis' import { EnvironmentService } from '../environment/environment.service'; import { EnvironmentModule } from '../environment/environment.module'; import { parseRedisUrl } from '../../common/helpers'; +import { AUTH_THROTTLER, AI_CHAT_THROTTLER } from './throttler-names'; import Redis from 'ioredis'; @Module({ @@ -14,7 +15,10 @@ import Redis from 'ioredis'; const redisConfig = parseRedisUrl(environmentService.getRedisUrl()); return { - throttlers: [{ name: 'auth', ttl: 60_000, limit: 10 }], + throttlers: [ + { name: AUTH_THROTTLER, ttl: 60_000, limit: 10 }, + { name: AI_CHAT_THROTTLER, ttl: 60_000, limit: 25 }, + ], errorMessage: 'Too many requests', storage: new ThrottlerStorageRedisService( new Redis({ diff --git a/apps/server/src/integrations/throttle/throttler-names.ts b/apps/server/src/integrations/throttle/throttler-names.ts new file mode 100644 index 00000000..388ba29d --- /dev/null +++ b/apps/server/src/integrations/throttle/throttler-names.ts @@ -0,0 +1,2 @@ +export const AUTH_THROTTLER = 'auth'; +export const AI_CHAT_THROTTLER = 'ai-chat'; diff --git a/apps/server/src/integrations/throttle/user-throttler.guard.ts b/apps/server/src/integrations/throttle/user-throttler.guard.ts new file mode 100644 index 00000000..35744c09 --- /dev/null +++ b/apps/server/src/integrations/throttle/user-throttler.guard.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; +import { ThrottlerGuard } from '@nestjs/throttler'; + +type AuthedRequest = { user?: { id?: string } }; + +@Injectable() +export class UserThrottlerGuard extends ThrottlerGuard { + protected async getTracker(req: AuthedRequest): Promise { + const userId = req.user?.id; + if (userId) return `user:${userId}`; + return super.getTracker(req as Parameters[0]); + } +} diff --git a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts index 04dc1978..7556aa4f 100644 --- a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts @@ -37,6 +37,8 @@ marked.use({ extensions: [calloutExtension, mathBlockExtension, mathInlineExtension], }); +marked.setOptions({ breaks: true }); + export function markdownToHtml( markdownInput: string, ): string | Promise { @@ -46,8 +48,5 @@ export function markdownToHtml( .replace(YAML_FONT_MATTER_REGEX, "") .trimStart(); - return marked - .options({ breaks: true }) - .parse(markdown) - .toString(); + return marked.parse(markdown).toString(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff37ec78..cfdfcdcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -589,6 +589,9 @@ importers: cookie: specifier: ^1.1.1 version: 1.1.1 + fast-bm25: + specifier: 0.0.5 + version: 0.0.5(typescript@5.9.3) fastify-ip: specifier: ^2.0.0 version: 2.0.0 @@ -601,6 +604,9 @@ importers: ioredis: specifier: ^5.10.1 version: 5.10.1 + js-tiktoken: + specifier: ^1.0.21 + version: 1.0.21 jsonwebtoken: specifier: ^9.0.3 version: 9.0.3 @@ -6874,6 +6880,12 @@ packages: exsolve@1.0.7: resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} + fast-bm25@0.0.5: + resolution: {integrity: sha512-6HTiLmPkgeqcPJHccN0pXdqnA7OzhaEQZTFzWnfjIyPoX5sGVKUUpfRc2K2o6zMwK+g009miRhADYn/f2Ax0Mg==} + engines: {node: '>=14.0.0'} + peerDependencies: + typescript: ^5.6.3 + fast-copy@4.0.2: resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} @@ -8966,6 +8978,9 @@ packages: points-on-path@0.2.1: resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + porter2@1.1.0: + resolution: {integrity: sha512-Io2cLEdZn0O1dH60pRsjmr/cH/qJJ/j6Cjubz8wQWi0b6vPdQIUxSBQKyx9d+8CN7fSnY+5uOU3rErMFjNqcLw==} + possible-typed-array-names@1.0.0: resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} engines: {node: '>= 0.4'} @@ -17795,6 +17810,11 @@ snapshots: exsolve@1.0.7: {} + fast-bm25@0.0.5(typescript@5.9.3): + dependencies: + porter2: 1.1.0 + typescript: 5.9.3 + fast-copy@4.0.2: {} fast-decode-uri-component@1.0.1: {} @@ -20144,6 +20164,8 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 + porter2@1.1.0: {} + possible-typed-array-names@1.0.0: {} postcss-js@4.0.1(postcss@8.5.8):