diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx
index c290157c..b8d83340 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";
export default function App() {
const { t } = useTranslation();
@@ -79,6 +80,8 @@ export default function App() {
}>
} />
+ } />
+ } />
} />
} />
} />
diff --git a/apps/client/src/components/layouts/global/app-header.tsx b/apps/client/src/components/layouts/global/app-header.tsx
index 58b76b71..49c2ba64 100644
--- a/apps/client/src/components/layouts/global/app-header.tsx
+++ b/apps/client/src/components/layouts/global/app-header.tsx
@@ -24,7 +24,10 @@ import {
} from "@/features/search/constants.ts";
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
-const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
+const links = [
+ { link: APP_ROUTE.HOME, label: "Home" },
+ { link: "/ai", label: "AI Chat" },
+];
export function AppHeader() {
const { t } = useTranslation();
diff --git a/apps/client/src/components/layouts/global/global-app-shell.tsx b/apps/client/src/components/layouts/global/global-app-shell.tsx
index 3e44b293..fbaa759d 100644
--- a/apps/client/src/components/layouts/global/global-app-shell.tsx
+++ b/apps/client/src/components/layouts/global/global-app-shell.tsx
@@ -10,6 +10,7 @@ import {
sidebarWidthAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
+import AiChatSidebar from "@/ee/ai-chat/components/ai-chat-sidebar.tsx";
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
import Aside from "@/components/layouts/global/aside.tsx";
import classes from "./app-shell.module.css";
@@ -72,6 +73,7 @@ export default function GlobalAppShell({
const location = useLocation();
const isSettingsRoute = location.pathname.startsWith("/settings");
const isSpaceRoute = location.pathname.startsWith("/s/");
+ const isAiRoute = location.pathname.startsWith("/ai");
const isHomeRoute = location.pathname.startsWith("/home");
const isSpacesRoute = location.pathname === "/spaces";
const isPageRoute = location.pathname.includes("/p/");
@@ -82,7 +84,7 @@ export default function GlobalAppShell({
header={{ height: 45 }}
navbar={
!hideSidebar && {
- width: isSpaceRoute ? sidebarWidth : 300,
+ width: isAiRoute ? 260 : isSpaceRoute ? sidebarWidth : 300,
breakpoint: "sm",
collapsed: {
mobile: !mobileOpened,
@@ -108,9 +110,10 @@ export default function GlobalAppShell({
withBorder={false}
ref={sidebarRef}
>
-
+ {!isAiRoute && }
{isSpaceRoute && }
{isSettingsRoute && }
+ {isAiRoute && }
)}
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..01ac3206
--- /dev/null
+++ b/apps/client/src/ee/ai-chat/components/ai-chat-layout.tsx
@@ -0,0 +1,80 @@
+import { useEffect } from "react";
+import { 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 classes from "../styles/ai-chat.module.css";
+
+export default function AiChatLayout() {
+ const { chatId } = useParams<{ chatId: string }>();
+ const chatInfoQuery = useChatInfoQuery(chatId);
+ const {
+ messages,
+ streamingContent,
+ streamingToolCalls,
+ isStreaming,
+ error,
+ sendMessage,
+ stopGeneration,
+ initMessages,
+ } = useChatStream(chatId);
+
+ useEffect(() => {
+ if (chatInfoQuery.data?.messages) {
+ initMessages(chatInfoQuery.data.messages);
+ }
+ }, [chatInfoQuery.data, initMessages]);
+
+ useEffect(() => {
+ if (!chatId) {
+ initMessages([]);
+ }
+ }, [chatId, initMessages]);
+
+ const hasMessages = messages.length > 0 || isStreaming;
+
+ if (!hasMessages) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/ai-chat/components/ai-chat-sidebar-item.tsx b/apps/client/src/ee/ai-chat/components/ai-chat-sidebar-item.tsx
new file mode 100644
index 00000000..3f76c80b
--- /dev/null
+++ b/apps/client/src/ee/ai-chat/components/ai-chat-sidebar-item.tsx
@@ -0,0 +1,117 @@
+import { useState, useRef, useEffect } from "react";
+import { ActionIcon, Menu, Popover, TextInput } from "@mantine/core";
+import { IconDots, IconTrash, IconEdit } from "@tabler/icons-react";
+import { Link } from "react-router-dom";
+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;
+};
+
+export default function AiChatSidebarItem({
+ chat,
+ isActive,
+ onDelete,
+ onRename,
+}: Props) {
+ const [renaming, setRenaming] = useState(false);
+ const [renameValue, setRenameValue] = useState("");
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ if (renaming) {
+ inputRef.current?.select();
+ }
+ }, [renaming]);
+
+ const startRename = () => {
+ setRenameValue(chat.title || "");
+ setRenaming(true);
+ };
+
+ const submitRename = () => {
+ const trimmed = renameValue.trim();
+ if (trimmed && trimmed !== chat.title) {
+ onRename(chat.id, trimmed);
+ }
+ setRenaming(false);
+ };
+
+ return (
+ setRenaming(false)}
+ position="bottom-start"
+ withinPortal
+ trapFocus
+ >
+
+
+
+ {chat.title || "Untitled chat"}
+
+
+
+
+
+
+
+ setRenameValue(e.currentTarget.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ submitRename();
+ } else if (e.key === "Escape") {
+ setRenaming(false);
+ }
+ }}
+ onBlur={submitRename}
+ />
+
+
+ );
+}
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..5e75a53b
--- /dev/null
+++ b/apps/client/src/ee/ai-chat/components/ai-chat-sidebar.tsx
@@ -0,0 +1,96 @@
+import { useState, useCallback, useMemo } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { ActionIcon, TextInput, Loader } from "@mantine/core";
+import { useDebouncedValue } from "@mantine/hooks";
+import { IconPlus, IconSearch } from "@tabler/icons-react";
+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";
+
+export default function AiChatSidebar() {
+ 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 handleNewChat = useCallback(() => {
+ 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 (
+
+
+
+
}
+ size="xs"
+ value={search}
+ onChange={(e) => setSearch(e.currentTarget.value)}
+ />
+
+
+ {isLoading &&
}
+ {chats.map((chat) => (
+
+ ))}
+
+
+ );
+}
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..70f3a4e1
--- /dev/null
+++ b/apps/client/src/ee/ai-chat/components/chat-empty-state.tsx
@@ -0,0 +1,85 @@
+import {
+ IconSparkles,
+ IconSearch,
+ IconFilePlus,
+ IconEdit,
+ IconFileText,
+} from "@tabler/icons-react";
+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 handleSuggestionClick = (prompt: string) => {
+ onSend(prompt, [], []);
+ };
+
+ return (
+
+
+
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..f0257429
--- /dev/null
+++ b/apps/client/src/ee/ai-chat/components/chat-input.tsx
@@ -0,0 +1,297 @@
+import { useCallback, useRef, useEffect, useState } from "react";
+import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto } from "@tabler/icons-react";
+import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
+import { Placeholder } from "@tiptap/extension-placeholder";
+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";
+
+type Props = {
+ isStreaming: boolean;
+ onSend: (content: string, mentions: PageMention[], attachments: ChatAttachment[]) => void;
+ onStop: () => void;
+ placeholder?: string;
+ autofocus?: boolean;
+};
+
+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,
+}: Props) {
+ const [isEmpty, setIsEmpty] = useState(true);
+ const [pendingAttachments, setPendingAttachments] = useState([]);
+ const fileInputRef = useRef(null);
+ const onSendRef = useRef(onSend);
+ onSendRef.current = onSend;
+
+ const handleFileSelect = useCallback(async (files: FileList | null) => {
+ if (!files?.length) return;
+
+ for (const file of Array.from(files)) {
+ 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);
+ 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 = "";
+ }
+ }, []);
+
+ 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",
+ }),
+ 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);
+
+ return (
+
+
handleFileSelect(e.target.files)}
+ />
+
+ {pendingAttachments.length > 0 && (
+
+ {pendingAttachments.map((attachment) => (
+
+ {IMAGE_EXTENSIONS.includes(attachment.fileExt) ? (
+
+ ) : (
+
+ )}
+
+ {attachment.fileName}
+
+ {!attachment.uploading && (
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ {isStreaming ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
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..9cf66b63
--- /dev/null
+++ b/apps/client/src/ee/ai-chat/components/chat-message-list.tsx
@@ -0,0 +1,49 @@
+import { useEffect, useRef } from "react";
+import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
+import ChatMessage from "./chat-message";
+import classes from "../styles/ai-chat.module.css";
+
+type Props = {
+ messages: AiChatMessage[];
+ isStreaming: boolean;
+ streamingContent: string;
+ streamingToolCalls: AiChatToolCall[];
+};
+
+export default function ChatMessageList({
+ messages,
+ isStreaming,
+ streamingContent,
+ streamingToolCalls,
+}: Props) {
+ const bottomRef = useRef(null);
+
+ useEffect(() => {
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, [messages.length, streamingContent, streamingToolCalls.length]);
+
+ return (
+
+ {messages.map((msg) => (
+
+ ))}
+ {isStreaming && (
+
+ )}
+
+
+ );
+}
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..fa3eb11c
--- /dev/null
+++ b/apps/client/src/ee/ai-chat/components/chat-message.tsx
@@ -0,0 +1,108 @@
+import { useCallback } from "react";
+import { useNavigate } from "react-router";
+import DOMPurify from "dompurify";
+import { IconFile, IconLoader2, IconPhoto } from "@tabler/icons-react";
+import { markdownToHtml } from "@docmost/editor-ext";
+import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
+import ChatToolResult from "./chat-tool-result";
+import classes from "../styles/chat-message.module.css";
+
+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?.map((tc) => (
+
+ ))}
+ {content && (
+
+ )}
+ {isStreaming && (
+ <>
+ {!content && (
+
+
+ Thinking
+
+ )}
+
+ >
+ )}
+
+
+ );
+}
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..d21565b8
--- /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, IconTool } from "@tabler/icons-react";
+import type { AiChatToolCall } from "../types/ai-chat.types";
+import classes from "../styles/chat-message.module.css";
+
+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)}
+ >
+
+ {label}
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+ {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..c91e045f
--- /dev/null
+++ b/apps/client/src/ee/ai-chat/components/enable-ai-chat.tsx
@@ -0,0 +1,60 @@
+import { Group, Text, Switch } 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";
+
+export default function EnableAiChat() {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {t("AI Chat")}
+
+ {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 { hasLicenseKey } = useLicense();
+
+ const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
+
+ 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..0a23b713
--- /dev/null
+++ b/apps/client/src/ee/ai-chat/hooks/use-chat-stream.ts
@@ -0,0 +1,182 @@
+import { useState, useCallback, 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";
+
+export function useChatStream(chatId: string | undefined) {
+ const [messages, setMessages] = useState([]);
+ const [streamingContent, setStreamingContent] = useState("");
+ const [streamingToolCalls, setStreamingToolCalls] = useState(
+ [],
+ );
+ const [isStreaming, setIsStreaming] = useState(false);
+ const [error, setError] = useState(null);
+ const abortRef = useRef(null);
+ const queryClient = useQueryClient();
+ const navigate = useNavigate();
+ const currentChatIdRef = useRef(chatId);
+ currentChatIdRef.current = chatId;
+
+ const initMessages = useCallback((msgs: AiChatMessage[]) => {
+ setMessages(msgs);
+ }, []);
+
+ const sendMessage = useCallback(
+ (content: string, mentions: PageMention[] = [], attachments: ChatAttachment[] = []) => {
+ if (isStreaming || (!content.trim() && attachments.length === 0)) return;
+
+ setError(null);
+ 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),
+ ...(attachmentIds.length && { attachmentIds }),
+ },
+ (event: AiChatStreamEvent) => {
+ switch (event.type) {
+ case "chat_created":
+ currentChatIdRef.current = event.chatId;
+ 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);
+ 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,
+ sendMessage,
+ stopGeneration,
+ initMessages,
+ };
+}
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..f9b751d0
--- /dev/null
+++ b/apps/client/src/ee/ai-chat/pages/ai-chat.tsx
@@ -0,0 +1,10 @@
+import AiChatLayout from "../components/ai-chat-layout";
+import classes from "../styles/ai-chat.module.css";
+
+export default function AiChat() {
+ return (
+
+ );
+}
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..70faa4e7
--- /dev/null
+++ b/apps/client/src/ee/ai-chat/services/ai-chat-service.ts
@@ -0,0 +1,137 @@
+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-chat/create");
+ return req.data;
+}
+
+export async function listChats(params?: {
+ limit?: number;
+ cursor?: string;
+}): Promise> {
+ const req = await api.post("/ai-chat/list", params);
+ return req.data;
+}
+
+export async function getChatInfo(
+ chatId: string,
+): Promise<{ chat: AiChat; messages: AiChatMessage[] }> {
+ const req = await api.post("/ai-chat/info", { chatId });
+ return req.data;
+}
+
+export async function deleteChat(chatId: string): Promise {
+ await api.post("/ai-chat/delete", { chatId });
+}
+
+export async function updateChatTitle(
+ chatId: string,
+ title: string,
+): Promise {
+ await api.post("/ai-chat/update", { chatId, title });
+}
+
+export async function searchChats(query: string): Promise {
+ const req = await api.post("/ai-chat/search", { query });
+ return req.data;
+}
+
+export async function uploadChatFile(file: File): Promise {
+ const formData = new FormData();
+ formData.append("file", file);
+ return await api.post("/ai-chat/upload", formData, {
+ headers: { "Content-Type": "multipart/form-data" },
+ });
+}
+
+export function sendChatMessage(
+ params: {
+ chatId?: string;
+ content: string;
+ mentionedPageIds?: 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-chat/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..b4ca5a16
--- /dev/null
+++ b/apps/client/src/ee/ai-chat/styles/ai-chat.module.css
@@ -0,0 +1,109 @@
+.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%;
+}
+
+.messageList {
+ flex: 1;
+ overflow-y: auto;
+ padding: var(--mantine-spacing-md) var(--mantine-spacing-lg);
+ scroll-behavior: smooth;
+}
+
+.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-lg);
+ color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
+}
+
+.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/chat-input.module.css b/apps/client/src/ee/ai-chat/styles/chat-input.module.css
new file mode 100644
index 00000000..6f6d8f53
--- /dev/null
+++ b/apps/client/src/ee/ai-chat/styles/chat-input.module.css
@@ -0,0 +1,163 @@
+.inputWrapper {
+ position: relative;
+ border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
+ 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;
+
+ &: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);
+ }
+}
+
+.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;
+ 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));
+ }
+}
+
+.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..2390d45b
--- /dev/null
+++ b/apps/client/src/ee/ai-chat/styles/chat-message.module.css
@@ -0,0 +1,231 @@
+.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;
+}
+
+.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;
+}
+
+.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));
+ font-size: var(--mantine-font-size-xs);
+}
+
+.toolCallHeader {
+ display: flex;
+ align-items: center;
+ gap: var(--mantine-spacing-xs);
+ cursor: pointer;
+ user-select: none;
+}
+
+.toolCallName {
+ font-weight: 600;
+ color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-4));
+}
+
+.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));
+}
+
+.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..d0848001
--- /dev/null
+++ b/apps/client/src/ee/ai-chat/styles/chat-sidebar.module.css
@@ -0,0 +1,78 @@
+.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;
+}
+
+.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;
+}
+
+.chatItemActions {
+ opacity: 0;
+ transition: opacity 150ms;
+}
+
+.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..ac601b11
--- /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 };
+
+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 53fa9a87..05f302e9 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";
@@ -68,6 +69,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 f086df49..1b533544 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/workspace/types/workspace.types.ts b/apps/client/src/features/workspace/types/workspace.types.ts
index db8aadf5..6f8c9fda 100644
--- a/apps/client/src/features/workspace/types/workspace.types.ts
+++ b/apps/client/src/features/workspace/types/workspace.types.ts
@@ -44,6 +44,7 @@ export interface IWorkspaceAiSettings {
search?: boolean;
generative?: boolean;
mcp?: boolean;
+ chat?: boolean;
}
export interface IWorkspaceSharingSettings {
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 2ef80590..f3bc50ca 100644
--- a/apps/server/src/core/workspace/services/workspace.service.ts
+++ b/apps/server/src/core/workspace/services/workspace.service.ts
@@ -440,11 +440,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/20260305T120000-ai-chat.ts b/apps/server/src/database/migrations/20260305T120000-ai-chat.ts
new file mode 100644
index 00000000..ddd5fa75
--- /dev/null
+++ b/apps/server/src/database/migrations/20260305T120000-ai-chat.ts
@@ -0,0 +1,60 @@
+import { type Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .createTable('ai_chats')
+ .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()`),
+ )
+ .execute();
+
+ await db.schema
+ .createIndex('idx_ai_chats_workspace_creator')
+ .on('ai_chats')
+ .columns(['workspace_id', 'creator_id', 'id'])
+ .execute();
+
+ await db.schema
+ .createTable('ai_chat_messages')
+ .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('role', 'varchar', (col) => col.notNull())
+ .addColumn('content', 'text', (col) => col)
+ .addColumn('tool_calls', 'jsonb', (col) => col)
+ .addColumn('metadata', 'jsonb', (col) => col)
+ .addColumn('created_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .execute();
+
+ await db.schema
+ .createIndex('idx_ai_chat_messages_chat_id')
+ .on('ai_chat_messages')
+ .columns(['chat_id', 'id'])
+ .execute();
+}
+
+export async function down(db: Kysely): Promise {
+ 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..fcc5caee 100644
--- a/apps/server/src/database/repos/attachment/attachment.repo.ts
+++ b/apps/server/src/database/repos/attachment/attachment.repo.ts
@@ -44,6 +44,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,
diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts
index ed166b75..55b4a6ec 100644
--- a/apps/server/src/database/types/db.d.ts
+++ b/apps/server/src/database/types/db.d.ts
@@ -429,7 +429,29 @@ export interface PagePermissions {
updatedAt: Generated;
}
+export interface AiChats {
+ id: Generated;
+ workspaceId: string;
+ creatorId: string;
+ title: string | null;
+ createdAt: Generated;
+ updatedAt: Generated;
+}
+
+export interface AiChatMessages {
+ id: Generated;
+ chatId: string;
+ workspaceId: string;
+ role: string;
+ content: string | null;
+ toolCalls: Json | null;
+ metadata: Json | null;
+ createdAt: Generated;
+}
+
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 f8bf9ff7..6bbc041b 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,
@@ -28,6 +30,15 @@ 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
+export type AiChatMessage = Selectable;
+export type InsertableAiChatMessage = Insertable;
+
// Workspace
export type Workspace = Selectable;
export type InsertableWorkspace = Insertable;
diff --git a/apps/server/src/ee b/apps/server/src/ee
index 8b7ae8cf..8b48e93a 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit 8b7ae8cf1b17e6c5f7b497d35df33b058cf0472d
+Subproject commit 8b48e93a1ba281394e8df205eaf1601900b26578
diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts
index 89e4bb81..035f407e 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'),