From 9d9e389bbfaa44f6fbb3b8aef799da57b42dfa0d Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:56:09 +0000 Subject: [PATCH] feat: ai chat --- apps/client/src/App.tsx | 3 + .../components/layouts/global/app-header.tsx | 5 +- .../layouts/global/global-app-shell.tsx | 7 +- .../ee/ai-chat/components/ai-chat-layout.tsx | 80 +++++ .../components/ai-chat-sidebar-item.tsx | 117 +++++++ .../ee/ai-chat/components/ai-chat-sidebar.tsx | 96 ++++++ .../ai-chat/components/chat-empty-state.tsx | 85 +++++ .../src/ee/ai-chat/components/chat-input.tsx | 297 ++++++++++++++++++ .../ai-chat/components/chat-message-list.tsx | 49 +++ .../ee/ai-chat/components/chat-message.tsx | 108 +++++++ .../ai-chat/components/chat-tool-result.tsx | 49 +++ .../ee/ai-chat/components/enable-ai-chat.tsx | 60 ++++ .../src/ee/ai-chat/hooks/use-chat-stream.ts | 182 +++++++++++ apps/client/src/ee/ai-chat/pages/ai-chat.tsx | 10 + .../src/ee/ai-chat/queries/ai-chat-query.ts | 61 ++++ .../ee/ai-chat/services/ai-chat-service.ts | 137 ++++++++ .../src/ee/ai-chat/styles/ai-chat.module.css | 109 +++++++ .../ee/ai-chat/styles/chat-input.module.css | 163 ++++++++++ .../ee/ai-chat/styles/chat-message.module.css | 231 ++++++++++++++ .../ee/ai-chat/styles/chat-sidebar.module.css | 78 +++++ .../src/ee/ai-chat/types/ai-chat.types.ts | 49 +++ apps/client/src/ee/ai/pages/ai-settings.tsx | 2 + .../components/mention/mention-list.tsx | 2 +- .../components/mention/mention-suggestion.ts | 4 +- .../workspace/types/workspace.types.ts | 1 + .../workspace/dto/update-workspace.dto.ts | 4 + .../workspace/services/workspace.service.ts | 15 + .../migrations/20260305T120000-ai-chat.ts | 60 ++++ .../repos/attachment/attachment.repo.ts | 15 + apps/server/src/database/types/db.d.ts | 22 ++ .../server/src/database/types/entity.types.ts | 11 + apps/server/src/ee | 2 +- .../environment/environment.service.ts | 7 + 33 files changed, 2114 insertions(+), 7 deletions(-) create mode 100644 apps/client/src/ee/ai-chat/components/ai-chat-layout.tsx create mode 100644 apps/client/src/ee/ai-chat/components/ai-chat-sidebar-item.tsx create mode 100644 apps/client/src/ee/ai-chat/components/ai-chat-sidebar.tsx create mode 100644 apps/client/src/ee/ai-chat/components/chat-empty-state.tsx create mode 100644 apps/client/src/ee/ai-chat/components/chat-input.tsx create mode 100644 apps/client/src/ee/ai-chat/components/chat-message-list.tsx create mode 100644 apps/client/src/ee/ai-chat/components/chat-message.tsx create mode 100644 apps/client/src/ee/ai-chat/components/chat-tool-result.tsx create mode 100644 apps/client/src/ee/ai-chat/components/enable-ai-chat.tsx create mode 100644 apps/client/src/ee/ai-chat/hooks/use-chat-stream.ts create mode 100644 apps/client/src/ee/ai-chat/pages/ai-chat.tsx create mode 100644 apps/client/src/ee/ai-chat/queries/ai-chat-query.ts create mode 100644 apps/client/src/ee/ai-chat/services/ai-chat-service.ts create mode 100644 apps/client/src/ee/ai-chat/styles/ai-chat.module.css create mode 100644 apps/client/src/ee/ai-chat/styles/chat-input.module.css create mode 100644 apps/client/src/ee/ai-chat/styles/chat-message.module.css create mode 100644 apps/client/src/ee/ai-chat/styles/chat-sidebar.module.css create mode 100644 apps/client/src/ee/ai-chat/types/ai-chat.types.ts create mode 100644 apps/server/src/database/migrations/20260305T120000-ai-chat.ts 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"} + +
+ + + e.preventDefault()} + > + + + + + } + onClick={(e) => { + e.preventDefault(); + startRename(); + }} + > + Rename + + } + color="red" + onClick={(e) => { + e.preventDefault(); + onDelete(chat.id); + }} + > + Delete + + + +
+ +
+ + 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 ( +
+
+ AI Chat + + + +
+ + } + 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'),