{JSON.stringify(
{ args: toolCall.args, result: toolCall.result },
diff --git a/apps/client/src/ee/ai-chat/components/enable-ai-chat.tsx b/apps/client/src/ee/ai-chat/components/enable-ai-chat.tsx
index c91e045f..f4200e5b 100644
--- a/apps/client/src/ee/ai-chat/components/enable-ai-chat.tsx
+++ b/apps/client/src/ee/ai-chat/components/enable-ai-chat.tsx
@@ -1,12 +1,13 @@
-import { Group, Text, Switch } from "@mantine/core";
+import { Badge, Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
-import { isCloud } from "@/lib/config.ts";
-import useLicense from "@/ee/hooks/use-license.tsx";
+import { useHasFeature } from "@/ee/hooks/use-feature";
+import { Feature } from "@/ee/features";
+import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function EnableAiChat() {
const { t } = useTranslation();
@@ -14,7 +15,12 @@ export default function EnableAiChat() {
return (
-
{t("AI Chat")}
+
+ {t("AI Chat")}
+
+ {t("Beta")}
+
+
{t(
"Enable AI Chat to allow users to have multi-turn conversations with AI about your workspace content.",
@@ -31,9 +37,8 @@ function AiChatToggle() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.chat);
- const { hasLicenseKey } = useLicense();
-
- const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
+ const hasAccess = useHasFeature(Feature.AI);
+ const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent) => {
const value = event.currentTarget.checked;
@@ -50,11 +55,13 @@ function AiChatToggle() {
};
return (
-
+
+
+
);
}
diff --git a/apps/client/src/ee/ai-chat/hooks/use-chat-stream.ts b/apps/client/src/ee/ai-chat/hooks/use-chat-stream.ts
index 0a23b713..65a93a97 100644
--- a/apps/client/src/ee/ai-chat/hooks/use-chat-stream.ts
+++ b/apps/client/src/ee/ai-chat/hooks/use-chat-stream.ts
@@ -1,4 +1,4 @@
-import { useState, useCallback, useRef } from "react";
+import { useState, useCallback, useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { sendChatMessage } from "../services/ai-chat-service";
@@ -10,7 +10,14 @@ import type {
PageMention,
} from "../types/ai-chat.types";
-export function useChatStream(chatId: string | undefined) {
+type ChatStreamOptions = {
+ onChatCreated?: (chatId: string) => void;
+};
+
+export function useChatStream(
+ chatId: string | undefined,
+ options?: ChatStreamOptions,
+) {
const [messages, setMessages] = useState([]);
const [streamingContent, setStreamingContent] = useState("");
const [streamingToolCalls, setStreamingToolCalls] = useState(
@@ -18,21 +25,46 @@ export function useChatStream(chatId: string | undefined) {
);
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState(null);
+ const [errorCode, setErrorCode] = useState(null);
+ const [isRetryable, setIsRetryable] = useState(false);
const abortRef = useRef(null);
const queryClient = useQueryClient();
const navigate = useNavigate();
const currentChatIdRef = useRef(chatId);
currentChatIdRef.current = chatId;
+ // Tracks which chatId the local `messages` state currently represents.
+ // Set when we seed from a server fetch AND when we optimistically own a
+ // freshly-created chat after `chat_created`. This is the single authority
+ // marker that keeps server-state effects from clobbering in-flight streams.
+ const hydratedChatIdRef = useRef(undefined);
- const initMessages = useCallback((msgs: AiChatMessage[]) => {
+ // Reset local state when the consumer switches to a different chat.
+ // Skip the reset if the new chatId is one the hook itself already claimed
+ // during a new-chat flow — in that case our optimistic state is the truth.
+ useEffect(() => {
+ if (chatId && chatId === hydratedChatIdRef.current) return;
+ hydratedChatIdRef.current = undefined;
+ setMessages([]);
+ setError(null);
+ setErrorCode(null);
+ setIsRetryable(false);
+ }, [chatId]);
+
+ const hydrateFromServer = useCallback((msgs: AiChatMessage[]) => {
+ const forId = currentChatIdRef.current;
+ if (!forId) return;
+ if (hydratedChatIdRef.current === forId) return;
+ hydratedChatIdRef.current = forId;
setMessages(msgs);
}, []);
const sendMessage = useCallback(
- (content: string, mentions: PageMention[] = [], attachments: ChatAttachment[] = []) => {
+ (content: string, mentions: PageMention[] = [], attachments: ChatAttachment[] = [], contextPageId?: string) => {
if (isStreaming || (!content.trim() && attachments.length === 0)) return;
setError(null);
+ setErrorCode(null);
+ setIsRetryable(false);
setIsStreaming(true);
setStreamingContent("");
setStreamingToolCalls([]);
@@ -68,13 +100,22 @@ export function useChatStream(chatId: string | undefined) {
chatId: currentChatIdRef.current,
content,
mentionedPageIds: mentions.map((m) => m.id),
+ ...(contextPageId && { contextPageId }),
...(attachmentIds.length && { attachmentIds }),
},
(event: AiChatStreamEvent) => {
switch (event.type) {
case "chat_created":
currentChatIdRef.current = event.chatId;
- navigate(`/ai/chat/${event.chatId}`, { replace: true });
+ // Claim authority over this new chatId so when the consumer's
+ // prop catches up via navigation/onChatCreated, the reset effect
+ // sees a match and preserves our optimistic messages.
+ hydratedChatIdRef.current = event.chatId;
+ if (options?.onChatCreated) {
+ options.onChatCreated(event.chatId);
+ } else {
+ navigate(`/ai/chat/${event.chatId}`, { replace: true });
+ }
queryClient.invalidateQueries({ queryKey: ["ai-chats"] });
break;
case "content":
@@ -125,6 +166,8 @@ export function useChatStream(chatId: string | undefined) {
}
case "error":
setError(event.message);
+ setErrorCode(event.code || null);
+ setIsRetryable(event.retryable || false);
setIsStreaming(false);
break;
}
@@ -175,8 +218,10 @@ export function useChatStream(chatId: string | undefined) {
streamingToolCalls,
isStreaming,
error,
+ errorCode,
+ isRetryable,
sendMessage,
stopGeneration,
- initMessages,
+ hydrateFromServer,
};
}
diff --git a/apps/client/src/ee/ai-chat/pages/ai-chat.tsx b/apps/client/src/ee/ai-chat/pages/ai-chat.tsx
index f9b751d0..bb264b73 100644
--- a/apps/client/src/ee/ai-chat/pages/ai-chat.tsx
+++ b/apps/client/src/ee/ai-chat/pages/ai-chat.tsx
@@ -1,10 +1,39 @@
+import { useParams } from "react-router-dom";
+import { ErrorBoundary } from "react-error-boundary";
+import { Button } from "@mantine/core";
+import { IconAlertTriangle } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
import AiChatLayout from "../components/ai-chat-layout";
+import { EmptyState } from "@/components/ui/empty-state.tsx";
import classes from "../styles/ai-chat.module.css";
export default function AiChat() {
+ const { t } = useTranslation();
+ const { chatId } = useParams<{ chatId: string }>();
+
return (
-
+
(
+
+ {t("Try again")}
+
+ }
+ />
+ )}
+ >
+
+
);
}
diff --git a/apps/client/src/ee/ai-chat/services/ai-chat-service.ts b/apps/client/src/ee/ai-chat/services/ai-chat-service.ts
index 70faa4e7..2932372e 100644
--- a/apps/client/src/ee/ai-chat/services/ai-chat-service.ts
+++ b/apps/client/src/ee/ai-chat/services/ai-chat-service.ts
@@ -8,7 +8,7 @@ import type {
import { IPagination } from "@/lib/types.ts";
export async function createChat(): Promise {
- const req = await api.post("/ai-chat/create");
+ const req = await api.post("/ai/chats/create");
return req.data;
}
@@ -16,37 +16,43 @@ export async function listChats(params?: {
limit?: number;
cursor?: string;
}): Promise> {
- const req = await api.post("/ai-chat/list", params);
+ const req = await api.post("/ai/chats", params);
return req.data;
}
export async function getChatInfo(
chatId: string,
): Promise<{ chat: AiChat; messages: AiChatMessage[] }> {
- const req = await api.post("/ai-chat/info", { chatId });
+ const req = await api.post("/ai/chats/info", { chatId });
return req.data;
}
export async function deleteChat(chatId: string): Promise {
- await api.post("/ai-chat/delete", { chatId });
+ await api.post("/ai/chats/delete", { chatId });
}
export async function updateChatTitle(
chatId: string,
title: string,
): Promise {
- await api.post("/ai-chat/update", { chatId, title });
+ await api.post("/ai/chats/update", { chatId, title });
}
export async function searchChats(query: string): Promise {
- const req = await api.post("/ai-chat/search", { query });
+ const req = await api.post("/ai/chats/search", { query });
return req.data;
}
-export async function uploadChatFile(file: File): Promise {
+export async function uploadChatFile(
+ file: File,
+ chatId?: string,
+): Promise {
const formData = new FormData();
formData.append("file", file);
- return await api.post("/ai-chat/upload", formData, {
+ if (chatId) {
+ formData.append("chatId", chatId);
+ }
+ return await api.post("/ai/chats/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
}
@@ -56,6 +62,7 @@ export function sendChatMessage(
chatId?: string;
content: string;
mentionedPageIds?: string[];
+ contextPageId?: string;
attachmentIds?: string[];
},
onEvent: (event: AiChatStreamEvent) => void,
@@ -66,7 +73,7 @@ export function sendChatMessage(
(async () => {
try {
- const response = await fetch("/api/ai-chat/send", {
+ const response = await fetch("/api/ai/chats/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
diff --git a/apps/client/src/ee/ai-chat/styles/ai-chat.module.css b/apps/client/src/ee/ai-chat/styles/ai-chat.module.css
index b4ca5a16..27b0f0c0 100644
--- a/apps/client/src/ee/ai-chat/styles/ai-chat.module.css
+++ b/apps/client/src/ee/ai-chat/styles/ai-chat.module.css
@@ -14,11 +14,62 @@
width: 100%;
}
+.messageListWrapper {
+ flex: 1;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ height: 100%;
+ width: 100%;
+}
+
.messageList {
flex: 1;
overflow-y: auto;
padding: var(--mantine-spacing-md) var(--mantine-spacing-lg);
- scroll-behavior: smooth;
+}
+
+.messageErrorFallback {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 12px;
+ margin-bottom: var(--mantine-spacing-lg);
+ border-radius: var(--mantine-radius-sm);
+ background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
+ color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
+ font-size: var(--mantine-font-size-xs);
+}
+
+.scrollToBottomButton {
+ position: absolute;
+ bottom: 12px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
+ background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
+ color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
+ cursor: pointer;
+ padding: 0;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+ transition: background-color 120ms ease, border-color 120ms ease, transform 120ms ease;
+ z-index: 2;
+}
+
+.scrollToBottomButton:hover {
+ background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
+ border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
+}
+
+.scrollToBottomButton:active {
+ transform: translateX(-50%) scale(0.95);
}
.inputArea {
@@ -38,10 +89,19 @@
.emptyStateIcon {
width: 48px;
height: 48px;
- margin-bottom: var(--mantine-spacing-lg);
+ margin-bottom: var(--mantine-spacing-sm);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
}
+.emptyStateBrand {
+ font-size: var(--mantine-font-size-xs);
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
+ margin-bottom: var(--mantine-spacing-xs);
+}
+
.emptyStateTitle {
font-size: 1.5rem;
font-weight: 600;
diff --git a/apps/client/src/ee/ai-chat/styles/aside-chat-panel.module.css b/apps/client/src/ee/ai-chat/styles/aside-chat-panel.module.css
new file mode 100644
index 00000000..f8958615
--- /dev/null
+++ b/apps/client/src/ee/ai-chat/styles/aside-chat-panel.module.css
@@ -0,0 +1,139 @@
+.panel {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+}
+
+.toolbar {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 0 0 var(--mantine-spacing-sm) 0;
+ border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
+}
+
+.toolbarSpacer {
+ flex: 1;
+}
+
+.titleButton {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 8px;
+ border-radius: var(--mantine-radius-sm);
+ font-size: var(--mantine-font-size-sm);
+ font-weight: 500;
+ color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
+ max-width: 60%;
+ min-width: 0;
+}
+
+.titleButton:hover {
+ background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
+}
+
+.titleText {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ min-width: 0;
+}
+
+.messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: var(--mantine-spacing-sm) 0;
+ scroll-behavior: smooth;
+}
+
+.inputArea {
+ padding-top: var(--mantine-spacing-sm);
+}
+
+.emptyState {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--mantine-spacing-md);
+ padding: var(--mantine-spacing-xl) var(--mantine-spacing-sm);
+}
+
+.emptyStateIcon {
+ color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
+}
+
+.emptyStateTitle {
+ font-size: var(--mantine-font-size-lg);
+ font-weight: 600;
+ color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
+ text-align: center;
+}
+
+.quickActions {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ width: 100%;
+}
+
+.quickAction {
+ display: flex;
+ align-items: center;
+ gap: var(--mantine-spacing-sm);
+ padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
+ border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
+ border-radius: var(--mantine-radius-md);
+ cursor: pointer;
+ background: transparent;
+ color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
+ font-size: var(--mantine-font-size-sm);
+ text-align: left;
+ width: 100%;
+ transition: background-color 150ms, border-color 150ms;
+
+ @mixin hover {
+ background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
+ border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
+ }
+}
+
+.quickActionIcon {
+ flex-shrink: 0;
+ color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
+}
+
+.historyList {
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+.historyItem {
+ display: flex;
+ align-items: center;
+ padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
+ cursor: pointer;
+ border-radius: var(--mantine-radius-sm);
+ font-size: var(--mantine-font-size-sm);
+ color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
+ transition: background-color 150ms;
+
+ @mixin hover {
+ background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
+ }
+
+ &[data-active] {
+ background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
+ }
+}
+
+.historyItemTitle {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
diff --git a/apps/client/src/ee/ai-chat/styles/chat-input.module.css b/apps/client/src/ee/ai-chat/styles/chat-input.module.css
index 6f6d8f53..20b287c1 100644
--- a/apps/client/src/ee/ai-chat/styles/chat-input.module.css
+++ b/apps/client/src/ee/ai-chat/styles/chat-input.module.css
@@ -1,21 +1,51 @@
.inputWrapper {
position: relative;
- border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
+ overflow: hidden;
+ border: 1px solid
+ light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
border-radius: 16px;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
- box-shadow:
- 0 1px 3px rgba(0, 0, 0, 0.04),
- 0 4px 12px rgba(0, 0, 0, 0.06);
- transition: border-color 150ms, box-shadow 150ms;
+ box-shadow: light-dark(
+ 0 2px 40px 4px rgba(0, 0, 0, 0.07),
+ 0 2px 40px 4px rgba(0, 0, 0, 0.5)
+ );
+ transition:
+ border-color 150ms,
+ box-shadow 150ms;
&:focus-within {
- border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
- box-shadow:
- 0 1px 3px rgba(0, 0, 0, 0.04),
- 0 4px 16px rgba(0, 0, 0, 0.1);
+ border-color: light-dark(
+ var(--mantine-color-gray-3),
+ var(--mantine-color-dark-4)
+ );
+ box-shadow: light-dark(
+ 0 4px 48px 6px rgba(0, 0, 0, 0.09),
+ 0 4px 48px 6px rgba(0, 0, 0, 0.6)
+ );
}
}
+.inputWrapperFlat {
+ position: relative;
+ overflow: hidden;
+ border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
+ border-radius: 12px;
+ background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
+ box-shadow: none;
+ transition: border-color 150ms;
+
+ &:focus-within {
+ border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
+ }
+}
+
+.disclaimer {
+ margin-top: 6px;
+ text-align: center;
+ font-size: var(--mantine-font-size-xs);
+ color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
+}
+
.attachmentChips {
display: flex;
flex-wrap: wrap;
@@ -68,6 +98,7 @@
:global(.ProseMirror) {
outline: none;
border: none;
+ background-color: transparent;
padding: 14px 18px 8px;
font-size: 15px;
line-height: 1.6;
@@ -142,6 +173,54 @@
}
}
+.plusButton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
+ background: none;
+ cursor: pointer;
+ color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
+ transition: color 150ms, background-color 150ms;
+
+ @mixin hover {
+ color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
+ background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
+ }
+}
+
+.plusMenuItem {
+ display: flex;
+ align-items: center;
+ gap: var(--mantine-spacing-sm);
+ padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
+ border: none;
+ background: none;
+ cursor: pointer;
+ width: 100%;
+ font-size: var(--mantine-font-size-sm);
+ color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
+ border-radius: var(--mantine-radius-sm);
+ transition: background-color 150ms;
+
+ @mixin hover {
+ background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
+ }
+
+ &:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ background: none;
+ }
+}
+
+.plusMenuIcon {
+ color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
+}
+
.stopButton {
width: 28px;
height: 28px;
diff --git a/apps/client/src/ee/ai-chat/styles/chat-message.module.css b/apps/client/src/ee/ai-chat/styles/chat-message.module.css
index 2390d45b..33e39dd6 100644
--- a/apps/client/src/ee/ai-chat/styles/chat-message.module.css
+++ b/apps/client/src/ee/ai-chat/styles/chat-message.module.css
@@ -20,6 +20,11 @@
overflow-wrap: break-word;
}
+[data-aside-chat] .userBubble {
+ background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
+ border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
+}
+
.userBubble p {
margin: 0;
}
@@ -165,32 +170,82 @@
margin: 1em 0;
}
-.toolCallCard {
- margin: var(--mantine-spacing-xs) 0;
- padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
- border-radius: var(--mantine-radius-sm);
- border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
- background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
+.toolGroup {
+ margin: 6px 0;
font-size: var(--mantine-font-size-xs);
}
-.toolCallHeader {
- display: flex;
+.toolGroupHeader {
+ display: inline-flex;
align-items: center;
- gap: var(--mantine-spacing-xs);
+ gap: 6px;
cursor: pointer;
user-select: none;
+ color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
+ line-height: 1.4;
+ transition: color 120ms ease;
}
-.toolCallName {
- font-weight: 600;
- color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-4));
+.toolGroupHeader:hover {
+ color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
}
-.toolCallDetails {
- margin-top: var(--mantine-spacing-xs);
- padding-top: var(--mantine-spacing-xs);
- border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
+.toolGroupLabel {
+ font-weight: 500;
+}
+
+.toolGroupSteps {
+ margin-top: 4px;
+ padding-left: 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.toolStep {
+ font-size: var(--mantine-font-size-xs);
+}
+
+.toolStepRow {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ cursor: pointer;
+ user-select: none;
+ color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
+ line-height: 1.5;
+ transition: color 120ms ease;
+}
+
+.toolStepRow:hover {
+ color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
+}
+
+.toolStepBullet {
+ display: inline-block;
+ width: 8px;
+ text-align: center;
+ opacity: 0.6;
+}
+
+.toolStepDetails {
+ margin-top: 4px;
+ margin-left: 18px;
+ padding: 6px 10px;
+ border-radius: var(--mantine-radius-sm);
+ background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
+ color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
+ font-size: 11px;
+ line-height: 1.5;
+ overflow-x: auto;
+}
+
+.messageActions {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin-top: 4px;
+ color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
}
.processingIndicator {
diff --git a/apps/client/src/ee/ai-chat/styles/chat-sidebar.module.css b/apps/client/src/ee/ai-chat/styles/chat-sidebar.module.css
index d0848001..c7991147 100644
--- a/apps/client/src/ee/ai-chat/styles/chat-sidebar.module.css
+++ b/apps/client/src/ee/ai-chat/styles/chat-sidebar.module.css
@@ -28,6 +28,46 @@
overflow-y: auto;
}
+.chatGroup + .chatGroup {
+ margin-top: var(--mantine-spacing-sm);
+}
+
+.chatGroupLabel {
+ padding: 4px var(--mantine-spacing-xs);
+ font-size: var(--mantine-font-size-xs);
+ font-weight: 600;
+ color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
+ user-select: none;
+}
+
+.chatListEmpty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: var(--mantine-spacing-xl) var(--mantine-spacing-md);
+ text-align: center;
+ gap: 4px;
+ user-select: none;
+}
+
+.chatListEmptyIcon {
+ color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
+ margin-bottom: var(--mantine-spacing-xs);
+}
+
+.chatListEmptyTitle {
+ font-size: var(--mantine-font-size-sm);
+ font-weight: 600;
+ color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
+}
+
+.chatListEmptyHint {
+ font-size: var(--mantine-font-size-xs);
+ color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3));
+ line-height: 1.4;
+}
+
.chatItem {
display: flex;
align-items: center;
@@ -66,13 +106,33 @@
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
white-space: nowrap;
+ transition: opacity 150ms;
+}
+
+.chatItemRenameInput {
+ font-size: var(--mantine-font-size-sm);
+ padding: 0;
+ height: auto;
+ min-height: 0;
+ background: transparent;
+ color: inherit;
+}
+
+.chatItem:hover .chatItemDate {
+ opacity: 0;
}
.chatItemActions {
+ position: absolute;
+ right: var(--mantine-spacing-xs);
opacity: 0;
transition: opacity 150ms;
}
+.chatItem {
+ position: relative;
+}
+
.chatItem:hover .chatItemActions {
opacity: 1;
}
diff --git a/apps/client/src/ee/ai-chat/types/ai-chat.types.ts b/apps/client/src/ee/ai-chat/types/ai-chat.types.ts
index ac601b11..89754d26 100644
--- a/apps/client/src/ee/ai-chat/types/ai-chat.types.ts
+++ b/apps/client/src/ee/ai-chat/types/ai-chat.types.ts
@@ -30,7 +30,7 @@ export type AiChatStreamEvent =
| { type: 'tool_call'; id: string; name: string; args: Record }
| { type: 'tool_result'; id: string; result: unknown }
| { type: 'done'; messageId: string; usage?: Record }
- | { type: 'error'; message: string };
+ | { type: 'error'; message: string; code?: string; retryable?: boolean };
export type PageMention = {
id: string;
diff --git a/apps/client/src/features/home/components/home-ai-prompt.module.css b/apps/client/src/features/home/components/home-ai-prompt.module.css
new file mode 100644
index 00000000..e6d81606
--- /dev/null
+++ b/apps/client/src/features/home/components/home-ai-prompt.module.css
@@ -0,0 +1,28 @@
+.wrapper {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: var(--mantine-spacing-xl) var(--mantine-spacing-md) var(--mantine-spacing-lg);
+}
+
+.heading {
+ font-size: 1.75rem;
+ font-weight: 600;
+ color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
+ text-align: center;
+ margin: 0;
+ line-height: 1.2;
+}
+
+.subtitle {
+ font-size: var(--mantine-font-size-sm);
+ color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
+ text-align: center;
+ margin-top: 6px;
+ margin-bottom: var(--mantine-spacing-lg);
+}
+
+.inputContainer {
+ width: 100%;
+ max-width: 640px;
+}
diff --git a/apps/client/src/features/home/components/home-ai-prompt.tsx b/apps/client/src/features/home/components/home-ai-prompt.tsx
new file mode 100644
index 00000000..c3c69398
--- /dev/null
+++ b/apps/client/src/features/home/components/home-ai-prompt.tsx
@@ -0,0 +1,60 @@
+import { useAtomValue } from "jotai";
+import { useNavigate } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
+import ChatInput from "@/ee/ai-chat/components/chat-input";
+import type {
+ ChatAttachment,
+ PageMention,
+} from "@/ee/ai-chat/types/ai-chat.types";
+import classes from "./home-ai-prompt.module.css";
+
+export type HomeAiPromptInitialState = {
+ initialContent: string;
+ initialMentions: PageMention[];
+ initialAttachments: ChatAttachment[];
+};
+
+export default function HomeAiPrompt() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const workspace = useAtomValue(workspaceAtom);
+
+ const aiChatEnabled = workspace?.settings?.ai?.chat === true;
+ if (!aiChatEnabled) return null;
+
+ const handleSend = (
+ content: string,
+ mentions: PageMention[],
+ attachments: ChatAttachment[],
+ ) => {
+ if (!content.trim() && attachments.length === 0) return;
+ const state: HomeAiPromptInitialState = {
+ initialContent: content,
+ initialMentions: mentions,
+ initialAttachments: attachments,
+ };
+ navigate("/ai", { state });
+ };
+
+ return (
+
+
+ {t("Welcome to {{name}}", { name: workspace?.name ?? "Docmost" })}
+
+
+ {t("Ask anything or search your workspace")}
+
+
+
+ {}}
+ placeholder={t("Ask anything... Use @ to mention pages")}
+ autofocus={false}
+ />
+
+
+ );
+}
diff --git a/apps/client/src/features/search/components/search-control.module.css b/apps/client/src/features/search/components/search-control.module.css
index 5e5a9c26..80be81d9 100644
--- a/apps/client/src/features/search/components/search-control.module.css
+++ b/apps/client/src/features/search/components/search-control.module.css
@@ -29,6 +29,8 @@
border-radius: var(--mantine-radius-sm);
border: 1px solid;
font-weight: bold;
+ white-space: nowrap;
+ flex-shrink: 0;
@mixin light {
color: var(--mantine-color-gray-7);
diff --git a/apps/client/src/features/space/components/space-carousel.module.css b/apps/client/src/features/space/components/space-carousel.module.css
new file mode 100644
index 00000000..87b9f1de
--- /dev/null
+++ b/apps/client/src/features/space/components/space-carousel.module.css
@@ -0,0 +1,22 @@
+.card {
+ background-color: var(--mantine-color-body);
+ width: 220px;
+
+ @mixin hover {
+ box-shadow: var(--mantine-shadow-xs);
+ transform: scale(1.02);
+ }
+}
+
+.cardSection {
+ background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
+}
+
+.title {
+ font-family:
+ Greycliff CF,
+ var(--mantine-font-family);
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+}
diff --git a/apps/client/src/features/space/components/space-carousel.tsx b/apps/client/src/features/space/components/space-carousel.tsx
new file mode 100644
index 00000000..f2932675
--- /dev/null
+++ b/apps/client/src/features/space/components/space-carousel.tsx
@@ -0,0 +1,77 @@
+import { Text, Card, rem, Group, Button } from "@mantine/core";
+import {
+ prefetchSpace,
+ useGetSpacesQuery,
+} from "@/features/space/queries/space-query.ts";
+import { getSpaceUrl } from "@/lib/config.ts";
+import { Link } from "react-router-dom";
+import classes from "./space-carousel.module.css";
+import { formatMemberCount } from "@/lib";
+import { useTranslation } from "react-i18next";
+import { IconArrowRight } from "@tabler/icons-react";
+import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
+import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
+import CardCarousel from "@/components/ui/card-carousel";
+
+export default function SpaceCarousel() {
+ const { t } = useTranslation();
+ const { data } = useGetSpacesQuery({ limit: 20 });
+
+ const cards = data?.items.map((space) => (
+ prefetchSpace(space.slug, space.id)}
+ className={classes.card}
+ withBorder
+ >
+
+
+
+
+ {space.name}
+
+
+
+ {formatMemberCount(space.memberCount, t)}
+
+
+ ));
+
+ return (
+ <>
+
+
+ {t("Spaces you belong to")}
+
+
+
+ {cards}
+
+ {data?.items && data.items.length >= 20 && (
+
+ }
+ size="sm"
+ >
+ {t("View all spaces")}
+
+
+ )}
+ >
+ );
+}
diff --git a/apps/client/src/pages/dashboard/home.tsx b/apps/client/src/pages/dashboard/home.tsx
index 900afa40..cefff053 100644
--- a/apps/client/src/pages/dashboard/home.tsx
+++ b/apps/client/src/pages/dashboard/home.tsx
@@ -1,6 +1,7 @@
import { Container, Space } from "@mantine/core";
import HomeTabs from "@/features/home/components/home-tabs";
-import SpaceGrid from "@/features/space/components/space-grid.tsx";
+import HomeAiPrompt from "@/features/home/components/home-ai-prompt";
+import SpaceCarousel from "@/features/space/components/space-carousel.tsx";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
@@ -16,7 +17,11 @@ export default function Home() {
-
+
+
+
+
+
diff --git a/apps/server/package.json b/apps/server/package.json
index 9994ba43..a1a061db 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -75,10 +75,12 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie": "^1.1.1",
+ "fast-bm25": "0.0.5",
"fastify-ip": "^2.0.0",
"fs-extra": "^11.3.4",
"happy-dom": "20.8.9",
"ioredis": "^5.10.1",
+ "js-tiktoken": "^1.0.21",
"jsonwebtoken": "^9.0.3",
"kysely": "^0.28.14",
"kysely-migration-cli": "^0.4.2",
diff --git a/apps/server/src/core/attachment/attachment.constants.ts b/apps/server/src/core/attachment/attachment.constants.ts
index a9bce5c1..a5d90692 100644
--- a/apps/server/src/core/attachment/attachment.constants.ts
+++ b/apps/server/src/core/attachment/attachment.constants.ts
@@ -3,6 +3,7 @@ export enum AttachmentType {
WorkspaceIcon = 'workspace-icon',
SpaceIcon = 'space-icon',
File = 'file',
+ Chat = 'chat',
}
export const validImageExtensions = ['.jpg', '.png', '.jpeg'];
diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts
index 6382b34e..7b24cc35 100644
--- a/apps/server/src/core/attachment/attachment.controller.ts
+++ b/apps/server/src/core/attachment/attachment.controller.ts
@@ -178,21 +178,29 @@ export class AttachmentController {
}
const attachment = await this.attachmentRepo.findById(fileId);
- if (
- !attachment ||
- attachment.workspaceId !== workspace.id ||
- !attachment.pageId ||
- !attachment.spaceId
- ) {
+ if (!attachment || attachment.workspaceId !== workspace.id) {
throw new NotFoundException();
}
- const page = await this.pageRepo.findById(attachment.pageId);
- if (!page) {
- throw new NotFoundException();
- }
+ if (attachment.aiChatId) {
+ // Chat-owned attachment: only the user who uploaded (and therefore
+ // owns the chat, per AttachmentRepo.claimAttachmentsForChat) can
+ // read it back.
+ if (attachment.creatorId !== user.id) {
+ throw new NotFoundException();
+ }
+ } else {
+ if (!attachment.pageId || !attachment.spaceId) {
+ throw new NotFoundException();
+ }
- await this.pageAccessService.validateCanView(page, user);
+ const page = await this.pageRepo.findById(attachment.pageId);
+ if (!page) {
+ throw new NotFoundException();
+ }
+
+ await this.pageAccessService.validateCanView(page, user);
+ }
try {
return await this.sendFileResponse(req, res, attachment, 'private');
diff --git a/apps/server/src/core/attachment/attachment.utils.ts b/apps/server/src/core/attachment/attachment.utils.ts
index 88edb2af..616fed53 100644
--- a/apps/server/src/core/attachment/attachment.utils.ts
+++ b/apps/server/src/core/attachment/attachment.utils.ts
@@ -71,6 +71,8 @@ export function getAttachmentFolderPath(
return `${workspaceId}/space-logos`;
case AttachmentType.File:
return `${workspaceId}/files`;
+ case AttachmentType.Chat:
+ return `${workspaceId}/chat-files`;
default:
return `${workspaceId}/files`;
}
diff --git a/apps/server/src/core/attachment/processors/attachment.processor.ts b/apps/server/src/core/attachment/processors/attachment.processor.ts
index bcf8a08a..7249a9fa 100644
--- a/apps/server/src/core/attachment/processors/attachment.processor.ts
+++ b/apps/server/src/core/attachment/processors/attachment.processor.ts
@@ -28,6 +28,11 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
job.data.pageId,
);
}
+ if (job.name === QueueJob.DELETE_AI_CHAT_ATTACHMENTS) {
+ await this.attachmentService.handleDeleteAiChatAttachments(
+ job.data.aiChatId,
+ );
+ }
if (
job.name === QueueJob.ATTACHMENT_INDEX_CONTENT ||
job.name === QueueJob.ATTACHMENT_INDEXING
diff --git a/apps/server/src/core/attachment/services/attachment.service.ts b/apps/server/src/core/attachment/services/attachment.service.ts
index 6419ed58..766c9f65 100644
--- a/apps/server/src/core/attachment/services/attachment.service.ts
+++ b/apps/server/src/core/attachment/services/attachment.service.ts
@@ -289,6 +289,31 @@ export class AttachmentService {
);
}
+ async handleDeleteAiChatAttachments(aiChatId: string) {
+ try {
+ const attachments = await this.attachmentRepo.findByAiChatId(aiChatId);
+ if (!attachments || attachments.length === 0) {
+ return;
+ }
+
+ await Promise.all(
+ attachments.map(async (attachment) => {
+ try {
+ await this.storageService.delete(attachment.filePath);
+ await this.attachmentRepo.deleteAttachmentById(attachment.id);
+ } catch (err) {
+ this.logger.log(
+ `DeleteAiChatAttachments: failed to delete attachment ${attachment.id}:`,
+ err,
+ );
+ }
+ }),
+ );
+ } catch (err) {
+ throw err;
+ }
+ }
+
async handleDeleteSpaceAttachments(spaceId: string) {
try {
const attachments = await this.attachmentRepo.findBySpaceId(spaceId);
diff --git a/apps/server/src/core/auth/auth.controller.ts b/apps/server/src/core/auth/auth.controller.ts
index 441bfc1c..89bb9e1b 100644
--- a/apps/server/src/core/auth/auth.controller.ts
+++ b/apps/server/src/core/auth/auth.controller.ts
@@ -11,6 +11,10 @@ import {
Logger,
} from '@nestjs/common';
import { SkipThrottle, ThrottlerGuard } from '@nestjs/throttler';
+import {
+ AI_CHAT_THROTTLER,
+ AUTH_THROTTLER,
+} from '../../integrations/throttle/throttler-names';
import { LoginDto } from './dto/login.dto';
import { AuthService } from './services/auth.service';
import { SessionService } from '../session/session.service';
@@ -34,6 +38,7 @@ import {
IAuditService,
} from '../../integrations/audit/audit.service';
+@SkipThrottle({ [AI_CHAT_THROTTLER]: true })
@UseGuards(ThrottlerGuard)
@Controller('auth')
export class AuthController {
@@ -113,7 +118,7 @@ export class AuthController {
return workspace;
}
- @SkipThrottle()
+ @SkipThrottle({ [AUTH_THROTTLER]: true })
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('change-password')
@@ -176,7 +181,7 @@ export class AuthController {
return this.authService.verifyUserToken(verifyUserTokenDto, workspace.id);
}
- @SkipThrottle()
+ @SkipThrottle({ [AUTH_THROTTLER]: true })
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('collab-token')
@@ -187,7 +192,7 @@ export class AuthController {
return this.authService.getCollabToken(user, workspace.id);
}
- @SkipThrottle()
+ @SkipThrottle({ [AUTH_THROTTLER]: true })
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('logout')
diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts
index 6d500a34..c6d822ff 100644
--- a/apps/server/src/core/workspace/services/workspace.service.ts
+++ b/apps/server/src/core/workspace/services/workspace.service.ts
@@ -142,7 +142,7 @@ export class WorkspaceService {
status = WorkspaceStatus.Active;
plan = 'standard';
billingEmail = user.email;
- settings = { ai: { generative: true } };
+ settings = { ai: { generative: true, chat: true } };
}
// create workspace
diff --git a/apps/server/src/database/migrations/20260305T120000-ai-chat.ts b/apps/server/src/database/migrations/20260409T132415-ai-chat.ts
similarity index 52%
rename from apps/server/src/database/migrations/20260305T120000-ai-chat.ts
rename to apps/server/src/database/migrations/20260409T132415-ai-chat.ts
index ddd5fa75..28b595f1 100644
--- a/apps/server/src/database/migrations/20260305T120000-ai-chat.ts
+++ b/apps/server/src/database/migrations/20260409T132415-ai-chat.ts
@@ -3,6 +3,7 @@ import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely): Promise {
await db.schema
.createTable('ai_chats')
+ .ifNotExists()
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
@@ -19,16 +20,19 @@ export async function up(db: Kysely): Promise {
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
+ .addColumn('deleted_at', 'timestamptz', (col) => col)
.execute();
await db.schema
.createIndex('idx_ai_chats_workspace_creator')
+ .ifNotExists()
.on('ai_chats')
.columns(['workspace_id', 'creator_id', 'id'])
.execute();
await db.schema
.createTable('ai_chat_messages')
+ .ifNotExists()
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
@@ -38,23 +42,77 @@ export async function up(db: Kysely): Promise {
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
+ .addColumn('user_id', 'uuid', (col) =>
+ col.references('users.id').onDelete('set null'),
+ )
.addColumn('role', 'varchar', (col) => col.notNull())
.addColumn('content', 'text', (col) => col)
.addColumn('tool_calls', 'jsonb', (col) => col)
.addColumn('metadata', 'jsonb', (col) => col)
+ .addColumn('tsv', sql`tsvector`, (col) => col)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
+ .addColumn('updated_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addColumn('deleted_at', 'timestamptz', (col) => col)
.execute();
await db.schema
.createIndex('idx_ai_chat_messages_chat_id')
+ .ifNotExists()
.on('ai_chat_messages')
.columns(['chat_id', 'id'])
.execute();
+
+ await db.schema
+ .createIndex('idx_ai_chat_messages_tsv')
+ .ifNotExists()
+ .on('ai_chat_messages')
+ .using('GIN')
+ .column('tsv')
+ .execute();
+
+ //ts-vector
+ await sql`
+ CREATE OR REPLACE FUNCTION ai_chat_messages_tsvector_trigger() RETURNS trigger AS $$
+ BEGIN
+ NEW.tsv := to_tsvector('english', f_unaccent(substring(coalesce(NEW.content, ''), 1, 100000)));
+ RETURN NEW;
+ END;
+ $$ LANGUAGE plpgsql;
+ `.execute(db);
+
+ await sql`
+ CREATE OR REPLACE TRIGGER ai_chat_messages_tsvector_update
+ BEFORE INSERT OR UPDATE ON ai_chat_messages
+ FOR EACH ROW EXECUTE FUNCTION ai_chat_messages_tsvector_trigger();
+ `.execute(db);
+
+ await db.schema
+ .alterTable('attachments')
+ .addColumn('ai_chat_id', 'uuid', (col) => col)
+ .execute();
+
+ await db.schema
+ .createIndex('idx_attachments_ai_chat_id')
+ .ifNotExists()
+ .on('attachments')
+ .column('ai_chat_id')
+ .execute();
}
export async function down(db: Kysely): Promise {
+ await db.schema.dropIndex('idx_attachments_ai_chat_id').execute();
+ await db.schema.alterTable('attachments').dropColumn('ai_chat_id').execute();
+
+ await sql`DROP TRIGGER IF EXISTS ai_chat_messages_tsvector_update ON ai_chat_messages`.execute(
+ db,
+ );
+ await sql`DROP FUNCTION IF EXISTS ai_chat_messages_tsvector_trigger`.execute(
+ db,
+ );
await db.schema.dropTable('ai_chat_messages').execute();
await db.schema.dropTable('ai_chats').execute();
}
diff --git a/apps/server/src/database/repos/attachment/attachment.repo.ts b/apps/server/src/database/repos/attachment/attachment.repo.ts
index fcc5caee..bf2b5ecb 100644
--- a/apps/server/src/database/repos/attachment/attachment.repo.ts
+++ b/apps/server/src/database/repos/attachment/attachment.repo.ts
@@ -7,6 +7,7 @@ import {
InsertableAttachment,
UpdatableAttachment,
} from '@docmost/db/types/entity.types';
+import { AttachmentType } from '../../../core/attachment/attachment.constants';
@Injectable()
export class AttachmentRepo {
@@ -23,6 +24,7 @@ export class AttachmentRepo {
'creatorId',
'pageId',
'spaceId',
+ 'aiChatId',
'workspaceId',
'createdAt',
'updatedAt',
@@ -87,6 +89,21 @@ export class AttachmentRepo {
.execute();
}
+ async findByAiChatId(
+ aiChatId: string,
+ opts?: {
+ trx?: KyselyTransaction;
+ },
+ ): Promise {
+ const db = dbOrTx(this.db, opts?.trx);
+
+ return db
+ .selectFrom('attachments')
+ .select(this.baseFields)
+ .where('aiChatId', '=', aiChatId)
+ .execute();
+ }
+
updateAttachmentsByPageId(
updatableAttachment: UpdatableAttachment,
pageIds: string[],
@@ -112,6 +129,25 @@ export class AttachmentRepo {
.executeTakeFirst();
}
+ async claimAttachmentsForChat(
+ attachmentIds: string[],
+ aiChatId: string,
+ creatorId: string,
+ workspaceId: string,
+ ): Promise {
+ if (attachmentIds.length === 0) return;
+
+ await this.db
+ .updateTable('attachments')
+ .set({ aiChatId })
+ .where('id', 'in', attachmentIds)
+ .where('creatorId', '=', creatorId)
+ .where('workspaceId', '=', workspaceId)
+ .where('type', '=', AttachmentType.Chat)
+ .where('aiChatId', 'is', null)
+ .execute();
+ }
+
async deleteAttachmentById(attachmentId: string): Promise {
await this.db
.deleteFrom('attachments')
diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts
index 42c46eae..3f4081e1 100644
--- a/apps/server/src/database/types/db.d.ts
+++ b/apps/server/src/database/types/db.d.ts
@@ -43,6 +43,7 @@ export interface ApiKeys {
}
export interface Attachments {
+ aiChatId: string | null;
createdAt: Generated;
creatorId: string;
deletedAt: Timestamp | null;
@@ -436,17 +437,22 @@ export interface AiChats {
title: string | null;
createdAt: Generated;
updatedAt: Generated;
+ deletedAt: Timestamp | null;
}
export interface AiChatMessages {
id: Generated;
chatId: string;
workspaceId: string;
+ userId: string | null;
role: string;
content: string | null;
toolCalls: Json | null;
metadata: Json | null;
+ tsv: string | null;
createdAt: Generated;
+ updatedAt: Generated;
+ deletedAt: Timestamp | null;
}
export interface UserSessions {
diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts
index cb2857b6..8d4f482a 100644
--- a/apps/server/src/database/types/entity.types.ts
+++ b/apps/server/src/database/types/entity.types.ts
@@ -37,8 +37,14 @@ export type InsertableAiChat = Insertable;
export type UpdatableAiChat = Updateable>;
// AI Chat Message
-export type AiChatMessage = Selectable;
-export type InsertableAiChatMessage = Insertable;
+// `tsv` is an internal tsvector column maintained by a trigger for
+// full-text search. It is omitted from the public type so it never leaks
+// into HTTP responses or the chat history fed to the language model.
+export type AiChatMessage = Omit, 'tsv'>;
+export type InsertableAiChatMessage = Omit<
+ Insertable,
+ 'tsv'
+>;
// Workspace
export type Workspace = Selectable;
diff --git a/apps/server/src/ee b/apps/server/src/ee
index a9d3d468..a3e4e9c7 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit a9d3d4686965a6df57bb94e300231329ca9fc1ae
+Subproject commit a3e4e9c72c2e004e3b7db39064fc447ce65411f0
diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts
index d93f9ba0..3f8da1bf 100644
--- a/apps/server/src/integrations/export/export.service.ts
+++ b/apps/server/src/integrations/export/export.service.ts
@@ -353,7 +353,7 @@ export class ExportService {
if (attachmentIds.length > 0) {
const attachments = await this.db
.selectFrom('attachments')
- .selectAll()
+ .select(['id', 'fileName', 'filePath'])
.where('id', 'in', attachmentIds)
.where('spaceId', '=', spaceId)
.execute();
diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts
index 92d15426..6d92f09c 100644
--- a/apps/server/src/integrations/queue/constants/queue.constants.ts
+++ b/apps/server/src/integrations/queue/constants/queue.constants.ts
@@ -17,6 +17,7 @@ export enum QueueJob {
ATTACHMENT_INDEX_CONTENT = 'attachment-index-content',
ATTACHMENT_INDEXING = 'attachment-indexing',
DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments',
+ DELETE_AI_CHAT_ATTACHMENTS = 'delete-ai-chat-attachments',
DELETE_USER_AVATARS = 'delete-user-avatars',
diff --git a/apps/server/src/integrations/throttle/throttle.module.ts b/apps/server/src/integrations/throttle/throttle.module.ts
index 8f080e1d..42dd0ec4 100644
--- a/apps/server/src/integrations/throttle/throttle.module.ts
+++ b/apps/server/src/integrations/throttle/throttle.module.ts
@@ -4,6 +4,7 @@ import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis'
import { EnvironmentService } from '../environment/environment.service';
import { EnvironmentModule } from '../environment/environment.module';
import { parseRedisUrl } from '../../common/helpers';
+import { AUTH_THROTTLER, AI_CHAT_THROTTLER } from './throttler-names';
import Redis from 'ioredis';
@Module({
@@ -14,7 +15,10 @@ import Redis from 'ioredis';
const redisConfig = parseRedisUrl(environmentService.getRedisUrl());
return {
- throttlers: [{ name: 'auth', ttl: 60_000, limit: 10 }],
+ throttlers: [
+ { name: AUTH_THROTTLER, ttl: 60_000, limit: 10 },
+ { name: AI_CHAT_THROTTLER, ttl: 60_000, limit: 25 },
+ ],
errorMessage: 'Too many requests',
storage: new ThrottlerStorageRedisService(
new Redis({
diff --git a/apps/server/src/integrations/throttle/throttler-names.ts b/apps/server/src/integrations/throttle/throttler-names.ts
new file mode 100644
index 00000000..388ba29d
--- /dev/null
+++ b/apps/server/src/integrations/throttle/throttler-names.ts
@@ -0,0 +1,2 @@
+export const AUTH_THROTTLER = 'auth';
+export const AI_CHAT_THROTTLER = 'ai-chat';
diff --git a/apps/server/src/integrations/throttle/user-throttler.guard.ts b/apps/server/src/integrations/throttle/user-throttler.guard.ts
new file mode 100644
index 00000000..35744c09
--- /dev/null
+++ b/apps/server/src/integrations/throttle/user-throttler.guard.ts
@@ -0,0 +1,13 @@
+import { Injectable } from '@nestjs/common';
+import { ThrottlerGuard } from '@nestjs/throttler';
+
+type AuthedRequest = { user?: { id?: string } };
+
+@Injectable()
+export class UserThrottlerGuard extends ThrottlerGuard {
+ protected async getTracker(req: AuthedRequest): Promise {
+ const userId = req.user?.id;
+ if (userId) return `user:${userId}`;
+ return super.getTracker(req as Parameters[0]);
+ }
+}
diff --git a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts
index 04dc1978..7556aa4f 100644
--- a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts
+++ b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts
@@ -37,6 +37,8 @@ marked.use({
extensions: [calloutExtension, mathBlockExtension, mathInlineExtension],
});
+marked.setOptions({ breaks: true });
+
export function markdownToHtml(
markdownInput: string,
): string | Promise {
@@ -46,8 +48,5 @@ export function markdownToHtml(
.replace(YAML_FONT_MATTER_REGEX, "")
.trimStart();
- return marked
- .options({ breaks: true })
- .parse(markdown)
- .toString();
+ return marked.parse(markdown).toString();
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ff37ec78..cfdfcdcb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -589,6 +589,9 @@ importers:
cookie:
specifier: ^1.1.1
version: 1.1.1
+ fast-bm25:
+ specifier: 0.0.5
+ version: 0.0.5(typescript@5.9.3)
fastify-ip:
specifier: ^2.0.0
version: 2.0.0
@@ -601,6 +604,9 @@ importers:
ioredis:
specifier: ^5.10.1
version: 5.10.1
+ js-tiktoken:
+ specifier: ^1.0.21
+ version: 1.0.21
jsonwebtoken:
specifier: ^9.0.3
version: 9.0.3
@@ -6874,6 +6880,12 @@ packages:
exsolve@1.0.7:
resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==}
+ fast-bm25@0.0.5:
+ resolution: {integrity: sha512-6HTiLmPkgeqcPJHccN0pXdqnA7OzhaEQZTFzWnfjIyPoX5sGVKUUpfRc2K2o6zMwK+g009miRhADYn/f2Ax0Mg==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ typescript: ^5.6.3
+
fast-copy@4.0.2:
resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==}
@@ -8966,6 +8978,9 @@ packages:
points-on-path@0.2.1:
resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
+ porter2@1.1.0:
+ resolution: {integrity: sha512-Io2cLEdZn0O1dH60pRsjmr/cH/qJJ/j6Cjubz8wQWi0b6vPdQIUxSBQKyx9d+8CN7fSnY+5uOU3rErMFjNqcLw==}
+
possible-typed-array-names@1.0.0:
resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==}
engines: {node: '>= 0.4'}
@@ -17795,6 +17810,11 @@ snapshots:
exsolve@1.0.7: {}
+ fast-bm25@0.0.5(typescript@5.9.3):
+ dependencies:
+ porter2: 1.1.0
+ typescript: 5.9.3
+
fast-copy@4.0.2: {}
fast-decode-uri-component@1.0.1: {}
@@ -20144,6 +20164,8 @@ snapshots:
path-data-parser: 0.1.0
points-on-curve: 0.2.0
+ porter2@1.1.0: {}
+
possible-typed-array-names@1.0.0: {}
postcss-js@4.0.1(postcss@8.5.8):