diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 5db91598..b83b3548 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)", @@ -755,5 +756,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/App.tsx b/apps/client/src/App.tsx index b99e63b3..382660c7 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -38,6 +38,7 @@ 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"; import AuditLogs from "@/ee/audit/pages/audit-logs.tsx"; +import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx"; import VerifyEmail from "@/ee/pages/verify-email.tsx"; export default function App() { @@ -81,6 +82,8 @@ export default function App() { }> } /> + } /> + } /> } /> } /> } /> 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 58b76b71..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,8 +33,11 @@ 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" }]; +const links = [ + { link: APP_ROUTE.HOME, label: "Home" }, +]; export function AppHeader() { const { t } = useTranslation(); @@ -34,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) => ( @@ -73,15 +91,24 @@ export function AppHeader() { )} - - Docmost - + + + Docmost + + + Docmost + + {items} @@ -98,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 ) : ( -
+ {!isAiRoute &&
} {isSpaceRoute && } {isSettingsRoute && } + {isAiRoute && } )} diff --git a/apps/client/src/components/ui/card-carousel.module.css b/apps/client/src/components/ui/card-carousel.module.css new file mode 100644 index 00000000..4ec513f2 --- /dev/null +++ b/apps/client/src/components/ui/card-carousel.module.css @@ -0,0 +1,68 @@ +.root { + position: relative; +} + +.track { + display: flex; + gap: var(--mantine-spacing-md); + overflow-x: auto; + scroll-snap-type: x mandatory; + scroll-behavior: smooth; + scrollbar-width: none; + -ms-overflow-style: none; + padding: 2px; + margin: -2px; +} + +.track::-webkit-scrollbar { + display: none; +} + +.track > * { + 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 new file mode 100644 index 00000000..f0fe3035 --- /dev/null +++ b/apps/client/src/ee/ai-chat/components/ai-chat-layout.tsx @@ -0,0 +1,106 @@ +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); + + // If the URL points at a chat the user does not own, the info fetch 404s. + // Bounce them back to /ai so they cannot interact with any chat UI (including + // kicking off orphan uploads) tied to a chat they have no access to. + useEffect(() => { + if (chatId && chatInfoQuery.isError) { + navigate("/ai", { replace: true }); + } + }, [chatId, chatInfoQuery.isError, navigate]); + const { + messages, + streamingContent, + streamingToolCalls, + isStreaming, + error, + sendMessage, + stopGeneration, + hydrateFromServer, + } = useChatStream(chatId); + + const autoSentRef = useRef(false); + + useEffect(() => { + if (chatInfoQuery.data?.messages) { + hydrateFromServer(chatInfoQuery.data.messages); + } + }, [chatInfoQuery.data, hydrateFromServer]); + + useEffect(() => { + 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; + + // While the redirect effect is running (or if the user is still on this + // component for any reason) never render the chat UI for a forbidden chat. + if (chatId && chatInfoQuery.isError) { + return null; + } + + return ( +
+ {error && ( +
+ {error} +
+ )} + + {hasMessages ? ( + <> + +
+ +
+ + ) : ( + + )} +
+ ); +} 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 new file mode 100644 index 00000000..f59b740e --- /dev/null +++ b/apps/client/src/ee/ai-chat/components/ai-chat-sidebar-item.tsx @@ -0,0 +1,166 @@ +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"; + +type Props = { + chat: AiChat; + isActive: boolean; + onDelete: (chatId: string) => void; + 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) { + // Wait for the input to be mounted before selecting. + const id = window.setTimeout(() => inputRef.current?.select(), 0); + return () => window.clearTimeout(id); + } + }, [renaming]); + + const startRename = useCallback(() => { + setRenameValue(chat.title || ""); + setRenaming(true); + }, [chat.title]); + + const submitRename = useCallback(() => { + const trimmed = renameValue.trim(); + if (trimmed && trimmed !== chat.title) { + onRename(chat.id, trimmed); + } + setRenaming(false); + }, [renameValue, chat.id, chat.title, onRename]); + + 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 new file mode 100644 index 00000000..e3df3e52 --- /dev/null +++ b/apps/client/src/ee/ai-chat/components/ai-chat-sidebar.tsx @@ -0,0 +1,224 @@ +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, IconMessageCircle2 } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { + useChatsQuery, + useDeleteChatMutation, + useUpdateChatTitleMutation, + useSearchChatsQuery, +} from "../queries/ai-chat-query"; +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(""); + const [debouncedSearch] = useDebouncedValue(search, 300); + const chatsQuery = useChatsQuery(); + const searchQuery = useSearchChatsQuery(debouncedSearch); + const deleteMutation = useDeleteChatMutation(); + const renameMutation = useUpdateChatTitleMutation(); + + const chats = useMemo(() => { + if (debouncedSearch) { + return searchQuery.data || []; + } + return chatsQuery.data?.pages.flatMap((p) => p.items) || []; + }, [debouncedSearch, searchQuery.data, chatsQuery.data]); + + 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) => { + deleteMutation.mutate(id, { + onSuccess: () => { + if (chatId === id) { + navigate("/ai"); + } + }, + }); + }, + [deleteMutation, chatId, navigate], + ); + + const handleRename = useCallback( + (chatId: string, title: string) => { + renameMutation.mutate({ chatId, title }); + }, + [renameMutation], + ); + + const isLoading = chatsQuery.isLoading || searchQuery.isLoading; + + return ( +
+
+ {t("AI Chat")} + + + + + +
+ + } + size="xs" + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + /> + +
+ {isLoading && } + {!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..bd2b9569 --- /dev/null +++ b/apps/client/src/ee/ai-chat/components/aside-chat-panel.tsx @@ -0,0 +1,258 @@ +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]); + + // Drop the open chatId if the current user lost access to it (404/403 on + // the info fetch). Reverts the panel to a fresh chat instead of presenting + // an input tied to a chat the user does not own. + useEffect(() => { + if (chatId && chatInfoQuery.isError) { + setChatId(undefined); + } + }, [chatId, chatInfoQuery.isError]); + + 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 new file mode 100644 index 00000000..d7bacf18 --- /dev/null +++ b/apps/client/src/ee/ai-chat/components/chat-empty-state.tsx @@ -0,0 +1,91 @@ +import { + IconSparkles, + IconSearch, + IconFilePlus, + 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"; + +type Suggestion = { + icon: React.ReactNode; + text: string; + prompt: string; +}; + +const SUGGESTIONS: Suggestion[] = [ + { + icon: , + text: "Search across all pages", + prompt: "Search for pages about ", + }, + { + icon: , + text: "Create a new page", + prompt: "Create a new page titled ", + }, + { + icon: , + text: "Summarize a page", + prompt: "Summarize the page @", + }, + { + icon: , + text: "Update page content", + prompt: "Update the page @", + }, +]; + +type Props = { + isStreaming: boolean; + onSend: (content: string, mentions: PageMention[], attachments: ChatAttachment[]) => void; + onStop: () => void; +}; + +export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) { + const { t } = useTranslation(); + + const handleSuggestionClick = (prompt: string) => { + onSend(prompt, [], []); + }; + + return ( +
+ +
{t("Docmost AI")}
+
+ {t("What can I help you with?")} +
+ +
+ +
+ +
+
Get started
+
+ {SUGGESTIONS.map((s) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/client/src/ee/ai-chat/components/chat-input.tsx b/apps/client/src/ee/ai-chat/components/chat-input.tsx new file mode 100644 index 00000000..d003adfb --- /dev/null +++ b/apps/client/src/ee/ai-chat/components/chat-input.tsx @@ -0,0 +1,409 @@ +import { useCallback, useRef, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react"; +import { Popover } from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react"; +import { Placeholder } from "@tiptap/extension-placeholder"; +import { CharacterCount } from "@tiptap/extensions"; +import { StarterKit } from "@tiptap/starter-kit"; +import { Mention, LinkExtension } from "@docmost/editor-ext"; +import EmojiCommand from "@/features/editor/extensions/emoji-command"; +import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion"; +import MentionView from "@/features/editor/components/mention/mention-view"; +import { uploadChatFile } from "../services/ai-chat-service"; +import type { ChatAttachment, PageMention } from "../types/ai-chat.types"; +import classes from "../styles/chat-input.module.css"; + +type PendingAttachment = ChatAttachment & { uploading: boolean }; + +const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"]; +const ACCEPTED_FILE_TYPES = ".pdf,.docx,.txt,.csv,.md,.png,.jpg,.jpeg,.webp"; +// Kept in sync with MAX_ATTACHMENTS_PER_MESSAGE in apps/server/src/ee/ai-chat/ai-chat-limits.ts +const MAX_ATTACHMENTS_PER_MESSAGE = 5; + +type Props = { + isStreaming: boolean; + onSend: (content: string, mentions: PageMention[], attachments: ChatAttachment[]) => void; + onStop: () => void; + placeholder?: string; + autofocus?: boolean; + contextPages?: PageMention[]; + onRemoveContextPage?: (pageId: string) => void; + variant?: "card" | "flat"; + showDisclaimer?: boolean; + chatId?: string; +}; + +function extractMentions(json: any): PageMention[] { + const mentions: PageMention[] = []; + const seen = new Set(); + + function walk(node: any) { + if (node.type === "mention" && node.attrs?.entityType === "page" && node.attrs?.entityId) { + if (!seen.has(node.attrs.entityId)) { + seen.add(node.attrs.entityId); + mentions.push({ + id: node.attrs.entityId, + title: node.attrs.label || "", + slugId: node.attrs.slugId || "", + }); + } + } + if (node.content) { + for (const child of node.content) { + walk(child); + } + } + } + + walk(json); + return mentions; +} + +function editorJsonToText(json: any): string { + let text = ""; + + function walk(node: any) { + if (node.type === "text") { + text += node.text || ""; + } else if (node.type === "mention") { + text += `@${node.attrs?.label || ""}`; + } else if (node.type === "paragraph") { + if (text.length > 0) text += "\n"; + if (node.content) { + for (const child of node.content) { + walk(child); + } + } + return; + } + if (node.content) { + for (const child of node.content) { + walk(child); + } + } + } + + walk(json); + return text; +} + +export default function ChatInput({ + isStreaming, + onSend, + 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; + + const handleFileSelect = useCallback(async (files: FileList | null) => { + if (!files?.length) return; + + 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() || ""; + + const placeholder: PendingAttachment = { + id: tempId, + fileName: file.name, + fileExt: ext, + fileSize: file.size, + mimeType: file.type, + uploading: true, + }; + + setPendingAttachments((prev) => [...prev, placeholder]); + + try { + const uploaded = await uploadChatFile(file, chatIdRef.current); + setPendingAttachments((prev) => + prev.map((a) => + a.id === tempId ? { ...uploaded, uploading: false } : a, + ), + ); + } catch { + setPendingAttachments((prev) => prev.filter((a) => a.id !== tempId)); + } + } + + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }, [pendingAttachments.length, t]); + + const removeAttachment = useCallback((id: string) => { + setPendingAttachments((prev) => prev.filter((a) => a.id !== id)); + }, []); + + const handleSubmit = useCallback(() => { + if (!editor || isStreaming) return; + const json = editor.getJSON(); + const text = editorJsonToText(json).trim(); + const readyAttachments = pendingAttachments.filter((a) => !a.uploading); + if (!text && readyAttachments.length === 0) return; + + const mentions = extractMentions(json); + onSendRef.current(text, mentions, readyAttachments); + editor.commands.clearContent(); + editor.commands.focus(); + setPendingAttachments([]); + }, [isStreaming, pendingAttachments]); + + const handleSubmitRef = useRef(handleSubmit); + handleSubmitRef.current = handleSubmit; + + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + gapcursor: false, + dropcursor: false, + link: false, + }), + Placeholder.configure({ + placeholder: placeholder || "Ask anything... Use @ to mention pages", + }), + CharacterCount.configure({ + limit: 50000, + }), + LinkExtension, + EmojiCommand, + Mention.configure({ + suggestion: { + allowSpaces: true, + items: () => [], + // @ts-ignore + render: mentionRenderItems, + }, + HTMLAttributes: { + class: "mention", + }, + }).extend({ + addNodeView() { + this.editor.isInitialized = true; + return ReactNodeViewRenderer(MentionView); + }, + }), + ], + editorProps: { + handleDOMEvents: { + keydown: (_view, event) => { + if ( + ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter"].includes( + event.key, + ) + ) { + const emojiCommand = document.querySelector("#emoji-command"); + const mentionPopup = document.querySelector("#mention"); + if (emojiCommand || mentionPopup) { + return true; + } + } + + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + handleSubmitRef.current(); + return true; + } + }, + }, + }, + content: "", + editable: true, + immediatelyRender: true, + shouldRerenderOnTransaction: false, + autofocus: autofocus ? "end" : false, + onUpdate: ({ editor: e }) => { + setIsEmpty(!e.getText().trim()); + }, + }); + + useEffect(() => { + if (editor && autofocus) { + editor.commands.focus(); + } + }, [editor]); + + 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)} + /> + + {((contextPages?.length ?? 0) > 0 || pendingAttachments.length > 0) && ( +
+ {contextPages?.map((page) => ( +
+ + + {page.title || "Untitled"} + + {onRemoveContextPage && ( + + )} +
+ ))} + {pendingAttachments.map((attachment) => ( +
+ {IMAGE_EXTENSIONS.includes(attachment.fileExt) ? ( + + ) : ( + + )} + + {attachment.fileName} + + {!attachment.uploading && ( + + )} +
+ ))} +
+ )} + + +
+ + + + + + + + + + +
+ + {isStreaming ? ( + + ) : ( + + )} +
+
+ {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 new file mode 100644 index 00000000..3a6fffef --- /dev/null +++ b/apps/client/src/ee/ai-chat/components/chat-message-list.tsx @@ -0,0 +1,174 @@ +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; + streamingContent: string; + 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(() => { + 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 && ( + } + > + + + )} +
+
+ {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 new file mode 100644 index 00000000..d0e9443a --- /dev/null +++ b/apps/client/src/ee/ai-chat/components/chat-message.tsx @@ -0,0 +1,139 @@ +import { useCallback } from "react"; +import { useNavigate } from "react-router"; +import DOMPurify from "dompurify"; +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 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"]; + +type Props = { + message: AiChatMessage; + isStreaming?: boolean; + streamingContent?: string; + streamingToolCalls?: AiChatToolCall[]; +}; + +export default function ChatMessage({ + message, + isStreaming, + streamingContent, + streamingToolCalls, +}: Props) { + const navigate = useNavigate(); + + const handleContentClick = useCallback( + (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + const anchor = target.closest("a"); + if (!anchor) return; + + const href = anchor.getAttribute("href"); + if (href && (href.startsWith("/s/") || href.startsWith("/p/"))) { + e.preventDefault(); + navigate(href); + } + }, + [navigate], + ); + + if (message.role === "tool") return null; + + const isUser = message.role === "user"; + const content = isStreaming ? streamingContent : message.content; + const toolCalls = isStreaming ? streamingToolCalls : message.toolCalls; + + if (isUser) { + const displayContent = (content || "").replace( + /\n\n[\s\S]*<\/referenced_pages>$/, + "", + ); + const attachments = + (message.metadata?.attachments as { + id: string; + fileName: string; + fileExt: string; + }[]) || []; + + return ( +
+
+ {attachments.length > 0 && ( +
+ {attachments.map((a) => ( + + {IMAGE_EXTENSIONS.includes(a.fileExt) ? ( + + ) : ( + + )} + {a.fileName} + + ))} +
+ )} + {displayContent} +
+
+ ); + } + + return ( +
+
+ {toolCalls && toolCalls.length > 0 && ( + + )} + {content && ( +
+ )} + {isStreaming && ( + <> + {!content && ( + + + Thinking + + )} + + + )} +
+ {!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 new file mode 100644 index 00000000..55f20f91 --- /dev/null +++ b/apps/client/src/ee/ai-chat/components/chat-tool-result.tsx @@ -0,0 +1,49 @@ +import { useState } from "react"; +import { IconChevronRight, IconChevronDown } from "@tabler/icons-react"; +import type { AiChatToolCall } from "../types/ai-chat.types"; +import classes from "../styles/chat-message.module.css"; + +export const TOOL_LABELS: Record = { + list_spaces: "Listed spaces", + search_pages: "Searched pages", + get_page: "Read page", + create_page: "Created page", + update_page: "Updated page", +}; + +type Props = { + toolCall: AiChatToolCall; +}; + +export default function ChatToolResult({ toolCall }: Props) { + const [expanded, setExpanded] = useState(false); + const label = TOOL_LABELS[toolCall.name] || toolCall.name; + + return ( +
+
setExpanded((prev) => !prev)} + > + · + {expanded ? ( + + ) : ( + + )} + {label} +
+ {expanded && ( +
+
+            {JSON.stringify(
+              { args: toolCall.args, result: toolCall.result },
+              null,
+              2,
+            )}
+          
+
+ )} +
+ ); +} 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 new file mode 100644 index 00000000..f4200e5b --- /dev/null +++ b/apps/client/src/ee/ai-chat/components/enable-ai-chat.tsx @@ -0,0 +1,67 @@ +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 { 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(); + + return ( + +
+ + {t("AI Chat")} + + {t("Beta")} + + + + {t( + "Enable AI Chat to allow users to have multi-turn conversations with AI about your workspace content.", + )} + +
+ + +
+ ); +} + +function AiChatToggle() { + const { t } = useTranslation(); + const [workspace, setWorkspace] = useAtom(workspaceAtom); + const [checked, setChecked] = useState(workspace?.settings?.ai?.chat); + const hasAccess = useHasFeature(Feature.AI); + const upgradeLabel = useUpgradeLabel(); + + const handleChange = async (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + try { + const updatedWorkspace = await updateWorkspace({ aiChat: value } as any); + setChecked(value); + setWorkspace(updatedWorkspace); + } catch (err: any) { + notifications.show({ + message: err?.response?.data?.message, + color: "red", + }); + } + }; + + 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 new file mode 100644 index 00000000..65a93a97 --- /dev/null +++ b/apps/client/src/ee/ai-chat/hooks/use-chat-stream.ts @@ -0,0 +1,227 @@ +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"; +import type { + AiChatMessage, + AiChatStreamEvent, + AiChatToolCall, + ChatAttachment, + PageMention, +} from "../types/ai-chat.types"; + +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( + [], + ); + 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); + + // 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[] = [], contextPageId?: string) => { + if (isStreaming || (!content.trim() && attachments.length === 0)) return; + + setError(null); + setErrorCode(null); + setIsRetryable(false); + setIsStreaming(true); + setStreamingContent(""); + setStreamingToolCalls([]); + + const metadata: Record = {}; + if (mentions.length) { + metadata.mentionedPageIds = mentions.map((m) => m.id); + } + if (attachments.length) { + metadata.attachments = attachments.map((a) => ({ + id: a.id, + fileName: a.fileName, + fileExt: a.fileExt, + })); + } + + const userMessage: AiChatMessage = { + id: `temp-${Date.now()}`, + chatId: currentChatIdRef.current || "", + role: "user", + content, + toolCalls: null, + metadata: Object.keys(metadata).length ? metadata : null, + createdAt: new Date().toISOString(), + }; + + setMessages((prev) => [...prev, userMessage]); + + const attachmentIds = attachments.map((a) => a.id); + + const abortController = sendChatMessage( + { + 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; + // 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": + setStreamingContent((prev) => prev + event.text); + break; + case "tool_call": + setStreamingToolCalls((prev) => [ + ...prev, + { + id: event.id, + name: event.name, + args: event.args, + }, + ]); + break; + case "tool_result": + setStreamingToolCalls((prev) => + prev.map((tc) => + tc.id === event.id ? { ...tc, result: event.result } : tc, + ), + ); + break; + case "done": { + setStreamingContent((currentContent) => { + setStreamingToolCalls((currentToolCalls) => { + const assistantMessage: AiChatMessage = { + id: event.messageId, + chatId: currentChatIdRef.current || "", + role: "assistant", + content: currentContent || null, + toolCalls: currentToolCalls.length + ? currentToolCalls + : null, + metadata: event.usage ? { tokenUsage: event.usage } : null, + createdAt: new Date().toISOString(), + }; + + setMessages((prev) => [...prev, assistantMessage]); + return []; + }); + return ""; + }); + setIsStreaming(false); + queryClient.invalidateQueries({ + queryKey: ["ai-chat", currentChatIdRef.current], + }); + break; + } + case "error": + setError(event.message); + setErrorCode(event.code || null); + setIsRetryable(event.retryable || false); + setIsStreaming(false); + break; + } + }, + (errorMsg) => { + setError(errorMsg); + setIsStreaming(false); + }, + () => { + setIsStreaming(false); + }, + ); + + abortRef.current = abortController; + }, + [isStreaming, navigate, queryClient], + ); + + const stopGeneration = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + + setStreamingContent((currentContent) => { + setStreamingToolCalls((currentToolCalls) => { + if (currentContent || currentToolCalls.length > 0) { + const partialMessage: AiChatMessage = { + id: `stopped-${Date.now()}`, + chatId: currentChatIdRef.current || "", + role: "assistant", + content: currentContent || null, + toolCalls: currentToolCalls.length ? currentToolCalls : null, + metadata: null, + createdAt: new Date().toISOString(), + }; + setMessages((prev) => [...prev, partialMessage]); + } + return []; + }); + return ""; + }); + + setIsStreaming(false); + }, []); + + return { + messages, + streamingContent, + streamingToolCalls, + isStreaming, + error, + errorCode, + isRetryable, + sendMessage, + stopGeneration, + 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 new file mode 100644 index 00000000..bb264b73 --- /dev/null +++ b/apps/client/src/ee/ai-chat/pages/ai-chat.tsx @@ -0,0 +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/queries/ai-chat-query.ts b/apps/client/src/ee/ai-chat/queries/ai-chat-query.ts new file mode 100644 index 00000000..8992a115 --- /dev/null +++ b/apps/client/src/ee/ai-chat/queries/ai-chat-query.ts @@ -0,0 +1,61 @@ +import { + useQuery, + useMutation, + useQueryClient, + useInfiniteQuery, +} from "@tanstack/react-query"; +import { + listChats, + getChatInfo, + deleteChat, + updateChatTitle, + searchChats, +} from "../services/ai-chat-service"; + +export function useChatsQuery() { + return useInfiniteQuery({ + queryKey: ["ai-chats"], + queryFn: ({ pageParam }) => + listChats({ cursor: pageParam, limit: 30 }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => + lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined, + }); +} + +export function useChatInfoQuery(chatId: string | undefined) { + return useQuery({ + queryKey: ["ai-chat", chatId], + queryFn: () => getChatInfo(chatId!), + enabled: !!chatId, + }); +} + +export function useDeleteChatMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (chatId: string) => deleteChat(chatId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["ai-chats"] }); + }, + }); +} + +export function useUpdateChatTitleMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ chatId, title }: { chatId: string; title: string }) => + updateChatTitle(chatId, title), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["ai-chats"] }); + }, + }); +} + +export function useSearchChatsQuery(query: string) { + return useQuery({ + queryKey: ["ai-chats-search", query], + queryFn: () => searchChats(query), + enabled: query.length > 0, + }); +} 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 new file mode 100644 index 00000000..2932372e --- /dev/null +++ b/apps/client/src/ee/ai-chat/services/ai-chat-service.ts @@ -0,0 +1,144 @@ +import api from "@/lib/api-client.ts"; +import type { + AiChat, + AiChatMessage, + AiChatStreamEvent, + ChatAttachment, +} from "../types/ai-chat.types"; +import { IPagination } from "@/lib/types.ts"; + +export async function createChat(): Promise { + const req = await api.post("/ai/chats/create"); + return req.data; +} + +export async function listChats(params?: { + limit?: number; + cursor?: string; +}): Promise> { + 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/chats/info", { chatId }); + return req.data; +} + +export async function deleteChat(chatId: string): Promise { + await api.post("/ai/chats/delete", { chatId }); +} + +export async function updateChatTitle( + chatId: string, + title: string, +): Promise { + await api.post("/ai/chats/update", { chatId, title }); +} + +export async function searchChats(query: string): Promise { + const req = await api.post("/ai/chats/search", { query }); + return req.data; +} + +export async function uploadChatFile( + file: File, + chatId?: string, +): Promise { + const formData = new FormData(); + formData.append("file", file); + if (chatId) { + formData.append("chatId", chatId); + } + return await api.post("/ai/chats/upload", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); +} + +export function sendChatMessage( + params: { + chatId?: string; + content: string; + mentionedPageIds?: string[]; + contextPageId?: string; + attachmentIds?: string[]; + }, + onEvent: (event: AiChatStreamEvent) => void, + onError?: (error: string) => void, + onComplete?: () => void, +): AbortController { + const abortController = new AbortController(); + + (async () => { + try { + const response = await fetch("/api/ai/chats/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + signal: abortController.signal, + credentials: "include", + }); + + if (!response.ok) { + const errorBody = await response.text(); + let errorMessage = `HTTP error ${response.status}`; + try { + const parsed = JSON.parse(errorBody); + errorMessage = parsed.message || errorMessage; + } catch { + // use default + } + onError?.(errorMessage); + return; + } + + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + + if (!reader) { + onError?.("Response body is not readable"); + return; + } + + let buffer = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + 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) as AiChatStreamEvent; + onEvent(parsed); + } catch { + // Skip invalid JSON + } + } + } + } + } finally { + reader.releaseLock(); + } + + onComplete?.(); + } catch (error: any) { + if (error.name !== "AbortError") { + onError?.(error.message); + } + } + })(); + + return abortController; +} 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 new file mode 100644 index 00000000..27b0f0c0 --- /dev/null +++ b/apps/client/src/ee/ai-chat/styles/ai-chat.module.css @@ -0,0 +1,169 @@ +.layout { + display: flex; + height: 100%; + width: 100%; +} + +.main { + flex: 1; + display: flex; + flex-direction: column; + height: calc(100vh - 45px - 2 * var(--mantine-spacing-md)); + max-width: 900px; + margin: 0 auto; + 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); +} + +.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 { + padding: var(--mantine-spacing-xs) var(--mantine-spacing-lg) var(--mantine-spacing-lg); +} + +/* Empty state - Notion AI style centered layout */ +.emptyState { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--mantine-spacing-xl) var(--mantine-spacing-lg); +} + +.emptyStateIcon { + width: 48px; + height: 48px; + 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; + color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0)); + margin-bottom: var(--mantine-spacing-xl); + text-align: center; +} + +.emptyStateInput { + width: 100%; + max-width: 600px; + margin-bottom: var(--mantine-spacing-xl); + padding: 6px 0; +} + +.suggestionsSection { + width: 100%; + max-width: 600px; +} + +.suggestionsLabel { + font-size: var(--mantine-font-size-xs); + font-weight: 500; + color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: var(--mantine-spacing-sm); +} + +.suggestionsGrid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--mantine-spacing-sm); +} + +.suggestionCard { + display: flex; + align-items: flex-start; + gap: var(--mantine-spacing-sm); + padding: var(--mantine-spacing-sm) var(--mantine-spacing-md); + 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; + transition: background-color 150ms, border-color 150ms; + text-align: left; + width: 100%; + + @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)); + } +} + +.suggestionIcon { + flex-shrink: 0; + color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); + margin-top: 1px; +} + +.suggestionText { + font-size: var(--mantine-font-size-sm); + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); + line-height: 1.4; +} 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 new file mode 100644 index 00000000..20b287c1 --- /dev/null +++ b/apps/client/src/ee/ai-chat/styles/chat-input.module.css @@ -0,0 +1,242 @@ +.inputWrapper { + position: relative; + 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: 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-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; + gap: 6px; + padding: 10px 14px 0; +} + +.attachmentChip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border-radius: 8px; + background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6)); + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); + font-size: var(--mantine-font-size-xs); + max-width: 200px; +} + +.attachmentChipUploading { + opacity: 0.55; +} + +.attachmentChipName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.attachmentChipRemove { + display: flex; + align-items: center; + justify-content: center; + border: none; + background: none; + cursor: pointer; + padding: 0; + margin-left: 2px; + color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); + border-radius: 50%; + + @mixin hover { + color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0)); + } +} + +.editorContent { + overflow: hidden; + + :global(.ProseMirror) { + outline: none; + border: none; + background-color: transparent; + padding: 14px 18px 8px; + font-size: 15px; + line-height: 1.6; + max-height: 200px; + overflow-y: auto; + min-height: 24px; + color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0)); + } + + :global(.ProseMirror p) { + margin-block-start: 0; + margin-block-end: 0; + } + + :global(.ProseMirror p.is-editor-empty:first-child::before) { + color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3)); + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; + } +} + +.actions { + display: flex; + align-items: center; + justify-content: flex-end; + padding: 4px 12px 10px; + gap: var(--mantine-spacing-xs); +} + +.sendButton { + width: 28px; + height: 28px; + min-width: 28px; + min-height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + border: none; + cursor: pointer; + transition: background-color 150ms, opacity 150ms; + background: light-dark(var(--mantine-color-dark-9), var(--mantine-color-gray-0)); + color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-9)); + + &:disabled { + opacity: 0.25; + cursor: default; + } + + @mixin hover { + &:not(:disabled) { + opacity: 0.85; + } + } +} + +.attachButton { + display: flex; + align-items: center; + justify-content: center; + border: none; + background: none; + cursor: pointer; + padding: 2px; + color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); + transition: color 150ms; + + @mixin hover { + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); + } +} + +.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; + min-width: 28px; + min-height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + cursor: pointer; + transition: background-color 150ms; + 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)); + + @mixin hover { + background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5)); + } +} 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 new file mode 100644 index 00000000..33e39dd6 --- /dev/null +++ b/apps/client/src/ee/ai-chat/styles/chat-message.module.css @@ -0,0 +1,286 @@ +.message { + margin-bottom: var(--mantine-spacing-lg); +} + +.userMessage { + composes: message; + display: flex; + justify-content: flex-end; +} + +.userBubble { + max-width: 75%; + padding: 10px 16px; + border-radius: 18px; + background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6)); + color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0)); + font-size: 15px; + line-height: 1.6; + word-wrap: break-word; + 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; +} + +.messageAttachments { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 6px; +} + +.messageAttachmentChip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 6px; + background: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-2)); + font-size: var(--mantine-font-size-xs); + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.assistantMessage { + composes: message; +} + +.messageContent { + font-size: 15px; + line-height: 1.7; + color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-1)); + word-wrap: break-word; + overflow-wrap: break-word; +} + +.messageContent p { + margin: 0 0 0.75em 0; +} + +.messageContent p:last-child { + margin-bottom: 0; +} + +.messageContent ul, +.messageContent ol { + margin: 0.5em 0 0.75em 0; + padding-left: 1.5em; +} + +.messageContent li { + margin-bottom: 0.3em; +} + +.messageContent h1, +.messageContent h2, +.messageContent h3 { + margin: 1em 0 0.5em 0; + font-weight: 600; + color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0)); +} + +.messageContent h1 { + font-size: 1.4em; +} + +.messageContent h2 { + font-size: 1.2em; +} + +.messageContent h3 { + font-size: 1.05em; +} + +.messageContent pre { + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7)); + padding: var(--mantine-spacing-sm) var(--mantine-spacing-md); + border-radius: var(--mantine-radius-md); + overflow-x: auto; + font-size: var(--mantine-font-size-sm); + margin: 0.75em 0; +} + +.messageContent code { + background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6)); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.88em; +} + +.messageContent pre code { + background: none; + padding: 0; +} + +.messageContent blockquote { + border-left: 3px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + padding-left: var(--mantine-spacing-md); + margin: 0.75em 0; + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-2)); +} + +.messageContent a { + color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-4)); + text-decoration: none; + + @mixin hover { + text-decoration: underline; + } +} + +.messageContent a[href^="/s/"], +.messageContent a[href^="/p/"] { + color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1)); + font-weight: 500; + text-decoration: none; + cursor: pointer; + + @mixin light { + border-bottom: 0.05em solid var(--mantine-color-dark-0); + } + + @mixin dark { + border-bottom: 0.05em solid var(--mantine-color-dark-2); + } + + @mixin hover { + text-decoration: none; + @mixin light { + border-bottom-color: var(--mantine-color-dark-2); + } + @mixin dark { + border-bottom-color: var(--mantine-color-dark-0); + } + } +} + +.messageContent hr { + border: none; + border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); + margin: 1em 0; +} + +.toolGroup { + margin: 6px 0; + font-size: var(--mantine-font-size-xs); +} + +.toolGroupHeader { + display: inline-flex; + align-items: center; + 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; +} + +.toolGroupHeader:hover { + color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0)); +} + +.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 { + display: inline-flex; + align-items: center; + gap: 6px; + color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); + font-size: var(--mantine-font-size-sm); +} + +.processingSpinner { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.streamingCursor { + display: inline-block; + width: 2px; + height: 1em; + background: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0)); + animation: blink 1s step-end infinite; + vertical-align: text-bottom; + margin-left: 1px; +} + +@keyframes blink { + 50% { + opacity: 0; + } +} 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 new file mode 100644 index 00000000..c7991147 --- /dev/null +++ b/apps/client/src/ee/ai-chat/styles/chat-sidebar.module.css @@ -0,0 +1,138 @@ +.sidebar { + height: 100%; + width: 100%; + padding: var(--mantine-spacing-md); + display: flex; + flex-direction: column; + gap: var(--mantine-spacing-xs); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: var(--mantine-spacing-xs); +} + +.title { + font-weight: 600; + font-size: var(--mantine-font-size-sm); +} + +.searchInput { + margin-bottom: var(--mantine-spacing-xs); +} + +.chatList { + flex: 1; + 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; + padding: 8px var(--mantine-spacing-xs); + border-radius: var(--mantine-radius-sm); + cursor: pointer; + text-decoration: none; + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); + font-size: var(--mantine-font-size-sm); + user-select: none; + gap: var(--mantine-spacing-xs); + + @mixin hover { + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-6) + ); + } + + &[data-active] { + background-color: light-dark( + var(--mantine-color-gray-2), + var(--mantine-color-dark-6) + ); + } +} + +.chatItemTitle { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chatItemDate { + 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 new file mode 100644 index 00000000..89754d26 --- /dev/null +++ b/apps/client/src/ee/ai-chat/types/ai-chat.types.ts @@ -0,0 +1,49 @@ +export type AiChat = { + id: string; + workspaceId: string; + creatorId: string; + title: string | null; + createdAt: string; + updatedAt: string; +}; + +export type AiChatToolCall = { + id: string; + name: string; + args: Record; + result?: unknown; +}; + +export type AiChatMessage = { + id: string; + chatId: string; + role: 'user' | 'assistant' | 'tool'; + content: string | null; + toolCalls: AiChatToolCall[] | null; + metadata: Record | null; + createdAt: string; +}; + +export type AiChatStreamEvent = + | { type: 'chat_created'; chatId: string } + | { type: 'content'; text: string } + | { 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; code?: string; retryable?: boolean }; + +export type PageMention = { + id: string; + title: string; + slugId: string; + spaceSlug?: string; + icon?: string; +}; + +export type ChatAttachment = { + id: string; + fileName: string; + fileExt: string; + fileSize: number; + mimeType: string; +}; diff --git a/apps/client/src/ee/ai/pages/ai-settings.tsx b/apps/client/src/ee/ai/pages/ai-settings.tsx index c3f93810..3a5a281e 100644 --- a/apps/client/src/ee/ai/pages/ai-settings.tsx +++ b/apps/client/src/ee/ai/pages/ai-settings.tsx @@ -6,6 +6,7 @@ import useUserRole from "@/hooks/use-user-role.tsx"; import { useTranslation } from "react-i18next"; import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx"; import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx"; +import EnableAiChat from "@/ee/ai-chat/components/enable-ai-chat.tsx"; import McpSettings from "@/ee/ai/components/mcp-settings.tsx"; import { Alert, Stack, Tabs } from "@mantine/core"; import { IconInfoCircle } from "@tabler/icons-react"; @@ -71,6 +72,7 @@ export default function AiSettings() { {!isCloud() && } + diff --git a/apps/client/src/features/editor/components/mention/mention-list.tsx b/apps/client/src/features/editor/components/mention/mention-list.tsx index af6f8a1d..3ddf3976 100644 --- a/apps/client/src/features/editor/components/mention/mention-list.tsx +++ b/apps/client/src/features/editor/components/mention/mention-list.tsx @@ -62,7 +62,7 @@ const MentionList = forwardRef((props, ref) => { query: props.query, includeUsers: true, includePages: true, - spaceId: space.id, + spaceId: space?.id, limit: props.query ? 10 : 5, preload: true, }); diff --git a/apps/client/src/features/editor/components/mention/mention-suggestion.ts b/apps/client/src/features/editor/components/mention/mention-suggestion.ts index 658fd182..894e7204 100644 --- a/apps/client/src/features/editor/components/mention/mention-suggestion.ts +++ b/apps/client/src/features/editor/components/mention/mention-suggestion.ts @@ -53,8 +53,8 @@ const mentionRenderItems = () => { const editorDom = props.editor?.view?.dom; const asideEl = editorDom?.closest(".mantine-AppShell-aside"); const dialogEl = editorDom?.closest("[data-comment-dialog]"); - const isInCommentContext = !!(asideEl || dialogEl); - // const isInCommentContext = !!asideEl; + const chatInput = editorDom?.closest("[data-chat-input]"); + const isInCommentContext = !!(asideEl || dialogEl || chatInput); component = new ReactRenderer(MentionList, { props: { ...props, isInCommentContext }, 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..e8c4eada --- /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 > 1 && ( + + + + )} + + ); +} diff --git a/apps/client/src/features/workspace/types/workspace.types.ts b/apps/client/src/features/workspace/types/workspace.types.ts index 4d98a5ef..96df25b7 100644 --- a/apps/client/src/features/workspace/types/workspace.types.ts +++ b/apps/client/src/features/workspace/types/workspace.types.ts @@ -43,6 +43,7 @@ export interface IWorkspaceAiSettings { search?: boolean; generative?: boolean; mcp?: boolean; + chat?: boolean; } export interface IWorkspaceSharingSettings { 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/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts index 9b822f50..d3f029d4 100644 --- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts +++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts @@ -46,6 +46,10 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) { @IsBoolean() mcpEnabled: boolean; + @IsOptional() + @IsBoolean() + aiChat: boolean; + @IsOptional() @IsInt() @Min(1) diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index d0bd27c6..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 @@ -458,11 +458,26 @@ export class WorkspaceService { ); } + if (typeof updateWorkspaceDto.aiChat !== 'undefined') { + const prev = settingsBefore?.ai?.chat ?? false; + if (prev !== updateWorkspaceDto.aiChat) { + before.aiChat = prev; + after.aiChat = updateWorkspaceDto.aiChat; + } + await this.workspaceRepo.updateAiSettings( + workspaceId, + 'chat', + updateWorkspaceDto.aiChat, + trx, + ); + } + delete updateWorkspaceDto.restrictApiToAdmins; delete updateWorkspaceDto.aiSearch; delete updateWorkspaceDto.generativeAi; delete updateWorkspaceDto.disablePublicSharing; delete updateWorkspaceDto.mcpEnabled; + delete updateWorkspaceDto.aiChat; await this.workspaceRepo.updateWorkspace( updateWorkspaceDto, diff --git a/apps/server/src/database/migrations/20260409T132415-ai-chat.ts b/apps/server/src/database/migrations/20260409T132415-ai-chat.ts new file mode 100644 index 00000000..28b595f1 --- /dev/null +++ b/apps/server/src/database/migrations/20260409T132415-ai-chat.ts @@ -0,0 +1,118 @@ +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()`), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + .addColumn('creator_id', 'uuid', (col) => + col.references('users.id').notNull(), + ) + .addColumn('title', 'varchar', (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_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()`), + ) + .addColumn('chat_id', 'uuid', (col) => + col.references('ai_chats.id').onDelete('cascade').notNull(), + ) + .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 5824ce5f..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', @@ -44,6 +46,21 @@ export class AttachmentRepo { .executeTakeFirst(); } + async findByIdWithContent( + attachmentId: string, + opts?: { + trx?: KyselyTransaction; + }, + ): Promise { + const db = dbOrTx(this.db, opts?.trx); + + return db + .selectFrom('attachments') + .select([...this.baseFields, 'textContent']) + .where('id', '=', attachmentId) + .executeTakeFirst(); + } + async insertAttachment( insertableAttachment: InsertableAttachment, trx?: KyselyTransaction, @@ -72,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[], @@ -97,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 fe4d5a13..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; @@ -429,6 +430,31 @@ export interface PagePermissions { updatedAt: Generated; } +export interface AiChats { + id: Generated; + workspaceId: string; + creatorId: string; + 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 { id: Generated; userId: string; @@ -445,6 +471,8 @@ export interface UserSessions { } export interface DB { + aiChats: AiChats; + aiChatMessages: AiChatMessages; apiKeys: ApiKeys; attachments: Attachments; audit: Audit; diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index d23e7475..8d4f482a 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -1,5 +1,7 @@ import { Insertable, Selectable, Updateable } from 'kysely'; import { + AiChats, + AiChatMessages, Attachments, Comments, Groups, @@ -29,6 +31,21 @@ import { } from './db'; import { PageEmbeddings } from '@docmost/db/types/embeddings.types'; +// AI Chat +export type AiChat = Selectable; +export type InsertableAiChat = Insertable; +export type UpdatableAiChat = Updateable>; + +// AI Chat Message +// `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; export type InsertableWorkspace = Insertable; diff --git a/apps/server/src/ee b/apps/server/src/ee index dc7ae0e3..d3bc4c51 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit dc7ae0e3b066df842248b2295ccfad190a0c5a93 +Subproject commit d3bc4c5160fec9c4fabf769180f0ff00ef12042b diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts index b1b27d40..ceb2eae7 100644 --- a/apps/server/src/integrations/environment/environment.service.ts +++ b/apps/server/src/integrations/environment/environment.service.ts @@ -252,6 +252,13 @@ export class EnvironmentService { return this.configService.get('AI_COMPLETION_MODEL'); } + getAiChatModel(): string { + return ( + this.configService.get('AI_CHAT_MODEL') || + this.configService.get('AI_COMPLETION_MODEL') + ); + } + getAiEmbeddingDimension(): number { return parseInt( this.configService.get('AI_EMBEDDING_DIMENSION'), 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):