mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat: ai chat
This commit is contained in:
@@ -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 WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
|
||||||
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
||||||
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
|
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
|
||||||
|
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -79,6 +80,8 @@ export default function App() {
|
|||||||
|
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path={"/home"} element={<Home />} />
|
<Route path={"/home"} element={<Home />} />
|
||||||
|
<Route path={"/ai"} element={<AiChat />} />
|
||||||
|
<Route path={"/ai/chat/:chatId"} element={<AiChat />} />
|
||||||
<Route path={"/spaces"} element={<SpacesPage />} />
|
<Route path={"/spaces"} element={<SpacesPage />} />
|
||||||
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
||||||
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
|
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ import {
|
|||||||
} from "@/features/search/constants.ts";
|
} from "@/features/search/constants.ts";
|
||||||
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
|
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() {
|
export function AppHeader() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
sidebarWidthAtom,
|
sidebarWidthAtom,
|
||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
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 { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||||
import Aside from "@/components/layouts/global/aside.tsx";
|
import Aside from "@/components/layouts/global/aside.tsx";
|
||||||
import classes from "./app-shell.module.css";
|
import classes from "./app-shell.module.css";
|
||||||
@@ -72,6 +73,7 @@ export default function GlobalAppShell({
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isSettingsRoute = location.pathname.startsWith("/settings");
|
const isSettingsRoute = location.pathname.startsWith("/settings");
|
||||||
const isSpaceRoute = location.pathname.startsWith("/s/");
|
const isSpaceRoute = location.pathname.startsWith("/s/");
|
||||||
|
const isAiRoute = location.pathname.startsWith("/ai");
|
||||||
const isHomeRoute = location.pathname.startsWith("/home");
|
const isHomeRoute = location.pathname.startsWith("/home");
|
||||||
const isSpacesRoute = location.pathname === "/spaces";
|
const isSpacesRoute = location.pathname === "/spaces";
|
||||||
const isPageRoute = location.pathname.includes("/p/");
|
const isPageRoute = location.pathname.includes("/p/");
|
||||||
@@ -82,7 +84,7 @@ export default function GlobalAppShell({
|
|||||||
header={{ height: 45 }}
|
header={{ height: 45 }}
|
||||||
navbar={
|
navbar={
|
||||||
!hideSidebar && {
|
!hideSidebar && {
|
||||||
width: isSpaceRoute ? sidebarWidth : 300,
|
width: isAiRoute ? 260 : isSpaceRoute ? sidebarWidth : 300,
|
||||||
breakpoint: "sm",
|
breakpoint: "sm",
|
||||||
collapsed: {
|
collapsed: {
|
||||||
mobile: !mobileOpened,
|
mobile: !mobileOpened,
|
||||||
@@ -108,9 +110,10 @@ export default function GlobalAppShell({
|
|||||||
withBorder={false}
|
withBorder={false}
|
||||||
ref={sidebarRef}
|
ref={sidebarRef}
|
||||||
>
|
>
|
||||||
<div className={classes.resizeHandle} onMouseDown={startResizing} />
|
{!isAiRoute && <div className={classes.resizeHandle} onMouseDown={startResizing} />}
|
||||||
{isSpaceRoute && <SpaceSidebar />}
|
{isSpaceRoute && <SpaceSidebar />}
|
||||||
{isSettingsRoute && <SettingsSidebar />}
|
{isSettingsRoute && <SettingsSidebar />}
|
||||||
|
{isAiRoute && <AiChatSidebar />}
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
)}
|
)}
|
||||||
<AppShell.Main>
|
<AppShell.Main>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className={classes.main}>
|
||||||
|
<ChatEmptyState
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
onSend={sendMessage}
|
||||||
|
onStop={stopGeneration}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.main}>
|
||||||
|
<ChatMessageList
|
||||||
|
messages={messages}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
streamingContent={streamingContent}
|
||||||
|
streamingToolCalls={streamingToolCalls}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0 var(--mantine-spacing-lg)",
|
||||||
|
color: "var(--mantine-color-red-6)",
|
||||||
|
fontSize: "var(--mantine-font-size-sm)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={classes.inputArea}>
|
||||||
|
<ChatInput
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
onSend={sendMessage}
|
||||||
|
onStop={stopGeneration}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLInputElement>(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 (
|
||||||
|
<Popover
|
||||||
|
opened={renaming}
|
||||||
|
onClose={() => setRenaming(false)}
|
||||||
|
position="bottom-start"
|
||||||
|
withinPortal
|
||||||
|
trapFocus
|
||||||
|
>
|
||||||
|
<Popover.Target>
|
||||||
|
<Link
|
||||||
|
to={`/ai/chat/${chat.id}`}
|
||||||
|
className={classes.chatItem}
|
||||||
|
data-active={isActive || undefined}
|
||||||
|
>
|
||||||
|
<span className={classes.chatItemTitle}>
|
||||||
|
{chat.title || "Untitled chat"}
|
||||||
|
</span>
|
||||||
|
<div className={classes.chatItemActions}>
|
||||||
|
<Menu position="bottom-end" withinPortal>
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
color="gray"
|
||||||
|
onClick={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<IconDots size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconEdit size={14} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startRename();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconTrash size={14} />}
|
||||||
|
color="red"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onDelete(chat.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown p="xs">
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
size="xs"
|
||||||
|
placeholder="Chat name"
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
submitRename();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setRenaming(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={submitRename}
|
||||||
|
/>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className={classes.sidebar}>
|
||||||
|
<div className={classes.header}>
|
||||||
|
<span className={classes.title}>AI Chat</span>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={handleNewChat}
|
||||||
|
aria-label="New chat"
|
||||||
|
>
|
||||||
|
<IconPlus size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
className={classes.searchInput}
|
||||||
|
placeholder="Search chats..."
|
||||||
|
leftSection={<IconSearch size={14} />}
|
||||||
|
size="xs"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={classes.chatList}>
|
||||||
|
{isLoading && <Loader size="xs" mx="auto" mt="md" />}
|
||||||
|
{chats.map((chat) => (
|
||||||
|
<AiChatSidebarItem
|
||||||
|
key={chat.id}
|
||||||
|
chat={chat}
|
||||||
|
isActive={chat.id === chatId}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onRename={handleRename}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: <IconSearch size={16} />,
|
||||||
|
text: "Search across all pages",
|
||||||
|
prompt: "Search for pages about ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <IconFilePlus size={16} />,
|
||||||
|
text: "Create a new page",
|
||||||
|
prompt: "Create a new page titled ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <IconFileText size={16} />,
|
||||||
|
text: "Summarize a page",
|
||||||
|
prompt: "Summarize the page @",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <IconEdit size={16} />,
|
||||||
|
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 (
|
||||||
|
<div className={classes.emptyState}>
|
||||||
|
<IconSparkles size={48} stroke={1.5} className={classes.emptyStateIcon} />
|
||||||
|
<div className={classes.emptyStateTitle}>What can I help you with?</div>
|
||||||
|
|
||||||
|
<div className={classes.emptyStateInput}>
|
||||||
|
<ChatInput
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
onSend={onSend}
|
||||||
|
onStop={onStop}
|
||||||
|
placeholder="Ask anything... Use @ to mention pages"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={classes.suggestionsSection}>
|
||||||
|
<div className={classes.suggestionsLabel}>Get started</div>
|
||||||
|
<div className={classes.suggestionsGrid}>
|
||||||
|
{SUGGESTIONS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.text}
|
||||||
|
type="button"
|
||||||
|
className={classes.suggestionCard}
|
||||||
|
onClick={() => handleSuggestionClick(s.prompt)}
|
||||||
|
>
|
||||||
|
<span className={classes.suggestionIcon}>{s.icon}</span>
|
||||||
|
<span className={classes.suggestionText}>{s.text}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string>();
|
||||||
|
|
||||||
|
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<PendingAttachment[]>([]);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(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 (
|
||||||
|
<div className={classes.inputWrapper} data-chat-input>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPTED_FILE_TYPES}
|
||||||
|
multiple
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={(e) => handleFileSelect(e.target.files)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{pendingAttachments.length > 0 && (
|
||||||
|
<div className={classes.attachmentChips}>
|
||||||
|
{pendingAttachments.map((attachment) => (
|
||||||
|
<div
|
||||||
|
key={attachment.id}
|
||||||
|
className={`${classes.attachmentChip} ${attachment.uploading ? classes.attachmentChipUploading : ""}`}
|
||||||
|
>
|
||||||
|
{IMAGE_EXTENSIONS.includes(attachment.fileExt) ? (
|
||||||
|
<IconPhoto size={14} />
|
||||||
|
) : (
|
||||||
|
<IconFile size={14} />
|
||||||
|
)}
|
||||||
|
<span className={classes.attachmentChipName}>
|
||||||
|
{attachment.fileName}
|
||||||
|
</span>
|
||||||
|
{!attachment.uploading && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={classes.attachmentChipRemove}
|
||||||
|
onClick={() => removeAttachment(attachment.id)}
|
||||||
|
aria-label={`Remove ${attachment.fileName}`}
|
||||||
|
>
|
||||||
|
<IconX size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EditorContent editor={editor} className={classes.editorContent} />
|
||||||
|
<div className={classes.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={classes.attachButton}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
aria-label="Attach file"
|
||||||
|
>
|
||||||
|
<IconPaperclip size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
{isStreaming ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={classes.stopButton}
|
||||||
|
onClick={onStop}
|
||||||
|
aria-label="Stop generation"
|
||||||
|
>
|
||||||
|
<IconPlayerStopFilled size={14} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={classes.sendButton}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!hasContent}
|
||||||
|
aria-label="Send message"
|
||||||
|
>
|
||||||
|
<IconArrowUp size={16} stroke={2.5} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [messages.length, streamingContent, streamingToolCalls.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.messageList}>
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<ChatMessage key={msg.id} message={msg} />
|
||||||
|
))}
|
||||||
|
{isStreaming && (
|
||||||
|
<ChatMessage
|
||||||
|
message={{
|
||||||
|
id: "streaming",
|
||||||
|
chatId: "",
|
||||||
|
role: "assistant",
|
||||||
|
content: null,
|
||||||
|
toolCalls: null,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}}
|
||||||
|
isStreaming
|
||||||
|
streamingContent={streamingContent}
|
||||||
|
streamingToolCalls={streamingToolCalls}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>) => {
|
||||||
|
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<referenced_pages>[\s\S]*<\/referenced_pages>$/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
const attachments = (message.metadata?.attachments as { id: string; fileName: string; fileExt: string }[]) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.userMessage}>
|
||||||
|
<div className={classes.userBubble}>
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className={classes.messageAttachments}>
|
||||||
|
{attachments.map((a) => (
|
||||||
|
<span key={a.id} className={classes.messageAttachmentChip}>
|
||||||
|
{IMAGE_EXTENSIONS.includes(a.fileExt) ? (
|
||||||
|
<IconPhoto size={13} />
|
||||||
|
) : (
|
||||||
|
<IconFile size={13} />
|
||||||
|
)}
|
||||||
|
{a.fileName}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{displayContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.assistantMessage}>
|
||||||
|
<div className={classes.messageContent}>
|
||||||
|
{toolCalls?.map((tc) => (
|
||||||
|
<ChatToolResult key={tc.id} toolCall={tc} />
|
||||||
|
))}
|
||||||
|
{content && (
|
||||||
|
<div
|
||||||
|
onClick={handleContentClick}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: DOMPurify.sanitize(
|
||||||
|
markdownToHtml(content) as string,
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isStreaming && (
|
||||||
|
<>
|
||||||
|
{!content && (
|
||||||
|
<span className={classes.processingIndicator}>
|
||||||
|
<IconLoader2 size={16} className={classes.processingSpinner} />
|
||||||
|
Thinking
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={classes.streamingCursor} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div className={classes.toolCallCard}>
|
||||||
|
<div
|
||||||
|
className={classes.toolCallHeader}
|
||||||
|
onClick={() => setExpanded((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<IconTool size={14} stroke={1.5} />
|
||||||
|
<span className={classes.toolCallName}>{label}</span>
|
||||||
|
{expanded ? (
|
||||||
|
<IconChevronDown size={14} />
|
||||||
|
) : (
|
||||||
|
<IconChevronRight size={14} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<div className={classes.toolCallDetails}>
|
||||||
|
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
|
||||||
|
{JSON.stringify(
|
||||||
|
{ args: toolCall.args, result: toolCall.result },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
|
<div>
|
||||||
|
<Text size="md">{t("AI Chat")}</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t(
|
||||||
|
"Enable AI Chat to allow users to have multi-turn conversations with AI about your workspace content.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AiChatToggle />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={checked}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!hasAccess}
|
||||||
|
aria-label={t("Toggle AI Chat")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<AiChatMessage[]>([]);
|
||||||
|
const [streamingContent, setStreamingContent] = useState("");
|
||||||
|
const [streamingToolCalls, setStreamingToolCalls] = useState<AiChatToolCall[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(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<string, unknown> = {};
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import AiChatLayout from "../components/ai-chat-layout";
|
||||||
|
import classes from "../styles/ai-chat.module.css";
|
||||||
|
|
||||||
|
export default function AiChat() {
|
||||||
|
return (
|
||||||
|
<div className={classes.layout}>
|
||||||
|
<AiChatLayout />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<AiChat> {
|
||||||
|
const req = await api.post<AiChat>("/ai-chat/create");
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listChats(params?: {
|
||||||
|
limit?: number;
|
||||||
|
cursor?: string;
|
||||||
|
}): Promise<IPagination<AiChat>> {
|
||||||
|
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<void> {
|
||||||
|
await api.post("/ai-chat/delete", { chatId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateChatTitle(
|
||||||
|
chatId: string,
|
||||||
|
title: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await api.post("/ai-chat/update", { chatId, title });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchChats(query: string): Promise<AiChat[]> {
|
||||||
|
const req = await api.post("/ai-chat/search", { query });
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadChatFile(file: File): Promise<ChatAttachment> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
result?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiChatMessage = {
|
||||||
|
id: string;
|
||||||
|
chatId: string;
|
||||||
|
role: 'user' | 'assistant' | 'tool';
|
||||||
|
content: string | null;
|
||||||
|
toolCalls: AiChatToolCall[] | null;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiChatStreamEvent =
|
||||||
|
| { type: 'chat_created'; chatId: string }
|
||||||
|
| { type: 'content'; text: string }
|
||||||
|
| { type: 'tool_call'; id: string; name: string; args: Record<string, unknown> }
|
||||||
|
| { type: 'tool_result'; id: string; result: unknown }
|
||||||
|
| { type: 'done'; messageId: string; usage?: Record<string, number> }
|
||||||
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import useUserRole from "@/hooks/use-user-role.tsx";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
|
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
|
||||||
import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.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 McpSettings from "@/ee/ai/components/mcp-settings.tsx";
|
||||||
import { Alert, Stack, Tabs } from "@mantine/core";
|
import { Alert, Stack, Tabs } from "@mantine/core";
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
import { IconInfoCircle } from "@tabler/icons-react";
|
||||||
@@ -68,6 +69,7 @@ export default function AiSettings() {
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{!isCloud() && <EnableAiSearch />}
|
{!isCloud() && <EnableAiSearch />}
|
||||||
<EnableGenerativeAi />
|
<EnableGenerativeAi />
|
||||||
|
<EnableAiChat />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
query: props.query,
|
query: props.query,
|
||||||
includeUsers: true,
|
includeUsers: true,
|
||||||
includePages: true,
|
includePages: true,
|
||||||
spaceId: space.id,
|
spaceId: space?.id,
|
||||||
limit: props.query ? 10 : 5,
|
limit: props.query ? 10 : 5,
|
||||||
preload: true,
|
preload: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ const mentionRenderItems = () => {
|
|||||||
const editorDom = props.editor?.view?.dom;
|
const editorDom = props.editor?.view?.dom;
|
||||||
const asideEl = editorDom?.closest(".mantine-AppShell-aside");
|
const asideEl = editorDom?.closest(".mantine-AppShell-aside");
|
||||||
const dialogEl = editorDom?.closest("[data-comment-dialog]");
|
const dialogEl = editorDom?.closest("[data-comment-dialog]");
|
||||||
const isInCommentContext = !!(asideEl || dialogEl);
|
const chatInput = editorDom?.closest("[data-chat-input]");
|
||||||
// const isInCommentContext = !!asideEl;
|
const isInCommentContext = !!(asideEl || dialogEl || chatInput);
|
||||||
|
|
||||||
component = new ReactRenderer(MentionList, {
|
component = new ReactRenderer(MentionList, {
|
||||||
props: { ...props, isInCommentContext },
|
props: { ...props, isInCommentContext },
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export interface IWorkspaceAiSettings {
|
|||||||
search?: boolean;
|
search?: boolean;
|
||||||
generative?: boolean;
|
generative?: boolean;
|
||||||
mcp?: boolean;
|
mcp?: boolean;
|
||||||
|
chat?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkspaceSharingSettings {
|
export interface IWorkspaceSharingSettings {
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
mcpEnabled: boolean;
|
mcpEnabled: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
aiChat: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
|
|||||||
@@ -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.restrictApiToAdmins;
|
||||||
delete updateWorkspaceDto.aiSearch;
|
delete updateWorkspaceDto.aiSearch;
|
||||||
delete updateWorkspaceDto.generativeAi;
|
delete updateWorkspaceDto.generativeAi;
|
||||||
delete updateWorkspaceDto.disablePublicSharing;
|
delete updateWorkspaceDto.disablePublicSharing;
|
||||||
delete updateWorkspaceDto.mcpEnabled;
|
delete updateWorkspaceDto.mcpEnabled;
|
||||||
|
delete updateWorkspaceDto.aiChat;
|
||||||
|
|
||||||
await this.workspaceRepo.updateWorkspace(
|
await this.workspaceRepo.updateWorkspace(
|
||||||
updateWorkspaceDto,
|
updateWorkspaceDto,
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { type Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
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<any>): Promise<void> {
|
||||||
|
await db.schema.dropTable('ai_chat_messages').execute();
|
||||||
|
await db.schema.dropTable('ai_chats').execute();
|
||||||
|
}
|
||||||
@@ -44,6 +44,21 @@ export class AttachmentRepo {
|
|||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByIdWithContent(
|
||||||
|
attachmentId: string,
|
||||||
|
opts?: {
|
||||||
|
trx?: KyselyTransaction;
|
||||||
|
},
|
||||||
|
): Promise<Attachment> {
|
||||||
|
const db = dbOrTx(this.db, opts?.trx);
|
||||||
|
|
||||||
|
return db
|
||||||
|
.selectFrom('attachments')
|
||||||
|
.select([...this.baseFields, 'textContent'])
|
||||||
|
.where('id', '=', attachmentId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
async insertAttachment(
|
async insertAttachment(
|
||||||
insertableAttachment: InsertableAttachment,
|
insertableAttachment: InsertableAttachment,
|
||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
|
|||||||
+22
@@ -429,7 +429,29 @@ export interface PagePermissions {
|
|||||||
updatedAt: Generated<Timestamp>;
|
updatedAt: Generated<Timestamp>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AiChats {
|
||||||
|
id: Generated<string>;
|
||||||
|
workspaceId: string;
|
||||||
|
creatorId: string;
|
||||||
|
title: string | null;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiChatMessages {
|
||||||
|
id: Generated<string>;
|
||||||
|
chatId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
role: string;
|
||||||
|
content: string | null;
|
||||||
|
toolCalls: Json | null;
|
||||||
|
metadata: Json | null;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DB {
|
export interface DB {
|
||||||
|
aiChats: AiChats;
|
||||||
|
aiChatMessages: AiChatMessages;
|
||||||
apiKeys: ApiKeys;
|
apiKeys: ApiKeys;
|
||||||
attachments: Attachments;
|
attachments: Attachments;
|
||||||
audit: Audit;
|
audit: Audit;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Insertable, Selectable, Updateable } from 'kysely';
|
import { Insertable, Selectable, Updateable } from 'kysely';
|
||||||
import {
|
import {
|
||||||
|
AiChats,
|
||||||
|
AiChatMessages,
|
||||||
Attachments,
|
Attachments,
|
||||||
Comments,
|
Comments,
|
||||||
Groups,
|
Groups,
|
||||||
@@ -28,6 +30,15 @@ import {
|
|||||||
} from './db';
|
} from './db';
|
||||||
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
||||||
|
|
||||||
|
// AI Chat
|
||||||
|
export type AiChat = Selectable<AiChats>;
|
||||||
|
export type InsertableAiChat = Insertable<AiChats>;
|
||||||
|
export type UpdatableAiChat = Updateable<Omit<AiChats, 'id'>>;
|
||||||
|
|
||||||
|
// AI Chat Message
|
||||||
|
export type AiChatMessage = Selectable<AiChatMessages>;
|
||||||
|
export type InsertableAiChatMessage = Insertable<AiChatMessages>;
|
||||||
|
|
||||||
// Workspace
|
// Workspace
|
||||||
export type Workspace = Selectable<Workspaces>;
|
export type Workspace = Selectable<Workspaces>;
|
||||||
export type InsertableWorkspace = Insertable<Workspaces>;
|
export type InsertableWorkspace = Insertable<Workspaces>;
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: 8b7ae8cf1b...8b48e93a1b
@@ -252,6 +252,13 @@ export class EnvironmentService {
|
|||||||
return this.configService.get<string>('AI_COMPLETION_MODEL');
|
return this.configService.get<string>('AI_COMPLETION_MODEL');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAiChatModel(): string {
|
||||||
|
return (
|
||||||
|
this.configService.get<string>('AI_CHAT_MODEL') ||
|
||||||
|
this.configService.get<string>('AI_COMPLETION_MODEL')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
getAiEmbeddingDimension(): number {
|
getAiEmbeddingDimension(): number {
|
||||||
return parseInt(
|
return parseInt(
|
||||||
this.configService.get<string>('AI_EMBEDDING_DIMENSION'),
|
this.configService.get<string>('AI_EMBEDDING_DIMENSION'),
|
||||||
|
|||||||
Reference in New Issue
Block a user