mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
57efb91bd3
* feat: ai chat * feat: ai chat * sync * cleanup * view space button
259 lines
7.9 KiB
TypeScript
259 lines
7.9 KiB
TypeScript
import { useState, useEffect, useCallback } from "react";
|
|
import { ActionIcon, Popover, Tooltip, UnstyledButton } from "@mantine/core";
|
|
import {
|
|
IconPlus,
|
|
IconChevronDown,
|
|
IconArrowsDiagonal,
|
|
IconX,
|
|
IconSparkles,
|
|
IconFileText,
|
|
IconLanguage,
|
|
IconSearch,
|
|
} from "@tabler/icons-react";
|
|
import { useAtom } from "jotai";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
|
import { usePageQuery } from "@/features/page/queries/page-query";
|
|
import { extractPageSlugId } from "@/lib";
|
|
import { useChatStream } from "../hooks/use-chat-stream";
|
|
import { useChatInfoQuery } from "../queries/ai-chat-query";
|
|
import ChatMessageList from "./chat-message-list";
|
|
import ChatInput from "./chat-input";
|
|
import AsideChatHistory from "./aside-chat-history";
|
|
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
|
|
import classes from "../styles/aside-chat-panel.module.css";
|
|
|
|
type QuickAction = {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
prompt: string;
|
|
};
|
|
|
|
export default function AsideChatPanel() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const [, setAsideState] = useAtom(asideStateAtom);
|
|
const [chatId, setChatId] = useState<string | undefined>(undefined);
|
|
const [historyOpen, setHistoryOpen] = useState(false);
|
|
const [contextPages, setContextPages] = useState<PageMention[]>([]);
|
|
const { pageSlug } = useParams();
|
|
const slugId = extractPageSlugId(pageSlug);
|
|
const { data: page } = usePageQuery({ pageId: slugId });
|
|
|
|
const chatInfoQuery = useChatInfoQuery(chatId);
|
|
const {
|
|
messages,
|
|
streamingContent,
|
|
streamingToolCalls,
|
|
isStreaming,
|
|
error,
|
|
sendMessage,
|
|
stopGeneration,
|
|
hydrateFromServer,
|
|
} = useChatStream(chatId, {
|
|
onChatCreated: (newChatId) => {
|
|
setChatId(newChatId);
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (page && !chatId) {
|
|
setContextPages([{ id: page.id, title: page.title || "", slugId: page.slugId }]);
|
|
}
|
|
}, [page, chatId]);
|
|
|
|
const handleRemoveContextPage = useCallback((pageId: string) => {
|
|
setContextPages((prev) => prev.filter((p) => p.id !== pageId));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (chatInfoQuery.data?.messages) {
|
|
hydrateFromServer(chatInfoQuery.data.messages);
|
|
}
|
|
}, [chatInfoQuery.data, hydrateFromServer]);
|
|
|
|
// Drop the open chatId if the current user lost access to it (404/403 on
|
|
// the info fetch). Reverts the panel to a fresh chat instead of presenting
|
|
// an input tied to a chat the user does not own.
|
|
useEffect(() => {
|
|
if (chatId && chatInfoQuery.isError) {
|
|
setChatId(undefined);
|
|
}
|
|
}, [chatId, chatInfoQuery.isError]);
|
|
|
|
const handleNewChat = useCallback(
|
|
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
|
if (
|
|
event.button !== 0 ||
|
|
event.ctrlKey ||
|
|
event.metaKey ||
|
|
event.shiftKey
|
|
) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
setChatId(undefined);
|
|
if (page) {
|
|
setContextPages([
|
|
{ id: page.id, title: page.title || "", slugId: page.slugId },
|
|
]);
|
|
}
|
|
},
|
|
[page],
|
|
);
|
|
|
|
const handleSelectChat = useCallback((selectedChatId: string) => {
|
|
setChatId(selectedChatId);
|
|
setHistoryOpen(false);
|
|
}, []);
|
|
|
|
const handleExpand = useCallback(() => {
|
|
if (chatId) {
|
|
navigate(`/ai/chat/${chatId}`);
|
|
} else {
|
|
navigate("/ai");
|
|
}
|
|
setAsideState({ tab: "", isAsideOpen: false });
|
|
}, [chatId, navigate, setAsideState]);
|
|
|
|
const handleClose = useCallback(() => {
|
|
setAsideState({ tab: "", isAsideOpen: false });
|
|
}, [setAsideState]);
|
|
|
|
const handleSend = useCallback(
|
|
(content: string, mentions: PageMention[], attachments: ChatAttachment[]) => {
|
|
const contextPageId = contextPages.length > 0 ? contextPages[0].id : undefined;
|
|
sendMessage(content, mentions, attachments, contextPageId);
|
|
},
|
|
[sendMessage, contextPages],
|
|
);
|
|
|
|
const handleQuickAction = useCallback(
|
|
(prompt: string) => {
|
|
handleSend(prompt, [], []);
|
|
},
|
|
[handleSend],
|
|
);
|
|
|
|
const hasMessages = messages.length > 0 || isStreaming;
|
|
|
|
const quickActions: QuickAction[] = [
|
|
{ icon: <IconFileText size={16} />, label: t("Summarize this page"), prompt: "Summarize this page" },
|
|
{ icon: <IconLanguage size={16} />, label: t("Translate this page"), prompt: "Translate this page" },
|
|
{ icon: <IconSearch size={16} />, label: t("Analyze for insights"), prompt: "Analyze this page for insights" },
|
|
];
|
|
|
|
return (
|
|
<div className={classes.panel}>
|
|
<div className={classes.toolbar}>
|
|
<Popover
|
|
opened={historyOpen}
|
|
onChange={setHistoryOpen}
|
|
position="bottom-start"
|
|
width={280}
|
|
shadow="md"
|
|
>
|
|
<Popover.Target>
|
|
<UnstyledButton
|
|
className={classes.titleButton}
|
|
onClick={() => setHistoryOpen((o) => !o)}
|
|
>
|
|
<span className={classes.titleText}>
|
|
{chatInfoQuery.data?.chat?.title || t("New chat")}
|
|
</span>
|
|
<IconChevronDown size={16} stroke={1.75} />
|
|
</UnstyledButton>
|
|
</Popover.Target>
|
|
<Popover.Dropdown>
|
|
<AsideChatHistory activeChatId={chatId} onSelect={handleSelectChat} />
|
|
</Popover.Dropdown>
|
|
</Popover>
|
|
|
|
<div className={classes.toolbarSpacer} />
|
|
|
|
<Tooltip label={t("New chat")} openDelay={250}>
|
|
<ActionIcon
|
|
component="a"
|
|
href="/ai"
|
|
variant="subtle"
|
|
color="dark"
|
|
onClick={handleNewChat}
|
|
>
|
|
<IconPlus size={20} stroke={1.75} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
|
|
<Tooltip label={t("Open full page")} openDelay={250}>
|
|
<ActionIcon variant="subtle" color="dark" onClick={handleExpand}>
|
|
<IconArrowsDiagonal size={18} stroke={1.5} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
|
|
<Tooltip label={t("Close")} openDelay={250}>
|
|
<ActionIcon variant="subtle" color="dark" onClick={handleClose}>
|
|
<IconX size={20} stroke={1.75} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
{error && (
|
|
<div
|
|
style={{
|
|
padding: "var(--mantine-spacing-xs) var(--mantine-spacing-sm)",
|
|
color: "var(--mantine-color-red-6)",
|
|
fontSize: "var(--mantine-font-size-xs)",
|
|
}}
|
|
>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{hasMessages ? (
|
|
<>
|
|
<div className={classes.messages} data-aside-chat>
|
|
<ChatMessageList
|
|
messages={messages}
|
|
isStreaming={isStreaming}
|
|
streamingContent={streamingContent}
|
|
streamingToolCalls={streamingToolCalls}
|
|
/>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className={classes.emptyState}>
|
|
<IconSparkles size={36} stroke={1.5} className={classes.emptyStateIcon} />
|
|
<div className={classes.emptyStateTitle}>{t("How can I help you today?")}</div>
|
|
<div className={classes.quickActions}>
|
|
{quickActions.map((action) => (
|
|
<button
|
|
key={action.label}
|
|
type="button"
|
|
className={classes.quickAction}
|
|
onClick={() => handleQuickAction(action.prompt)}
|
|
>
|
|
<span className={classes.quickActionIcon}>{action.icon}</span>
|
|
{action.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className={classes.inputArea}>
|
|
<ChatInput
|
|
isStreaming={isStreaming}
|
|
onSend={handleSend}
|
|
onStop={stopGeneration}
|
|
placeholder={t("Ask anything...")}
|
|
autofocus={false}
|
|
contextPages={contextPages}
|
|
onRemoveContextPage={handleRemoveContextPage}
|
|
variant="flat"
|
|
chatId={chatId}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|