feat: ai chat

This commit is contained in:
Philipinho
2026-04-10 17:56:09 +01:00
parent e93babc91f
commit 45d3d582d4
55 changed files with 2251 additions and 281 deletions
@@ -627,6 +627,7 @@
"AI Answer": "AI Answer",
"Ask AI": "Ask AI",
"AI is thinking...": "AI is thinking...",
"Thinking": "Thinking",
"Ask a question...": "Ask a question...",
"AI Answers": "AI Answers",
"AI-powered search (AI Answers)": "AI-powered search (AI Answers)",
@@ -751,5 +752,32 @@
"Publish": "Publish",
"Security": "Security",
"Enforce SSO": "Enforce SSO",
"Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to login with email and password."
"Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to login with email and password.",
"AI-generated content may not be accurate.": "AI-generated content may not be accurate.",
"AI Chat": "AI Chat",
"Analyze for insights": "Analyze for insights",
"Ask anything...": "Ask anything...",
"Chat history": "Chat history",
"Chat name": "Chat name",
"Close": "Close",
"Docmost AI": "Docmost AI",
"Failed to load chat. An error occurred.": "Failed to load chat. An error occurred.",
"Failed to render this message.": "Failed to render this message.",
"How can I help you today?": "How can I help you today?",
"New chat": "New chat",
"No chat history": "No chat history",
"No chats found": "No chats found",
"No conversations yet": "No conversations yet",
"Open full page": "Open full page",
"Previous 7 days": "Previous 7 days",
"Previous 30 days": "Previous 30 days",
"Search chats...": "Search chats...",
"Start a new chat to see it here.": "Start a new chat to see it here.",
"Summarize this page": "Summarize this page",
"Toggle AI Chat": "Toggle AI Chat",
"Translate this page": "Translate this page",
"Try a different search term.": "Try a different search term.",
"Try again": "Try again",
"Untitled chat": "Untitled chat",
"What can I help you with?": "What can I help you with?"
}
+5 -2
View File
@@ -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 ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon>
@@ -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));
@@ -1,8 +1,18 @@
import { Badge, Group, Text, Tooltip } from "@mantine/core";
import {
ActionIcon,
Badge,
Box,
Group,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import classes from "./app-header.module.css";
import React from "react";
import TopMenu from "@/components/layouts/global/top-menu.tsx";
import { Link } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import { IconSparkles } from "@tabler/icons-react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import APP_ROUTE from "@/lib/app-route.ts";
import { useAtom } from "jotai";
import {
@@ -23,10 +33,10 @@ import {
shareSearchSpotlight,
} from "@/features/search/constants.ts";
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
const links = [
{ link: APP_ROUTE.HOME, label: "Home" },
{ link: "/ai", label: "AI Chat" },
];
export function AppHeader() {
@@ -37,9 +47,14 @@ export function AppHeader() {
const [desktopOpened] = useAtom(desktopSidebarAtom);
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
const { isTrial, trialDaysLeft } = useTrial();
const location = useLocation();
const toggleAside = useToggleAside();
const [workspace] = useAtom(workspaceAtom);
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
const isHomeRoute = location.pathname.startsWith("/home");
const isSpacesRoute = location.pathname === "/spaces";
const isPageRoute = location.pathname.includes("/p/");
const hideSidebar = isHomeRoute || isSpacesRoute;
const items = links.map((link) => (
@@ -76,15 +91,24 @@ export function AppHeader() {
</>
)}
<Link to="/home" className={classes.brand} aria-label="Docmost">
<Box hiddenFrom="sm" className={classes.brandIcon}>
<img
src="/icons/favicon-32x32.png"
alt="Docmost"
width={22}
height={22}
/>
</Box>
<Text
size="lg"
fw={600}
style={{ cursor: "pointer", userSelect: "none" }}
component={Link}
to="/home"
style={{ userSelect: "none" }}
visibleFrom="sm"
>
Docmost
</Text>
</Link>
<Group ml={50} gap={5} className={classes.links} visibleFrom="sm">
{items}
@@ -101,6 +125,49 @@ export function AppHeader() {
</div>
<Group px={"xl"} wrap="nowrap">
{aiChatEnabled && (
<>
<UnstyledButton
component={Link}
to="/ai"
className={classes.link}
visibleFrom="sm"
onClick={(e: React.MouseEvent) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
return;
}
if (isPageRoute) {
e.preventDefault();
toggleAside("chat");
}
}}
>
{t("AI Chat")}
</UnstyledButton>
<Tooltip label={t("AI Chat")} openDelay={250} withArrow>
<ActionIcon
component={Link}
to="/ai"
variant="subtle"
color="dark"
size="sm"
hiddenFrom="sm"
aria-label={t("AI Chat")}
onClick={(e: React.MouseEvent) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
return;
}
if (isPageRoute) {
e.preventDefault();
toggleAside("chat");
}
}}
>
<IconSparkles size={20} stroke={2} />
</ActionIcon>
</Tooltip>
</>
)}
<NotificationPopover />
{isCloud() && isTrial && trialDaysLeft !== 0 && (
<Badge
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
export default function Aside() {
const [{ tab }] = useAtom(asideStateAtom);
@@ -25,6 +26,10 @@ export default function Aside() {
component = <TableOfContents editor={pageEditor} />;
title = "Table of contents";
break;
case "chat":
component = <AsideChatPanel />;
title = "AI Chat";
break;
default:
component = null;
title = null;
@@ -34,12 +39,14 @@ export default function Aside() {
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{component && (
<>
{tab !== "chat" && (
<Text mb="md" fw={500}>
{t(title)}
</Text>
)}
{tab === "comments" ? (
<CommentListWithTabs />
{tab === "comments" || tab === "chat" ? (
component
) : (
<ScrollArea
style={{ height: "85vh" }}
@@ -84,7 +84,7 @@ export default function GlobalAppShell({
header={{ height: 45 }}
navbar={
!hideSidebar && {
width: isAiRoute ? 260 : isSpaceRoute ? sidebarWidth : 300,
width: isSpaceRoute ? sidebarWidth : 300,
breakpoint: "sm",
collapsed: {
mobile: !mobileOpened,
@@ -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;
}
@@ -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<HTMLDivElement>(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 (
<div className={classes.root}>
<div
ref={trackRef}
className={classes.track}
onScroll={updateScrollState}
{...(ariaLabel ? { role: "region", "aria-label": ariaLabel } : {})}
>
{children}
</div>
<button
type="button"
className={`${classes.arrow} ${classes.arrowLeft} ${canScrollLeft ? classes.visible : ""}`}
onClick={() => scrollBy(-1)}
aria-label={t("Scroll left")}
tabIndex={canScrollLeft ? 0 : -1}
>
<IconChevronLeft size={18} />
</button>
<button
type="button"
className={`${classes.arrow} ${classes.arrowRight} ${canScrollRight ? classes.visible : ""}`}
onClick={() => scrollBy(1)}
aria-label={t("Scroll right")}
tabIndex={canScrollRight ? 0 : -1}
>
<IconChevronRight size={18} />
</button>
</div>
);
}
@@ -1,14 +1,17 @@
import { useEffect } from "react";
import { useParams } from "react-router-dom";
import { useEffect, useRef } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useChatInfoQuery } from "../queries/ai-chat-query";
import { useChatStream } from "../hooks/use-chat-stream";
import ChatMessageList from "./chat-message-list";
import ChatEmptyState from "./chat-empty-state";
import ChatInput from "./chat-input";
import type { HomeAiPromptInitialState } from "@/features/home/components/home-ai-prompt";
import classes from "../styles/ai-chat.module.css";
export default function AiChatLayout() {
const { chatId } = useParams<{ chatId: string }>();
const location = useLocation();
const navigate = useNavigate();
const chatInfoQuery = useChatInfoQuery(chatId);
const {
messages,
@@ -18,48 +21,39 @@ export default function AiChatLayout() {
error,
sendMessage,
stopGeneration,
initMessages,
hydrateFromServer,
} = useChatStream(chatId);
const autoSentRef = useRef(false);
useEffect(() => {
if (chatInfoQuery.data?.messages) {
initMessages(chatInfoQuery.data.messages);
hydrateFromServer(chatInfoQuery.data.messages);
}
}, [chatInfoQuery.data, initMessages]);
}, [chatInfoQuery.data, hydrateFromServer]);
useEffect(() => {
if (!chatId) {
initMessages([]);
}
}, [chatId, initMessages]);
if (autoSentRef.current || chatId) return;
const state = location.state as HomeAiPromptInitialState | null;
if (!state?.initialContent && !state?.initialAttachments?.length) return;
autoSentRef.current = true;
sendMessage(
state.initialContent ?? "",
state.initialMentions ?? [],
state.initialAttachments ?? [],
);
navigate(location.pathname, { replace: true, state: null });
}, [chatId, location, navigate, sendMessage]);
const hasMessages = messages.length > 0 || isStreaming;
if (!hasMessages) {
return (
<div className={classes.main}>
<ChatEmptyState
isStreaming={isStreaming}
onSend={sendMessage}
onStop={stopGeneration}
/>
</div>
);
}
return (
<div className={classes.main}>
<ChatMessageList
messages={messages}
isStreaming={isStreaming}
streamingContent={streamingContent}
streamingToolCalls={streamingToolCalls}
/>
{error && (
<div
style={{
padding: "0 var(--mantine-spacing-lg)",
padding: "var(--mantine-spacing-sm) var(--mantine-spacing-lg)",
color: "var(--mantine-color-red-6)",
fontSize: "var(--mantine-font-size-sm)",
}}
@@ -68,13 +62,30 @@ export default function AiChatLayout() {
</div>
)}
{hasMessages ? (
<>
<ChatMessageList
messages={messages}
isStreaming={isStreaming}
streamingContent={streamingContent}
streamingToolCalls={streamingToolCalls}
/>
<div className={classes.inputArea}>
<ChatInput
isStreaming={isStreaming}
onSend={sendMessage}
onStop={stopGeneration}
chatId={chatId}
/>
</div>
</>
) : (
<ChatEmptyState
isStreaming={isStreaming}
onSend={sendMessage}
onStop={stopGeneration}
/>
)}
</div>
);
}
@@ -1,7 +1,8 @@
import { useState, useRef, useEffect } from "react";
import { ActionIcon, Menu, Popover, TextInput } from "@mantine/core";
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
import { ActionIcon, Menu, TextInput } from "@mantine/core";
import { IconDots, IconTrash, IconEdit } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import type { AiChat } from "../types/ai-chat.types";
import classes from "../styles/chat-sidebar.module.css";
@@ -12,52 +13,117 @@ type Props = {
onRename: (chatId: string, title: string) => void;
};
function formatChatDate(
isoString: string | Date,
locale: string | undefined,
): string {
const date = new Date(isoString);
if (Number.isNaN(date.getTime())) return "";
const now = new Date();
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
).getTime();
const ts = date.getTime();
const sameYear = date.getFullYear() === now.getFullYear();
if (ts >= startOfToday) {
return date.toLocaleTimeString(locale, {
hour: "numeric",
minute: "2-digit",
});
}
if (sameYear) {
return date.toLocaleDateString(locale, {
month: "short",
day: "numeric",
});
}
return date.toLocaleDateString(locale, {
month: "short",
day: "numeric",
year: "numeric",
});
}
export default function AiChatSidebarItem({
chat,
isActive,
onDelete,
onRename,
}: Props) {
const { t, i18n } = useTranslation();
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const formattedDate = useMemo(
() => formatChatDate(chat.updatedAt, i18n.language),
[chat.updatedAt, i18n.language],
);
useEffect(() => {
if (renaming) {
inputRef.current?.select();
// Wait for the input to be mounted before selecting.
const id = window.setTimeout(() => inputRef.current?.select(), 0);
return () => window.clearTimeout(id);
}
}, [renaming]);
const startRename = () => {
const startRename = useCallback(() => {
setRenameValue(chat.title || "");
setRenaming(true);
};
}, [chat.title]);
const submitRename = () => {
const submitRename = useCallback(() => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== chat.title) {
onRename(chat.id, trimmed);
}
setRenaming(false);
};
}, [renameValue, chat.id, chat.title, onRename]);
if (renaming) {
return (
<div className={classes.chatItem} data-active={isActive || undefined}>
<TextInput
ref={inputRef}
size="xs"
variant="unstyled"
placeholder={t("Chat name")}
value={renameValue}
onChange={(e) => 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 }}
/>
</div>
);
}
return (
<Popover
opened={renaming}
onClose={() => setRenaming(false)}
position="bottom-start"
withinPortal
trapFocus
>
<Popover.Target>
<Link
to={`/ai/chat/${chat.id}`}
className={classes.chatItem}
data-active={isActive || undefined}
>
<span className={classes.chatItemTitle}>
{chat.title || "Untitled chat"}
{chat.title || t("Untitled chat")}
</span>
<span className={classes.chatItemDate}>{formattedDate}</span>
<div className={classes.chatItemActions}>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
@@ -75,43 +141,26 @@ export default function AiChatSidebarItem({
leftSection={<IconEdit size={14} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
startRename();
}}
>
Rename
{t("Rename")}
</Menu.Item>
<Menu.Item
leftSection={<IconTrash size={14} />}
color="red"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete(chat.id);
}}
>
Delete
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</div>
</Link>
</Popover.Target>
<Popover.Dropdown p="xs">
<TextInput
ref={inputRef}
size="xs"
placeholder="Chat name"
value={renameValue}
onChange={(e) => setRenameValue(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
submitRename();
} else if (e.key === "Escape") {
setRenaming(false);
}
}}
onBlur={submitRename}
/>
</Popover.Dropdown>
</Popover>
);
}
@@ -1,8 +1,9 @@
import { useState, useCallback, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ActionIcon, TextInput, Loader } from "@mantine/core";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { ActionIcon, Center, TextInput, Loader, Tooltip } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconPlus, IconSearch } from "@tabler/icons-react";
import { IconPlus, IconSearch, IconMessageCircle2 } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
useChatsQuery,
useDeleteChatMutation,
@@ -13,7 +14,52 @@ import AiChatSidebarItem from "./ai-chat-sidebar-item";
import type { AiChat } from "../types/ai-chat.types";
import classes from "../styles/chat-sidebar.module.css";
type ChatGroup = { key: string; label: string; chats: AiChat[] };
function groupChatsByAge(
chats: AiChat[],
t: (key: string) => string,
): ChatGroup[] {
if (chats.length === 0) return [];
const now = new Date();
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
const startOfLast7 = startOfToday - 7 * 24 * 60 * 60 * 1000;
const startOfLast30 = startOfToday - 30 * 24 * 60 * 60 * 1000;
const buckets: Record<string, ChatGroup> = {
today: { key: "today", label: t("Today"), chats: [] },
yesterday: { key: "yesterday", label: t("Yesterday"), chats: [] },
last7: { key: "last7", label: t("Previous 7 days"), chats: [] },
last30: { key: "last30", label: t("Previous 30 days"), chats: [] },
older: { key: "older", label: t("Older"), chats: [] },
};
for (const chat of chats) {
const ts = new Date(chat.updatedAt).getTime();
if (ts >= startOfToday) buckets.today.chats.push(chat);
else if (ts >= startOfYesterday) buckets.yesterday.chats.push(chat);
else if (ts >= startOfLast7) buckets.last7.chats.push(chat);
else if (ts >= startOfLast30) buckets.last30.chats.push(chat);
else buckets.older.chats.push(chat);
}
return [
buckets.today,
buckets.yesterday,
buckets.last7,
buckets.last30,
buckets.older,
].filter((b) => b.chats.length > 0);
}
export default function AiChatSidebar() {
const { t } = useTranslation();
const navigate = useNavigate();
const { chatId } = useParams<{ chatId: string }>();
const [search, setSearch] = useState("");
@@ -30,9 +76,45 @@ export default function AiChatSidebar() {
return chatsQuery.data?.pages.flatMap((p) => p.items) || [];
}, [debouncedSearch, searchQuery.data, chatsQuery.data]);
const handleNewChat = useCallback(() => {
const groupedChats = useMemo(() => groupChatsByAge(chats, t), [chats, t]);
const sentinelRef = useRef<HTMLDivElement>(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<HTMLAnchorElement>) => {
if (
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
) {
return;
}
event.preventDefault();
navigate("/ai");
}, [navigate]);
},
[navigate],
);
const handleDelete = useCallback(
(id: string) => {
@@ -59,15 +141,19 @@ export default function AiChatSidebar() {
return (
<div className={classes.sidebar}>
<div className={classes.header}>
<span className={classes.title}>AI Chat</span>
<span className={classes.title}>{t("AI Chat")}</span>
<Tooltip label={t("New chat")} openDelay={250} withArrow>
<ActionIcon
component={Link}
to="/ai"
variant="subtle"
color="gray"
onClick={handleNewChat}
aria-label="New chat"
aria-label={t("New chat")}
>
<IconPlus size={18} />
</ActionIcon>
</Tooltip>
</div>
<TextInput
@@ -81,7 +167,37 @@ export default function AiChatSidebar() {
<div className={classes.chatList}>
{isLoading && <Loader size="xs" mx="auto" mt="md" />}
{chats.map((chat) => (
{!isLoading && chats.length === 0 && (
<div className={classes.chatListEmpty}>
<IconMessageCircle2
size={28}
stroke={1.5}
className={classes.chatListEmptyIcon}
/>
<div className={classes.chatListEmptyTitle}>
{isSearching ? t("No chats found") : t("No conversations yet")}
</div>
<div className={classes.chatListEmptyHint}>
{isSearching
? t("Try a different search term.")
: t("Start a new chat to see it here.")}
</div>
</div>
)}
{isSearching
? chats.map((chat) => (
<AiChatSidebarItem
key={chat.id}
chat={chat}
isActive={chat.id === chatId}
onDelete={handleDelete}
onRename={handleRename}
/>
))
: groupedChats.map((group) => (
<div key={group.key} className={classes.chatGroup}>
<div className={classes.chatGroupLabel}>{group.label}</div>
{group.chats.map((chat) => (
<AiChatSidebarItem
key={chat.id}
chat={chat}
@@ -91,6 +207,18 @@ export default function AiChatSidebar() {
/>
))}
</div>
))}
{!isSearching && (
<>
<div ref={sentinelRef} style={{ height: 1 }} />
{isFetchingNextPage && (
<Center py="xs">
<Loader size="xs" />
</Center>
)}
</>
)}
</div>
</div>
);
}
@@ -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 (
<div>
<TextInput
placeholder={t("Search chats...")}
leftSection={<IconSearch size={14} />}
size="xs"
mb="xs"
value={searchValue}
onChange={(e) => setSearchValue(e.currentTarget.value)}
/>
{isLoading ? (
<div style={{ display: "flex", justifyContent: "center", padding: 16 }}>
<Loader size="sm" />
</div>
) : chats.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
{isSearching ? t("No chats found") : t("No chat history")}
</Text>
) : (
<ScrollArea.Autosize mah={300} scrollbars="y">
<div className={classes.historyList}>
{chats.map((chat) => (
<div
key={chat.id}
className={classes.historyItem}
data-active={chat.id === activeChatId || undefined}
onClick={() => onSelect(chat.id)}
>
<span className={classes.historyItemTitle}>
{chat.title || t("Untitled chat")}
</span>
</div>
))}
</div>
</ScrollArea.Autosize>
)}
</div>
);
}
@@ -0,0 +1,249 @@
import { useState, useEffect, useCallback } from "react";
import { ActionIcon, Popover, Tooltip, UnstyledButton } from "@mantine/core";
import {
IconPlus,
IconChevronDown,
IconArrowsDiagonal,
IconX,
IconSparkles,
IconFileText,
IconLanguage,
IconSearch,
} from "@tabler/icons-react";
import { useAtom } from "jotai";
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import { usePageQuery } from "@/features/page/queries/page-query";
import { extractPageSlugId } from "@/lib";
import { useChatStream } from "../hooks/use-chat-stream";
import { useChatInfoQuery } from "../queries/ai-chat-query";
import ChatMessageList from "./chat-message-list";
import ChatInput from "./chat-input";
import AsideChatHistory from "./aside-chat-history";
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
import classes from "../styles/aside-chat-panel.module.css";
type QuickAction = {
icon: React.ReactNode;
label: string;
prompt: string;
};
export default function AsideChatPanel() {
const { t } = useTranslation();
const navigate = useNavigate();
const [, setAsideState] = useAtom(asideStateAtom);
const [chatId, setChatId] = useState<string | undefined>(undefined);
const [historyOpen, setHistoryOpen] = useState(false);
const [contextPages, setContextPages] = useState<PageMention[]>([]);
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const { data: page } = usePageQuery({ pageId: slugId });
const chatInfoQuery = useChatInfoQuery(chatId);
const {
messages,
streamingContent,
streamingToolCalls,
isStreaming,
error,
sendMessage,
stopGeneration,
hydrateFromServer,
} = useChatStream(chatId, {
onChatCreated: (newChatId) => {
setChatId(newChatId);
},
});
useEffect(() => {
if (page && !chatId) {
setContextPages([{ id: page.id, title: page.title || "", slugId: page.slugId }]);
}
}, [page, chatId]);
const handleRemoveContextPage = useCallback((pageId: string) => {
setContextPages((prev) => prev.filter((p) => p.id !== pageId));
}, []);
useEffect(() => {
if (chatInfoQuery.data?.messages) {
hydrateFromServer(chatInfoQuery.data.messages);
}
}, [chatInfoQuery.data, hydrateFromServer]);
const handleNewChat = useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
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: <IconFileText size={16} />, label: t("Summarize this page"), prompt: "Summarize this page" },
{ icon: <IconLanguage size={16} />, label: t("Translate this page"), prompt: "Translate this page" },
{ icon: <IconSearch size={16} />, label: t("Analyze for insights"), prompt: "Analyze this page for insights" },
];
return (
<div className={classes.panel}>
<div className={classes.toolbar}>
<Popover
opened={historyOpen}
onChange={setHistoryOpen}
position="bottom-start"
width={280}
shadow="md"
>
<Popover.Target>
<UnstyledButton
className={classes.titleButton}
onClick={() => setHistoryOpen((o) => !o)}
>
<span className={classes.titleText}>
{chatInfoQuery.data?.chat?.title || t("New chat")}
</span>
<IconChevronDown size={16} stroke={1.75} />
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<AsideChatHistory activeChatId={chatId} onSelect={handleSelectChat} />
</Popover.Dropdown>
</Popover>
<div className={classes.toolbarSpacer} />
<Tooltip label={t("New chat")} openDelay={250}>
<ActionIcon
component="a"
href="/ai"
variant="subtle"
color="dark"
onClick={handleNewChat}
>
<IconPlus size={20} stroke={1.75} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Open full page")} openDelay={250}>
<ActionIcon variant="subtle" color="dark" onClick={handleExpand}>
<IconArrowsDiagonal size={18} stroke={1.5} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Close")} openDelay={250}>
<ActionIcon variant="subtle" color="dark" onClick={handleClose}>
<IconX size={20} stroke={1.75} />
</ActionIcon>
</Tooltip>
</div>
{error && (
<div
style={{
padding: "var(--mantine-spacing-xs) var(--mantine-spacing-sm)",
color: "var(--mantine-color-red-6)",
fontSize: "var(--mantine-font-size-xs)",
}}
>
{error}
</div>
)}
{hasMessages ? (
<>
<div className={classes.messages} data-aside-chat>
<ChatMessageList
messages={messages}
isStreaming={isStreaming}
streamingContent={streamingContent}
streamingToolCalls={streamingToolCalls}
/>
</div>
</>
) : (
<div className={classes.emptyState}>
<IconSparkles size={36} stroke={1.5} className={classes.emptyStateIcon} />
<div className={classes.emptyStateTitle}>{t("How can I help you today?")}</div>
<div className={classes.quickActions}>
{quickActions.map((action) => (
<button
key={action.label}
type="button"
className={classes.quickAction}
onClick={() => handleQuickAction(action.prompt)}
>
<span className={classes.quickActionIcon}>{action.icon}</span>
{action.label}
</button>
))}
</div>
</div>
)}
<div className={classes.inputArea}>
<ChatInput
isStreaming={isStreaming}
onSend={handleSend}
onStop={stopGeneration}
placeholder={t("Ask anything...")}
autofocus={false}
contextPages={contextPages}
onRemoveContextPage={handleRemoveContextPage}
variant="flat"
chatId={chatId}
/>
</div>
</div>
);
}
@@ -5,6 +5,7 @@ import {
IconEdit,
IconFileText,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import ChatInput from "./chat-input";
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
import classes from "../styles/ai-chat.module.css";
@@ -45,6 +46,8 @@ type Props = {
};
export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
const { t } = useTranslation();
const handleSuggestionClick = (prompt: string) => {
onSend(prompt, [], []);
};
@@ -52,7 +55,10 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
return (
<div className={classes.emptyState}>
<IconSparkles size={48} stroke={1.5} className={classes.emptyStateIcon} />
<div className={classes.emptyStateTitle}>What can I help you with?</div>
<div className={classes.emptyStateBrand}>{t("Docmost AI")}</div>
<div className={classes.emptyStateTitle}>
{t("What can I help you with?")}
</div>
<div className={classes.emptyStateInput}>
<ChatInput
@@ -1,7 +1,11 @@
import { useCallback, useRef, useEffect, useState } from "react";
import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto } from "@tabler/icons-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";
@@ -15,6 +19,8 @@ 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;
@@ -22,6 +28,11 @@ type Props = {
onStop: () => void;
placeholder?: string;
autofocus?: boolean;
contextPages?: PageMention[];
onRemoveContextPage?: (pageId: string) => void;
variant?: "card" | "flat";
showDisclaimer?: boolean;
chatId?: string;
};
function extractMentions(json: any): PageMention[] {
@@ -84,9 +95,18 @@ export default function ChatInput({
onStop,
placeholder,
autofocus = true,
contextPages,
onRemoveContextPage,
variant = "card",
showDisclaimer = true,
chatId,
}: Props) {
const chatIdRef = useRef(chatId);
chatIdRef.current = chatId;
const { t } = useTranslation();
const [isEmpty, setIsEmpty] = useState(true);
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const onSendRef = useRef(onSend);
onSendRef.current = onSend;
@@ -94,7 +114,32 @@ export default function ChatInput({
const handleFileSelect = useCallback(async (files: FileList | null) => {
if (!files?.length) return;
for (const file of Array.from(files)) {
const room = MAX_ATTACHMENTS_PER_MESSAGE - pendingAttachments.length;
if (room <= 0) {
notifications.show({
color: "yellow",
message: t("You can attach up to {{max}} files per message.", {
max: MAX_ATTACHMENTS_PER_MESSAGE,
}),
});
if (fileInputRef.current) fileInputRef.current.value = "";
return;
}
const incoming = Array.from(files);
const accepted = incoming.slice(0, room);
if (incoming.length > accepted.length) {
notifications.show({
color: "yellow",
message: t(
"Only the first {{n}} file(s) were added (max {{max}} per message).",
{ n: accepted.length, max: MAX_ATTACHMENTS_PER_MESSAGE },
),
});
}
for (const file of accepted) {
const tempId = `uploading-${Date.now()}-${Math.random()}`;
const ext = file.name.split(".").pop()?.toLowerCase() || "";
@@ -110,7 +155,7 @@ export default function ChatInput({
setPendingAttachments((prev) => [...prev, placeholder]);
try {
const uploaded = await uploadChatFile(file);
const uploaded = await uploadChatFile(file, chatIdRef.current);
setPendingAttachments((prev) =>
prev.map((a) =>
a.id === tempId ? { ...uploaded, uploading: false } : a,
@@ -124,7 +169,7 @@ export default function ChatInput({
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}, []);
}, [pendingAttachments.length, t]);
const removeAttachment = useCallback((id: string) => {
setPendingAttachments((prev) => prev.filter((a) => a.id !== id));
@@ -157,6 +202,9 @@ export default function ChatInput({
Placeholder.configure({
placeholder: placeholder || "Ask anything... Use @ to mention pages",
}),
CharacterCount.configure({
limit: 50000,
}),
LinkExtension,
EmojiCommand,
Mention.configure({
@@ -215,10 +263,13 @@ export default function ChatInput({
}
}, [editor]);
const hasContent = !isEmpty || pendingAttachments.some((a) => !a.uploading);
const hasContent = !isEmpty || pendingAttachments.some((a) => !a.uploading) || (contextPages?.length ?? 0) > 0;
const wrapperClass = variant === "flat" ? classes.inputWrapperFlat : classes.inputWrapper;
return (
<div className={classes.inputWrapper} data-chat-input>
<>
<div className={wrapperClass} data-chat-input>
<input
ref={fileInputRef}
type="file"
@@ -228,8 +279,26 @@ export default function ChatInput({
onChange={(e) => handleFileSelect(e.target.files)}
/>
{pendingAttachments.length > 0 && (
{((contextPages?.length ?? 0) > 0 || pendingAttachments.length > 0) && (
<div className={classes.attachmentChips}>
{contextPages?.map((page) => (
<div key={page.id} className={classes.attachmentChip}>
<IconFileText size={14} />
<span className={classes.attachmentChipName}>
{page.title || "Untitled"}
</span>
{onRemoveContextPage && (
<button
type="button"
className={classes.attachmentChipRemove}
onClick={() => onRemoveContextPage(page.id)}
aria-label={`Remove ${page.title}`}
>
<IconX size={12} />
</button>
)}
</div>
))}
{pendingAttachments.map((attachment) => (
<div
key={attachment.id}
@@ -260,14 +329,51 @@ export default function ChatInput({
<EditorContent editor={editor} className={classes.editorContent} />
<div className={classes.actions}>
<Popover opened={plusMenuOpen} onChange={setPlusMenuOpen} position="top-start" width={220} shadow="md">
<Popover.Target>
<button
type="button"
className={classes.attachButton}
onClick={() => fileInputRef.current?.click()}
aria-label="Attach file"
className={classes.plusButton}
onClick={() => setPlusMenuOpen((o) => !o)}
aria-label="Add content"
>
<IconPaperclip size={18} />
<IconPlus size={14} />
</button>
</Popover.Target>
<Popover.Dropdown p={4}>
<button
type="button"
className={classes.plusMenuItem}
onClick={() => {
fileInputRef.current?.click();
setPlusMenuOpen(false);
}}
disabled={pendingAttachments.length >= MAX_ATTACHMENTS_PER_MESSAGE}
title={
pendingAttachments.length >= MAX_ATTACHMENTS_PER_MESSAGE
? t("Max {{max}} files per message", {
max: MAX_ATTACHMENTS_PER_MESSAGE,
})
: undefined
}
>
<IconPaperclip size={16} className={classes.plusMenuIcon} />
{t("Add files")}
</button>
<button
type="button"
className={classes.plusMenuItem}
onClick={() => {
editor?.commands.insertContent("@");
editor?.commands.focus();
setPlusMenuOpen(false);
}}
>
<IconAt size={16} className={classes.plusMenuIcon} />
Mention a page
</button>
</Popover.Dropdown>
</Popover>
<div style={{ flex: 1 }} />
@@ -293,5 +399,11 @@ export default function ChatInput({
)}
</div>
</div>
{showDisclaimer && (
<div className={classes.disclaimer}>
{t("AI-generated content may not be accurate.")}
</div>
)}
</>
);
}
@@ -1,8 +1,21 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useCallback, useState } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { IconArrowDown, IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
import ChatMessage from "./chat-message";
import classes from "../styles/ai-chat.module.css";
function ChatMessageErrorFallback() {
const { t } = useTranslation();
return (
<div className={classes.messageErrorFallback}>
<IconAlertTriangle size={14} />
<span>{t("Failed to render this message.")}</span>
</div>
);
}
type Props = {
messages: AiChatMessage[];
isStreaming: boolean;
@@ -10,24 +23,124 @@ type Props = {
streamingToolCalls: AiChatToolCall[];
};
const BOTTOM_THRESHOLD_PX = 32;
const SCROLL_UP_THRESHOLD_PX = 5;
const SMOOTH_SCROLL_SETTLE_MS = 600;
export default function ChatMessageList({
messages,
isStreaming,
streamingContent,
streamingToolCalls,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true);
const isAutoScrollingRef = useRef(false);
const prevScrollTopRef = useRef(0);
const [showScrollButton, setShowScrollButton] = useState(false);
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
const container = containerRef.current;
if (!container) return;
isAutoScrollingRef.current = true;
const target = container.scrollHeight - container.clientHeight;
container.scrollTo({ top: target, behavior });
prevScrollTopRef.current = target;
isAtBottomRef.current = true;
setShowScrollButton(false);
if (behavior === "smooth") {
setTimeout(() => {
isAutoScrollingRef.current = false;
if (containerRef.current) {
prevScrollTopRef.current = containerRef.current.scrollTop;
}
}, SMOOTH_SCROLL_SETTLE_MS);
} else {
isAutoScrollingRef.current = false;
}
}, []);
const handleScroll = useCallback(() => {
if (isAutoScrollingRef.current) return;
const container = containerRef.current;
if (!container) return;
const currentScrollTop = container.scrollTop;
const scrolledUp =
currentScrollTop < prevScrollTopRef.current - SCROLL_UP_THRESHOLD_PX;
prevScrollTopRef.current = currentScrollTop;
const distanceFromBottom =
container.scrollHeight - currentScrollTop - container.clientHeight;
const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX;
if (scrolledUp) {
isAtBottomRef.current = atBottom;
} else if (atBottom) {
isAtBottomRef.current = true;
}
setShowScrollButton(!atBottom);
}, []);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages.length, streamingContent, streamingToolCalls.length]);
const container = containerRef.current;
if (!container) return;
container.addEventListener("scroll", handleScroll, { passive: true });
return () => container.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
// Instant scroll during streaming to keep up with rapid updates
useEffect(() => {
if (isAtBottomRef.current) {
scrollToBottom("instant");
}
}, [streamingContent, streamingToolCalls.length, scrollToBottom]);
// Smooth scroll for new messages. Always force-scroll when the latest
// message is from the user (they just sent it), even if they were reading
// scrollback.
useEffect(() => {
const lastMessage = messages[messages.length - 1];
const lastIsUser = lastMessage?.role === "user";
if (lastIsUser || isAtBottomRef.current) {
scrollToBottom("smooth");
return;
}
// No auto-scroll: recompute from actual layout so that chat switches to
// content that doesn't overflow correctly hide the button even when no
// scroll event fires.
const container = containerRef.current;
if (!container) return;
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX;
isAtBottomRef.current = atBottom;
setShowScrollButton(!atBottom);
}, [messages, scrollToBottom]);
return (
<div className={classes.messageList}>
<div className={classes.messageListWrapper}>
<div ref={containerRef} className={classes.messageList}>
{messages.map((msg) => (
<ChatMessage key={msg.id} message={msg} />
<ErrorBoundary
key={msg.id}
fallback={<ChatMessageErrorFallback />}
>
<ChatMessage message={msg} />
</ErrorBoundary>
))}
{isStreaming && (
<ErrorBoundary
resetKeys={[streamingContent, streamingToolCalls.length]}
fallback={<ChatMessageErrorFallback />}
>
<ChatMessage
message={{
id: "streaming",
@@ -42,8 +155,20 @@ export default function ChatMessageList({
streamingContent={streamingContent}
streamingToolCalls={streamingToolCalls}
/>
</ErrorBoundary>
)}
<div ref={bottomRef} />
</div>
{showScrollButton && (
<button
type="button"
aria-label="Scroll to bottom"
className={classes.scrollToBottomButton}
onClick={() => scrollToBottom("smooth")}
>
<IconArrowDown size={16} stroke={2} />
</button>
)}
</div>
);
}
@@ -1,11 +1,31 @@
import { useCallback } from "react";
import { useNavigate } from "react-router";
import DOMPurify from "dompurify";
import { IconFile, IconLoader2, IconPhoto } from "@tabler/icons-react";
import { ActionIcon, Tooltip } from "@mantine/core";
import {
IconCheck,
IconCopy,
IconFile,
IconLoader2,
IconPhoto,
} from "@tabler/icons-react";
import { markdownToHtml } from "@docmost/editor-ext";
import { CopyButton } from "@/components/common/copy-button";
import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
import ChatToolResult from "./chat-tool-result";
import ChatToolGroup from "./chat-tool-group";
import classes from "../styles/chat-message.module.css";
import CopyTextButton from "@/components/common/copy.tsx";
const chatSanitizer = DOMPurify();
chatSanitizer.addHook("afterSanitizeAttributes", (node) => {
if (node.tagName === "A") {
const href = node.getAttribute("href") || "";
if (href.startsWith("http://") || href.startsWith("https://")) {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer");
}
}
});
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"];
@@ -50,7 +70,12 @@ export default function ChatMessage({
/\n\n<referenced_pages>[\s\S]*<\/referenced_pages>$/,
"",
);
const attachments = (message.metadata?.attachments as { id: string; fileName: string; fileExt: string }[]) || [];
const attachments =
(message.metadata?.attachments as {
id: string;
fileName: string;
fileExt: string;
}[]) || [];
return (
<div className={classes.userMessage}>
@@ -78,15 +103,16 @@ export default function ChatMessage({
return (
<div className={classes.assistantMessage}>
<div className={classes.messageContent}>
{toolCalls?.map((tc) => (
<ChatToolResult key={tc.id} toolCall={tc} />
))}
{toolCalls && toolCalls.length > 0 && (
<ChatToolGroup toolCalls={toolCalls} isStreaming={isStreaming} />
)}
{content && (
<div
onClick={handleContentClick}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(
__html: chatSanitizer.sanitize(
markdownToHtml(content) as string,
{ ADD_ATTR: ["target", "rel"] },
),
}}
/>
@@ -103,6 +129,11 @@ export default function ChatMessage({
</>
)}
</div>
{!isStreaming && message.content && (
<div className={classes.messageActions}>
<CopyTextButton text={message?.content} />
</div>
)}
</div>
);
}
@@ -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 (
<div className={classes.toolGroup}>
<div
className={classes.toolGroupHeader}
onClick={() => setExpanded((prev) => !prev)}
>
{activeLabel ? (
<IconLoader2 size={12} className={classes.processingSpinner} />
) : expanded ? (
<IconChevronDown size={12} />
) : (
<IconChevronRight size={12} />
)}
<span className={classes.toolGroupLabel}>
{activeLabel ? `${activeLabel}` : `Steps ${toolCalls.length}`}
</span>
</div>
{expanded && (
<div className={classes.toolGroupSteps}>
{toolCalls.map((tc) => (
<ChatToolResult key={tc.id} toolCall={tc} />
))}
</div>
)}
</div>
);
}
@@ -1,9 +1,9 @@
import { useState } from "react";
import { IconChevronRight, IconChevronDown, IconTool } from "@tabler/icons-react";
import { IconChevronRight, IconChevronDown } from "@tabler/icons-react";
import type { AiChatToolCall } from "../types/ai-chat.types";
import classes from "../styles/chat-message.module.css";
const TOOL_LABELS: Record<string, string> = {
export const TOOL_LABELS: Record<string, string> = {
list_spaces: "Listed spaces",
search_pages: "Searched pages",
get_page: "Read page",
@@ -20,21 +20,21 @@ export default function ChatToolResult({ toolCall }: Props) {
const label = TOOL_LABELS[toolCall.name] || toolCall.name;
return (
<div className={classes.toolCallCard}>
<div className={classes.toolStep}>
<div
className={classes.toolCallHeader}
className={classes.toolStepRow}
onClick={() => setExpanded((prev) => !prev)}
>
<IconTool size={14} stroke={1.5} />
<span className={classes.toolCallName}>{label}</span>
<span className={classes.toolStepBullet}>·</span>
{expanded ? (
<IconChevronDown size={14} />
<IconChevronDown size={12} />
) : (
<IconChevronRight size={14} />
<IconChevronRight size={12} />
)}
<span>{label}</span>
</div>
{expanded && (
<div className={classes.toolCallDetails}>
<div className={classes.toolStepDetails}>
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
{JSON.stringify(
{ args: toolCall.args, result: toolCall.result },
@@ -1,12 +1,13 @@
import { Group, Text, Switch } from "@mantine/core";
import { Badge, Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { isCloud } from "@/lib/config.ts";
import useLicense from "@/ee/hooks/use-license.tsx";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function EnableAiChat() {
const { t } = useTranslation();
@@ -14,7 +15,12 @@ export default function EnableAiChat() {
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Group gap="xs" align="center">
<Text size="md">{t("AI Chat")}</Text>
<Badge color="gray" variant="light" size="sm" radius="sm">
{t("Beta")}
</Badge>
</Group>
<Text size="sm" c="dimmed">
{t(
"Enable AI Chat to allow users to have multi-turn conversations with AI about your workspace content.",
@@ -31,9 +37,8 @@ function AiChatToggle() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.chat);
const { hasLicenseKey } = useLicense();
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
@@ -50,11 +55,13 @@ function AiChatToggle() {
};
return (
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle AI Chat")}
/>
</Tooltip>
);
}
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from "react";
import { useState, useCallback, useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { sendChatMessage } from "../services/ai-chat-service";
@@ -10,7 +10,14 @@ import type {
PageMention,
} from "../types/ai-chat.types";
export function useChatStream(chatId: string | undefined) {
type ChatStreamOptions = {
onChatCreated?: (chatId: string) => void;
};
export function useChatStream(
chatId: string | undefined,
options?: ChatStreamOptions,
) {
const [messages, setMessages] = useState<AiChatMessage[]>([]);
const [streamingContent, setStreamingContent] = useState("");
const [streamingToolCalls, setStreamingToolCalls] = useState<AiChatToolCall[]>(
@@ -18,21 +25,46 @@ export function useChatStream(chatId: string | undefined) {
);
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState<string | null>(null);
const [errorCode, setErrorCode] = useState<string | null>(null);
const [isRetryable, setIsRetryable] = useState(false);
const abortRef = useRef<AbortController | null>(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<string | undefined>(undefined);
const initMessages = useCallback((msgs: AiChatMessage[]) => {
// Reset local state when the consumer switches to a different chat.
// Skip the reset if the new chatId is one the hook itself already claimed
// during a new-chat flow — in that case our optimistic state is the truth.
useEffect(() => {
if (chatId && chatId === hydratedChatIdRef.current) return;
hydratedChatIdRef.current = undefined;
setMessages([]);
setError(null);
setErrorCode(null);
setIsRetryable(false);
}, [chatId]);
const hydrateFromServer = useCallback((msgs: AiChatMessage[]) => {
const forId = currentChatIdRef.current;
if (!forId) return;
if (hydratedChatIdRef.current === forId) return;
hydratedChatIdRef.current = forId;
setMessages(msgs);
}, []);
const sendMessage = useCallback(
(content: string, mentions: PageMention[] = [], attachments: ChatAttachment[] = []) => {
(content: string, mentions: PageMention[] = [], attachments: ChatAttachment[] = [], contextPageId?: string) => {
if (isStreaming || (!content.trim() && attachments.length === 0)) return;
setError(null);
setErrorCode(null);
setIsRetryable(false);
setIsStreaming(true);
setStreamingContent("");
setStreamingToolCalls([]);
@@ -68,13 +100,22 @@ export function useChatStream(chatId: string | undefined) {
chatId: currentChatIdRef.current,
content,
mentionedPageIds: mentions.map((m) => m.id),
...(contextPageId && { contextPageId }),
...(attachmentIds.length && { attachmentIds }),
},
(event: AiChatStreamEvent) => {
switch (event.type) {
case "chat_created":
currentChatIdRef.current = event.chatId;
// Claim authority over this new chatId so when the consumer's
// prop catches up via navigation/onChatCreated, the reset effect
// sees a match and preserves our optimistic messages.
hydratedChatIdRef.current = event.chatId;
if (options?.onChatCreated) {
options.onChatCreated(event.chatId);
} else {
navigate(`/ai/chat/${event.chatId}`, { replace: true });
}
queryClient.invalidateQueries({ queryKey: ["ai-chats"] });
break;
case "content":
@@ -125,6 +166,8 @@ export function useChatStream(chatId: string | undefined) {
}
case "error":
setError(event.message);
setErrorCode(event.code || null);
setIsRetryable(event.retryable || false);
setIsStreaming(false);
break;
}
@@ -175,8 +218,10 @@ export function useChatStream(chatId: string | undefined) {
streamingToolCalls,
isStreaming,
error,
errorCode,
isRetryable,
sendMessage,
stopGeneration,
initMessages,
hydrateFromServer,
};
}
@@ -1,10 +1,39 @@
import { useParams } from "react-router-dom";
import { ErrorBoundary } from "react-error-boundary";
import { Button } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import AiChatLayout from "../components/ai-chat-layout";
import { EmptyState } from "@/components/ui/empty-state.tsx";
import classes from "../styles/ai-chat.module.css";
export default function AiChat() {
const { t } = useTranslation();
const { chatId } = useParams<{ chatId: string }>();
return (
<div className={classes.layout}>
<ErrorBoundary
resetKeys={[chatId]}
fallbackRender={({ resetErrorBoundary }) => (
<EmptyState
icon={IconAlertTriangle}
title={t("Failed to load chat. An error occurred.")}
action={
<Button
variant="default"
size="sm"
mt="xs"
onClick={resetErrorBoundary}
>
{t("Try again")}
</Button>
}
/>
)}
>
<AiChatLayout />
</ErrorBoundary>
</div>
);
}
@@ -8,7 +8,7 @@ import type {
import { IPagination } from "@/lib/types.ts";
export async function createChat(): Promise<AiChat> {
const req = await api.post<AiChat>("/ai-chat/create");
const req = await api.post<AiChat>("/ai/chats/create");
return req.data;
}
@@ -16,37 +16,43 @@ export async function listChats(params?: {
limit?: number;
cursor?: string;
}): Promise<IPagination<AiChat>> {
const req = await api.post("/ai-chat/list", params);
const req = await api.post("/ai/chats", params);
return req.data;
}
export async function getChatInfo(
chatId: string,
): Promise<{ chat: AiChat; messages: AiChatMessage[] }> {
const req = await api.post("/ai-chat/info", { chatId });
const req = await api.post("/ai/chats/info", { chatId });
return req.data;
}
export async function deleteChat(chatId: string): Promise<void> {
await api.post("/ai-chat/delete", { chatId });
await api.post("/ai/chats/delete", { chatId });
}
export async function updateChatTitle(
chatId: string,
title: string,
): Promise<void> {
await api.post("/ai-chat/update", { chatId, title });
await api.post("/ai/chats/update", { chatId, title });
}
export async function searchChats(query: string): Promise<AiChat[]> {
const req = await api.post("/ai-chat/search", { query });
const req = await api.post("/ai/chats/search", { query });
return req.data;
}
export async function uploadChatFile(file: File): Promise<ChatAttachment> {
export async function uploadChatFile(
file: File,
chatId?: string,
): Promise<ChatAttachment> {
const formData = new FormData();
formData.append("file", file);
return await api.post("/ai-chat/upload", formData, {
if (chatId) {
formData.append("chatId", chatId);
}
return await api.post("/ai/chats/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
}
@@ -56,6 +62,7 @@ export function sendChatMessage(
chatId?: string;
content: string;
mentionedPageIds?: string[];
contextPageId?: string;
attachmentIds?: string[];
},
onEvent: (event: AiChatStreamEvent) => void,
@@ -66,7 +73,7 @@ export function sendChatMessage(
(async () => {
try {
const response = await fetch("/api/ai-chat/send", {
const response = await fetch("/api/ai/chats/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
@@ -14,11 +14,62 @@
width: 100%;
}
.messageListWrapper {
flex: 1;
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
width: 100%;
}
.messageList {
flex: 1;
overflow-y: auto;
padding: var(--mantine-spacing-md) var(--mantine-spacing-lg);
scroll-behavior: smooth;
}
.messageErrorFallback {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
margin-bottom: var(--mantine-spacing-lg);
border-radius: var(--mantine-radius-sm);
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
font-size: var(--mantine-font-size-xs);
}
.scrollToBottomButton {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
cursor: pointer;
padding: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: background-color 120ms ease, border-color 120ms ease, transform 120ms ease;
z-index: 2;
}
.scrollToBottomButton:hover {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
}
.scrollToBottomButton:active {
transform: translateX(-50%) scale(0.95);
}
.inputArea {
@@ -38,10 +89,19 @@
.emptyStateIcon {
width: 48px;
height: 48px;
margin-bottom: var(--mantine-spacing-lg);
margin-bottom: var(--mantine-spacing-sm);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
}
.emptyStateBrand {
font-size: var(--mantine-font-size-xs);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
margin-bottom: var(--mantine-spacing-xs);
}
.emptyStateTitle {
font-size: 1.5rem;
font-weight: 600;
@@ -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;
}
@@ -1,21 +1,51 @@
.inputWrapper {
position: relative;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
overflow: hidden;
border: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
border-radius: 16px;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.04),
0 4px 12px rgba(0, 0, 0, 0.06);
transition: border-color 150ms, box-shadow 150ms;
box-shadow: light-dark(
0 2px 40px 4px rgba(0, 0, 0, 0.07),
0 2px 40px 4px rgba(0, 0, 0, 0.5)
);
transition:
border-color 150ms,
box-shadow 150ms;
&:focus-within {
border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.04),
0 4px 16px rgba(0, 0, 0, 0.1);
border-color: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-dark-4)
);
box-shadow: light-dark(
0 4px 48px 6px rgba(0, 0, 0, 0.09),
0 4px 48px 6px rgba(0, 0, 0, 0.6)
);
}
}
.inputWrapperFlat {
position: relative;
overflow: hidden;
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
border-radius: 12px;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
box-shadow: none;
transition: border-color 150ms;
&:focus-within {
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
}
.disclaimer {
margin-top: 6px;
text-align: center;
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.attachmentChips {
display: flex;
flex-wrap: wrap;
@@ -68,6 +98,7 @@
:global(.ProseMirror) {
outline: none;
border: none;
background-color: transparent;
padding: 14px 18px 8px;
font-size: 15px;
line-height: 1.6;
@@ -142,6 +173,54 @@
}
}
.plusButton {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
background: none;
cursor: pointer;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
transition: color 150ms, background-color 150ms;
@mixin hover {
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
}
.plusMenuItem {
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
border: none;
background: none;
cursor: pointer;
width: 100%;
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
border-radius: var(--mantine-radius-sm);
transition: background-color 150ms;
@mixin hover {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
background: none;
}
}
.plusMenuIcon {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.stopButton {
width: 28px;
height: 28px;
@@ -20,6 +20,11 @@
overflow-wrap: break-word;
}
[data-aside-chat] .userBubble {
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.userBubble p {
margin: 0;
}
@@ -165,32 +170,82 @@
margin: 1em 0;
}
.toolCallCard {
margin: var(--mantine-spacing-xs) 0;
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
border-radius: var(--mantine-radius-sm);
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
.toolGroup {
margin: 6px 0;
font-size: var(--mantine-font-size-xs);
}
.toolCallHeader {
display: flex;
.toolGroupHeader {
display: inline-flex;
align-items: center;
gap: var(--mantine-spacing-xs);
gap: 6px;
cursor: pointer;
user-select: none;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
line-height: 1.4;
transition: color 120ms ease;
}
.toolCallName {
font-weight: 600;
color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-4));
.toolGroupHeader:hover {
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
}
.toolCallDetails {
margin-top: var(--mantine-spacing-xs);
padding-top: var(--mantine-spacing-xs);
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
.toolGroupLabel {
font-weight: 500;
}
.toolGroupSteps {
margin-top: 4px;
padding-left: 14px;
display: flex;
flex-direction: column;
gap: 2px;
}
.toolStep {
font-size: var(--mantine-font-size-xs);
}
.toolStepRow {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
user-select: none;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
line-height: 1.5;
transition: color 120ms ease;
}
.toolStepRow:hover {
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
}
.toolStepBullet {
display: inline-block;
width: 8px;
text-align: center;
opacity: 0.6;
}
.toolStepDetails {
margin-top: 4px;
margin-left: 18px;
padding: 6px 10px;
border-radius: var(--mantine-radius-sm);
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
font-size: 11px;
line-height: 1.5;
overflow-x: auto;
}
.messageActions {
display: flex;
align-items: center;
gap: 4px;
margin-top: 4px;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
}
.processingIndicator {
@@ -28,6 +28,46 @@
overflow-y: auto;
}
.chatGroup + .chatGroup {
margin-top: var(--mantine-spacing-sm);
}
.chatGroupLabel {
padding: 4px var(--mantine-spacing-xs);
font-size: var(--mantine-font-size-xs);
font-weight: 600;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
user-select: none;
}
.chatListEmpty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--mantine-spacing-xl) var(--mantine-spacing-md);
text-align: center;
gap: 4px;
user-select: none;
}
.chatListEmptyIcon {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
margin-bottom: var(--mantine-spacing-xs);
}
.chatListEmptyTitle {
font-size: var(--mantine-font-size-sm);
font-weight: 600;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
.chatListEmptyHint {
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3));
line-height: 1.4;
}
.chatItem {
display: flex;
align-items: center;
@@ -66,13 +106,33 @@
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
white-space: nowrap;
transition: opacity 150ms;
}
.chatItemRenameInput {
font-size: var(--mantine-font-size-sm);
padding: 0;
height: auto;
min-height: 0;
background: transparent;
color: inherit;
}
.chatItem:hover .chatItemDate {
opacity: 0;
}
.chatItemActions {
position: absolute;
right: var(--mantine-spacing-xs);
opacity: 0;
transition: opacity 150ms;
}
.chatItem {
position: relative;
}
.chatItem:hover .chatItemActions {
opacity: 1;
}
@@ -30,7 +30,7 @@ export type AiChatStreamEvent =
| { type: 'tool_call'; id: string; name: string; args: Record<string, unknown> }
| { type: 'tool_result'; id: string; result: unknown }
| { type: 'done'; messageId: string; usage?: Record<string, number> }
| { type: 'error'; message: string };
| { type: 'error'; message: string; code?: string; retryable?: boolean };
export type PageMention = {
id: string;
@@ -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;
}
@@ -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 (
<div className={classes.wrapper}>
<h1 className={classes.heading}>
{t("Welcome to {{name}}", { name: workspace?.name ?? "Docmost" })}
</h1>
<div className={classes.subtitle}>
{t("Ask anything or search your workspace")}
</div>
<div className={classes.inputContainer}>
<ChatInput
isStreaming={false}
onSend={handleSend}
onStop={() => {}}
placeholder={t("Ask anything... Use @ to mention pages")}
autofocus={false}
/>
</div>
</div>
);
}
@@ -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);
@@ -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;
}
@@ -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) => (
<Card
key={space.id}
p="xs"
radius="md"
component={Link}
to={getSpaceUrl(space.slug)}
onMouseEnter={() => prefetchSpace(space.slug, space.id)}
className={classes.card}
withBorder
>
<Card.Section className={classes.cardSection} h={40}></Card.Section>
<CustomAvatar
name={space.name}
avatarUrl={space.logo}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
size="md"
mt={rem(-20)}
/>
<Text fz="md" fw={500} mt="xs" className={classes.title}>
{space.name}
</Text>
<Text c="dimmed" size="xs" fw={700} mt="md">
{formatMemberCount(space.memberCount, t)}
</Text>
</Card>
));
return (
<>
<Group justify="space-between" align="center" mb="md">
<Text fz="sm" fw={500}>
{t("Spaces you belong to")}
</Text>
</Group>
<CardCarousel ariaLabel={t("Spaces you belong to")}>{cards}</CardCarousel>
{data?.items && data.items.length >= 20 && (
<Group justify="flex-end" mt="lg">
<Button
component={Link}
to="/spaces"
variant="subtle"
rightSection={<IconArrowRight size={16} />}
size="sm"
>
{t("View all spaces")}
</Button>
</Group>
)}
</>
);
}
+7 -2
View File
@@ -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() {
</title>
</Helmet>
<Container size={"800"} pt="xl">
<SpaceGrid />
<HomeAiPrompt />
<Space h="xl" />
<SpaceCarousel />
<Space h="xl" />
+2
View File
@@ -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",
@@ -3,6 +3,7 @@ export enum AttachmentType {
WorkspaceIcon = 'workspace-icon',
SpaceIcon = 'space-icon',
File = 'file',
Chat = 'chat',
}
export const validImageExtensions = ['.jpg', '.png', '.jpeg'];
@@ -178,12 +178,19 @@ 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();
}
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();
}
@@ -193,6 +200,7 @@ export class AttachmentController {
}
await this.pageAccessService.validateCanView(page, user);
}
try {
return await this.sendFileResponse(req, res, attachment, 'private');
@@ -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`;
}
@@ -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
@@ -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);
+8 -3
View File
@@ -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')
@@ -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
@@ -3,6 +3,7 @@ import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('ai_chats')
.ifNotExists()
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
@@ -19,16 +20,19 @@ export async function up(db: Kysely<any>): Promise<void> {
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('deleted_at', 'timestamptz', (col) => col)
.execute();
await db.schema
.createIndex('idx_ai_chats_workspace_creator')
.ifNotExists()
.on('ai_chats')
.columns(['workspace_id', 'creator_id', 'id'])
.execute();
await db.schema
.createTable('ai_chat_messages')
.ifNotExists()
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
@@ -38,23 +42,77 @@ export async function up(db: Kysely<any>): Promise<void> {
.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<any>): Promise<void> {
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();
}
@@ -7,6 +7,7 @@ import {
InsertableAttachment,
UpdatableAttachment,
} from '@docmost/db/types/entity.types';
import { AttachmentType } from '../../../core/attachment/attachment.constants';
@Injectable()
export class AttachmentRepo {
@@ -23,6 +24,7 @@ export class AttachmentRepo {
'creatorId',
'pageId',
'spaceId',
'aiChatId',
'workspaceId',
'createdAt',
'updatedAt',
@@ -87,6 +89,21 @@ export class AttachmentRepo {
.execute();
}
async findByAiChatId(
aiChatId: string,
opts?: {
trx?: KyselyTransaction;
},
): Promise<Attachment[]> {
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('attachments')
.select(this.baseFields)
.where('aiChatId', '=', aiChatId)
.execute();
}
updateAttachmentsByPageId(
updatableAttachment: UpdatableAttachment,
pageIds: string[],
@@ -112,6 +129,25 @@ export class AttachmentRepo {
.executeTakeFirst();
}
async claimAttachmentsForChat(
attachmentIds: string[],
aiChatId: string,
creatorId: string,
workspaceId: string,
): Promise<void> {
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<void> {
await this.db
.deleteFrom('attachments')
+6
View File
@@ -43,6 +43,7 @@ export interface ApiKeys {
}
export interface Attachments {
aiChatId: string | null;
createdAt: Generated<Timestamp>;
creatorId: string;
deletedAt: Timestamp | null;
@@ -436,17 +437,22 @@ export interface AiChats {
title: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
}
export interface AiChatMessages {
id: Generated<string>;
chatId: string;
workspaceId: string;
userId: string | null;
role: string;
content: string | null;
toolCalls: Json | null;
metadata: Json | null;
tsv: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
}
export interface UserSessions {
@@ -37,8 +37,14 @@ export type InsertableAiChat = Insertable<AiChats>;
export type UpdatableAiChat = Updateable<Omit<AiChats, 'id'>>;
// AI Chat Message
export type AiChatMessage = Selectable<AiChatMessages>;
export type InsertableAiChatMessage = Insertable<AiChatMessages>;
// `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<Selectable<AiChatMessages>, 'tsv'>;
export type InsertableAiChatMessage = Omit<
Insertable<AiChatMessages>,
'tsv'
>;
// Workspace
export type Workspace = Selectable<Workspaces>;
@@ -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();
@@ -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',
@@ -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({
@@ -0,0 +1,2 @@
export const AUTH_THROTTLER = 'auth';
export const AI_CHAT_THROTTLER = 'ai-chat';
@@ -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<string> {
const userId = req.user?.id;
if (userId) return `user:${userId}`;
return super.getTracker(req as Parameters<ThrottlerGuard['getTracker']>[0]);
}
}
@@ -37,6 +37,8 @@ marked.use({
extensions: [calloutExtension, mathBlockExtension, mathInlineExtension],
});
marked.setOptions({ breaks: true });
export function markdownToHtml(
markdownInput: string,
): string | Promise<string> {
@@ -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();
}
+22
View File
@@ -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):