feat(fork)!: remove all frontend Enterprise-Edition code (community build)
Strip the proprietary client EE so the fork ships a clean community/AGPL edition, mirroring Forkmost. Delete apps/client/src/ee (201 files) and packages/ee, and patch every consumer that imported from @/ee/*. - gate-out EE features (useHasFeature -> false): API keys, SSO, MFA, SCIM, audit logs, AI / AI-chat, templates, page permissions, page verification, comment resolution, trash retention, viewer comments - drop cloud/billing/trial/entitlement/posthog flows; sign-in is now email+password only (no SSO/LDAP/cloud) - remove EE routes from App.tsx and EE entries from sidebars/settings nav - restore the community page-share button (ShareModal) that the EE PageShareModal used to provide - remove the dead "Attachments" search filter, dead MFA navigation and orphaned route constants Client type-checks clean; full `pnpm build` is green for all three projects.
This commit is contained in:
+1
-52
@@ -17,39 +17,17 @@ import Layout from "@/components/layouts/global/layout.tsx";
|
|||||||
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
||||||
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
||||||
import PasswordReset from "./pages/auth/password-reset";
|
import PasswordReset from "./pages/auth/password-reset";
|
||||||
import Billing from "@/ee/billing/pages/billing.tsx";
|
|
||||||
import CloudLogin from "@/ee/pages/cloud-login.tsx";
|
|
||||||
import CreateWorkspace from "@/ee/pages/create-workspace.tsx";
|
|
||||||
import { isCloud } from "@/lib/config.ts";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import Security from "@/ee/security/pages/security.tsx";
|
|
||||||
import License from "@/ee/licence/pages/license.tsx";
|
|
||||||
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
|
|
||||||
import SharedPage from "@/pages/share/shared-page.tsx";
|
import SharedPage from "@/pages/share/shared-page.tsx";
|
||||||
import PdfRenderPage from "@/ee/pdf-export/pdf-render-page.tsx";
|
|
||||||
import Shares from "@/pages/settings/shares/shares.tsx";
|
import Shares from "@/pages/settings/shares/shares.tsx";
|
||||||
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
||||||
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
||||||
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||||
import SpacesPage from "@/pages/spaces/spaces.tsx";
|
import SpacesPage from "@/pages/spaces/spaces.tsx";
|
||||||
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
|
|
||||||
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
|
|
||||||
import SpaceTrash from "@/pages/space/space-trash.tsx";
|
import SpaceTrash from "@/pages/space/space-trash.tsx";
|
||||||
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
|
|
||||||
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
|
|
||||||
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
|
||||||
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
|
|
||||||
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
|
|
||||||
import TemplateList from "@/ee/template/pages/template-list";
|
|
||||||
import TemplateEditor from "@/ee/template/pages/template-editor";
|
|
||||||
import FavoritesPage from "@/pages/favorites/favorites-page";
|
import FavoritesPage from "@/pages/favorites/favorites-page";
|
||||||
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
|
|
||||||
import VerifyEmail from "@/ee/pages/verify-email.tsx";
|
|
||||||
import LabelPage from "@/pages/label/label-page";
|
import LabelPage from "@/pages/label/label-page";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
|
||||||
useRedirectToCloudSelect();
|
|
||||||
useTrackOrigin();
|
useTrackOrigin();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -60,20 +38,8 @@ export default function App() {
|
|||||||
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
|
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
|
||||||
<Route path={"/forgot-password"} element={<ForgotPassword />} />
|
<Route path={"/forgot-password"} element={<ForgotPassword />} />
|
||||||
<Route path={"/password-reset"} element={<PasswordReset />} />
|
<Route path={"/password-reset"} element={<PasswordReset />} />
|
||||||
<Route path={"/login/mfa"} element={<MfaChallengePage />} />
|
|
||||||
<Route path={"/login/mfa/setup"} element={<MfaSetupRequiredPage />} />
|
|
||||||
|
|
||||||
{!isCloud() && (
|
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
||||||
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isCloud() && (
|
|
||||||
<>
|
|
||||||
<Route path={"/create"} element={<CreateWorkspace />} />
|
|
||||||
<Route path={"/select"} element={<CloudLogin />} />
|
|
||||||
<Route path={"/verify-email"} element={<VerifyEmail />} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Route element={<ShareLayout />}>
|
<Route element={<ShareLayout />}>
|
||||||
<Route
|
<Route
|
||||||
@@ -83,22 +49,14 @@ export default function App() {
|
|||||||
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path={"/pdf-render/:pageId"} element={<PdfRenderPage />} />
|
|
||||||
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
|
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
|
||||||
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
|
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
|
||||||
|
|
||||||
<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={"/favorites"} element={<FavoritesPage />} />
|
<Route path={"/favorites"} element={<FavoritesPage />} />
|
||||||
<Route path={"/labels/:labelName"} element={<LabelPage />} />
|
<Route path={"/labels/:labelName"} element={<LabelPage />} />
|
||||||
<Route path={"/templates"} element={<TemplateList />} />
|
|
||||||
<Route
|
|
||||||
path={"/templates/:templateId"}
|
|
||||||
element={<TemplateEditor />}
|
|
||||||
/>
|
|
||||||
<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 />} />
|
||||||
<Route
|
<Route
|
||||||
@@ -112,21 +70,12 @@ export default function App() {
|
|||||||
path={"account/preferences"}
|
path={"account/preferences"}
|
||||||
element={<AccountPreferences />}
|
element={<AccountPreferences />}
|
||||||
/>
|
/>
|
||||||
<Route path={"account/api-keys"} element={<UserApiKeys />} />
|
|
||||||
<Route path={"workspace"} element={<WorkspaceSettings />} />
|
<Route path={"workspace"} element={<WorkspaceSettings />} />
|
||||||
<Route path={"members"} element={<WorkspaceMembers />} />
|
<Route path={"members"} element={<WorkspaceMembers />} />
|
||||||
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
|
|
||||||
<Route path={"groups"} element={<Groups />} />
|
<Route path={"groups"} element={<Groups />} />
|
||||||
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
||||||
<Route path={"spaces"} element={<Spaces />} />
|
<Route path={"spaces"} element={<Spaces />} />
|
||||||
<Route path={"sharing"} element={<Shares />} />
|
<Route path={"sharing"} element={<Shares />} />
|
||||||
<Route path={"security"} element={<Security />} />
|
|
||||||
<Route path={"ai"} element={<AiSettings />} />
|
|
||||||
<Route path={"ai/mcp"} element={<AiSettings />} />
|
|
||||||
<Route path={"audit"} element={<AuditLogs />} />
|
|
||||||
<Route path={"verifications"} element={<VerifiedPages />} />
|
|
||||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
|
||||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
|
||||||
Badge,
|
|
||||||
Box,
|
Box,
|
||||||
Group,
|
Group,
|
||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
UnstyledButton,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import classes from "./app-header.module.css";
|
import classes from "./app-header.module.css";
|
||||||
import React from "react";
|
|
||||||
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { IconSparkles } from "@tabler/icons-react";
|
|
||||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
|
||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
@@ -22,18 +16,14 @@ import {
|
|||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useTrial from "@/ee/hooks/use-trial.tsx";
|
|
||||||
import { isCloud } from "@/lib/config.ts";
|
|
||||||
import {
|
import {
|
||||||
SearchControl,
|
SearchControl,
|
||||||
SearchMobileControl,
|
SearchMobileControl,
|
||||||
} from "@/features/search/components/search-control.tsx";
|
} from "@/features/search/components/search-control.tsx";
|
||||||
import {
|
import {
|
||||||
searchSpotlight,
|
searchSpotlight,
|
||||||
shareSearchSpotlight,
|
|
||||||
} 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";
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ link: APP_ROUTE.HOME, label: "Home" },
|
{ link: APP_ROUTE.HOME, label: "Home" },
|
||||||
@@ -46,13 +36,6 @@ export function AppHeader() {
|
|||||||
|
|
||||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||||
const { isTrial, trialDaysLeft } = useTrial();
|
|
||||||
const location = useLocation();
|
|
||||||
const toggleAside = useToggleAside();
|
|
||||||
const [workspace] = useAtom(workspaceAtom);
|
|
||||||
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
|
|
||||||
|
|
||||||
const isPageRoute = location.pathname.includes("/p/");
|
|
||||||
|
|
||||||
const items = links.map((link) => (
|
const items = links.map((link) => (
|
||||||
<Link key={link.label} to={link.link} className={classes.link}>
|
<Link key={link.label} to={link.link} className={classes.link}>
|
||||||
@@ -118,63 +101,7 @@ export function AppHeader() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Group px={"xl"} wrap="nowrap">
|
<Group px={"xl"} wrap="nowrap">
|
||||||
{aiChatEnabled && (
|
|
||||||
<>
|
|
||||||
<UnstyledButton
|
|
||||||
component={Link}
|
|
||||||
to="/ai"
|
|
||||||
className={classes.link}
|
|
||||||
visibleFrom="sm"
|
|
||||||
onClick={(e: React.MouseEvent) => {
|
|
||||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isPageRoute) {
|
|
||||||
e.preventDefault();
|
|
||||||
toggleAside("chat");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("AI Chat")}
|
|
||||||
</UnstyledButton>
|
|
||||||
<Tooltip label={t("AI Chat")} openDelay={250} withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
component={Link}
|
|
||||||
to="/ai"
|
|
||||||
variant="subtle"
|
|
||||||
color="dark"
|
|
||||||
size="sm"
|
|
||||||
hiddenFrom="sm"
|
|
||||||
aria-label={t("AI Chat")}
|
|
||||||
onClick={(e: React.MouseEvent) => {
|
|
||||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isPageRoute) {
|
|
||||||
e.preventDefault();
|
|
||||||
toggleAside("chat");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconSparkles size={20} stroke={2} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<NotificationPopover />
|
<NotificationPopover />
|
||||||
{isCloud() && isTrial && trialDaysLeft !== 0 && (
|
|
||||||
<Badge
|
|
||||||
variant="light"
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
component={Link}
|
|
||||||
to={APP_ROUTE.SETTINGS.WORKSPACE.BILLING}
|
|
||||||
visibleFrom="xs"
|
|
||||||
>
|
|
||||||
{trialDaysLeft === 1
|
|
||||||
? "1 day left"
|
|
||||||
: `${trialDaysLeft} days left`}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<TopMenu />
|
<TopMenu />
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
|
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
|
|
||||||
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
|
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
|
||||||
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
||||||
|
|
||||||
@@ -35,10 +34,6 @@ export default function Aside() {
|
|||||||
component = <TableOfContents editor={pageEditor} />;
|
component = <TableOfContents editor={pageEditor} />;
|
||||||
title = "Table of contents";
|
title = "Table of contents";
|
||||||
break;
|
break;
|
||||||
case "chat":
|
|
||||||
component = <AsideChatPanel />;
|
|
||||||
title = "AI Chat";
|
|
||||||
break;
|
|
||||||
case "details":
|
case "details":
|
||||||
component = <PageDetailsAside />;
|
component = <PageDetailsAside />;
|
||||||
title = "Details";
|
title = "Details";
|
||||||
@@ -52,23 +47,21 @@ export default function Aside() {
|
|||||||
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||||
{component && (
|
{component && (
|
||||||
<>
|
<>
|
||||||
{tab !== "chat" && (
|
<Group justify="space-between" wrap="nowrap" mb="md">
|
||||||
<Group justify="space-between" wrap="nowrap" mb="md">
|
<Title order={2} size="h6" fw={500}>{t(title)}</Title>
|
||||||
<Title order={2} size="h6" fw={500}>{t(title)}</Title>
|
<Tooltip label={t("Close")} withArrow>
|
||||||
<Tooltip label={t("Close")} withArrow>
|
<ActionIcon
|
||||||
<ActionIcon
|
variant="subtle"
|
||||||
variant="subtle"
|
color="gray"
|
||||||
color="gray"
|
onClick={closeAside}
|
||||||
onClick={closeAside}
|
aria-label={t("Close")}
|
||||||
aria-label={t("Close")}
|
>
|
||||||
>
|
<IconX size={18} />
|
||||||
<IconX size={18} />
|
</ActionIcon>
|
||||||
</ActionIcon>
|
</Tooltip>
|
||||||
</Tooltip>
|
</Group>
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "comments" || tab === "chat" ? (
|
{tab === "comments" ? (
|
||||||
component
|
component
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
|
|||||||
@@ -11,11 +11,9 @@ 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";
|
||||||
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
|
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
||||||
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
||||||
@@ -27,7 +25,6 @@ export default function GlobalAppShell({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
useTrialEndAction();
|
|
||||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||||
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
||||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||||
@@ -78,9 +75,8 @@ 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 isPageRoute = location.pathname.includes("/p/");
|
const isPageRoute = location.pathname.includes("/p/");
|
||||||
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute;
|
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -116,9 +112,7 @@ export default function GlobalAppShell({
|
|||||||
? t("Space navigation")
|
? t("Space navigation")
|
||||||
: isSettingsRoute
|
: isSettingsRoute
|
||||||
? t("Settings navigation")
|
? t("Settings navigation")
|
||||||
: isAiRoute
|
: t("Main navigation")
|
||||||
? t("AI navigation")
|
|
||||||
: t("Main navigation")
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isSpaceRoute && (
|
{isSpaceRoute && (
|
||||||
@@ -126,7 +120,6 @@ export default function GlobalAppShell({
|
|||||||
)}
|
)}
|
||||||
{isSpaceRoute && <SpaceSidebar />}
|
{isSpaceRoute && <SpaceSidebar />}
|
||||||
{isSettingsRoute && <SettingsSidebar />}
|
{isSettingsRoute && <SettingsSidebar />}
|
||||||
{isAiRoute && <AiChatSidebar />}
|
|
||||||
{showGlobalSidebar && <GlobalSidebar />}
|
{showGlobalSidebar && <GlobalSidebar />}
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
<AppShell.Main id={MAIN_CONTENT_ID} tabIndex={-1}>
|
<AppShell.Main id={MAIN_CONTENT_ID} tabIndex={-1}>
|
||||||
@@ -151,11 +144,9 @@ export default function GlobalAppShell({
|
|||||||
? t("Comments")
|
? t("Comments")
|
||||||
: asideTab === "toc"
|
: asideTab === "toc"
|
||||||
? t("Table of contents")
|
? t("Table of contents")
|
||||||
: asideTab === "chat"
|
: asideTab === "details"
|
||||||
? t("AI Chat")
|
? t("Details")
|
||||||
: asideTab === "details"
|
: undefined
|
||||||
? t("Details")
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Aside />
|
<Aside />
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ScrollArea, Text, Divider, Modal, UnstyledButton, Tooltip } from "@mantine/core";
|
import { ScrollArea, Text, Divider, Modal, UnstyledButton } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconHome,
|
IconHome,
|
||||||
IconClock,
|
|
||||||
IconStar,
|
IconStar,
|
||||||
IconLayoutGrid,
|
IconLayoutGrid,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconUserPlus,
|
IconUserPlus,
|
||||||
IconTemplate,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import classes from "./global-sidebar.module.css";
|
import classes from "./global-sidebar.module.css";
|
||||||
@@ -21,9 +19,6 @@ import { useDisclosure } from "@mantine/hooks";
|
|||||||
import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form";
|
import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
|
||||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
|
||||||
import { Feature } from "@/ee/features";
|
|
||||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
|
||||||
|
|
||||||
export default function GlobalSidebar() {
|
export default function GlobalSidebar() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -31,18 +26,10 @@ export default function GlobalSidebar() {
|
|||||||
const [active, setActive] = useState(location.pathname);
|
const [active, setActive] = useState(location.pathname);
|
||||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||||
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
||||||
const hasTemplates = useHasFeature(Feature.TEMPLATES);
|
|
||||||
const upgradeLabel = useUpgradeLabel();
|
|
||||||
const mainNavItems = [
|
const mainNavItems = [
|
||||||
{ label: "Home", icon: IconHome, path: "/home" },
|
{ label: "Home", icon: IconHome, path: "/home" },
|
||||||
{ label: "Favorites", icon: IconStar, path: "/favorites" },
|
{ label: "Favorites", icon: IconStar, path: "/favorites" },
|
||||||
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
|
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
|
||||||
{
|
|
||||||
label: "Templates",
|
|
||||||
icon: IconTemplate,
|
|
||||||
path: "/templates",
|
|
||||||
disabled: !hasTemplates,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
const { data: favoriteSpacesData, isPending: isFavoritesPending } = useFavoritesQuery("space");
|
const { data: favoriteSpacesData, isPending: isFavoritesPending } = useFavoritesQuery("space");
|
||||||
const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? [];
|
const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? [];
|
||||||
@@ -69,38 +56,19 @@ export default function GlobalSidebar() {
|
|||||||
<div className={classes.navbar}>
|
<div className={classes.navbar}>
|
||||||
<ScrollArea w="100%" style={{ flex: 1 }}>
|
<ScrollArea w="100%" style={{ flex: 1 }}>
|
||||||
<div className={classes.section}>
|
<div className={classes.section}>
|
||||||
{mainNavItems.map((item) =>
|
{mainNavItems.map((item) => (
|
||||||
item.disabled ? (
|
<Link
|
||||||
<Tooltip
|
key={item.label}
|
||||||
key={item.label}
|
className={classes.link}
|
||||||
label={upgradeLabel}
|
data-active={active === item.path || undefined}
|
||||||
position="right"
|
aria-current={active === item.path ? "page" : undefined}
|
||||||
withArrow
|
to={item.path}
|
||||||
>
|
onClick={handleNavClick}
|
||||||
<UnstyledButton
|
>
|
||||||
className={classes.link}
|
<item.icon className={classes.linkIcon} stroke={2} />
|
||||||
data-disabled
|
<span>{t(item.label)}</span>
|
||||||
aria-disabled="true"
|
</Link>
|
||||||
tabIndex={-1}
|
))}
|
||||||
>
|
|
||||||
<item.icon className={classes.linkIcon} stroke={2} />
|
|
||||||
<span>{t(item.label)}</span>
|
|
||||||
</UnstyledButton>
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
key={item.label}
|
|
||||||
className={classes.link}
|
|
||||||
data-active={active === item.path || undefined}
|
|
||||||
aria-current={active === item.path ? "page" : undefined}
|
|
||||||
to={item.path}
|
|
||||||
onClick={handleNavClick}
|
|
||||||
>
|
|
||||||
<item.icon className={classes.linkIcon} stroke={2} />
|
|
||||||
<span>{t(item.label)}</span>
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider my="xs" />
|
<Divider my="xs" />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const desktopSidebarAtom = atomWithWebStorage<boolean>(
|
|||||||
|
|
||||||
export const desktopAsideAtom = atom<boolean>(false);
|
export const desktopAsideAtom = atom<boolean>(false);
|
||||||
|
|
||||||
// Valid `tab` values: "" | "comments" | "toc" | "chat" | "details"
|
// Valid `tab` values: "" | "comments" | "toc" | "details"
|
||||||
type AsideStateType = {
|
type AsideStateType = {
|
||||||
tab: string;
|
tab: string;
|
||||||
isAsideOpen: boolean;
|
isAsideOpen: boolean;
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||||
import { Outlet, useParams } from "react-router-dom";
|
import { Outlet, useParams } from "react-router-dom";
|
||||||
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
||||||
import { PosthogUser } from "@/ee/components/posthog-user.tsx";
|
|
||||||
import { isCloud } from "@/lib/config.ts";
|
|
||||||
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
|
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
|
||||||
import React from "react";
|
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
@@ -16,7 +13,6 @@ export default function Layout() {
|
|||||||
<GlobalAppShell>
|
<GlobalAppShell>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</GlobalAppShell>
|
</GlobalAppShell>
|
||||||
{isCloud() && <PosthogUser />}
|
|
||||||
<SearchSpotlight spaceId={space?.id} />
|
<SearchSpotlight spaceId={space?.id} />
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,9 @@
|
|||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import {
|
|
||||||
getBilling,
|
|
||||||
getBillingPlans,
|
|
||||||
} from "@/ee/billing/services/billing-service.ts";
|
|
||||||
import { getSpaces } from "@/features/space/services/space-service.ts";
|
import { getSpaces } from "@/features/space/services/space-service.ts";
|
||||||
import { getGroups } from "@/features/group/services/group-service.ts";
|
import { getGroups } from "@/features/group/services/group-service.ts";
|
||||||
import { QueryParams } from "@/lib/types.ts";
|
import { QueryParams } from "@/lib/types.ts";
|
||||||
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts";
|
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts";
|
||||||
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
|
|
||||||
import { getSsoProviders } from "@/ee/security/services/security-service.ts";
|
|
||||||
import { getShares } from "@/features/share/services/share-service.ts";
|
import { getShares } from "@/features/share/services/share-service.ts";
|
||||||
import { getApiKeys } from "@/ee/api-key";
|
|
||||||
import { getAuditLogs } from "@/ee/audit/services/audit-service";
|
|
||||||
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
|
|
||||||
import { getScimTokens } from "@/ee/scim/services/scim-token-service";
|
|
||||||
|
|
||||||
export const prefetchWorkspaceMembers = () => {
|
export const prefetchWorkspaceMembers = () => {
|
||||||
const params: QueryParams = { limit: 100, query: "" };
|
const params: QueryParams = { limit: 100, query: "" };
|
||||||
@@ -37,72 +27,9 @@ export const prefetchGroups = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const prefetchBilling = () => {
|
|
||||||
queryClient.prefetchQuery({
|
|
||||||
queryKey: ["billing"],
|
|
||||||
queryFn: () => getBilling(),
|
|
||||||
});
|
|
||||||
|
|
||||||
queryClient.prefetchQuery({
|
|
||||||
queryKey: ["billing-plans"],
|
|
||||||
queryFn: () => getBillingPlans(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const prefetchLicense = () => {
|
|
||||||
queryClient.prefetchQuery({
|
|
||||||
queryKey: ["license"],
|
|
||||||
queryFn: () => getLicenseInfo(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const prefetchSsoProviders = () => {
|
|
||||||
queryClient.prefetchQuery({
|
|
||||||
queryKey: ["sso-providers"],
|
|
||||||
queryFn: () => getSsoProviders(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const prefetchShares = () => {
|
export const prefetchShares = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["share-list", {}],
|
queryKey: ["share-list", {}],
|
||||||
queryFn: () => getShares({}),
|
queryFn: () => getShares({}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const prefetchApiKeys = () => {
|
|
||||||
queryClient.prefetchQuery({
|
|
||||||
queryKey: ["api-key-list", {}],
|
|
||||||
queryFn: () => getApiKeys({}),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const prefetchApiKeyManagement = () => {
|
|
||||||
queryClient.prefetchQuery({
|
|
||||||
queryKey: ["api-key-list", { adminView: true }],
|
|
||||||
queryFn: () => getApiKeys({ adminView: true }),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const prefetchAuditLogs = () => {
|
|
||||||
const params = { limit: 50 };
|
|
||||||
queryClient.prefetchQuery({
|
|
||||||
queryKey: ["audit-logs", params],
|
|
||||||
queryFn: () => getAuditLogs(params),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const prefetchVerifiedPages = () => {
|
|
||||||
const params = { limit: 50 };
|
|
||||||
queryClient.prefetchQuery({
|
|
||||||
queryKey: ["verification-list", params],
|
|
||||||
queryFn: () => getVerificationList(params),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const prefetchScimTokens = () => {
|
|
||||||
queryClient.prefetchQuery({
|
|
||||||
queryKey: ["scim-token-list", { cursor: undefined }],
|
|
||||||
queryFn: () => getScimTokens({}),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Group, Text, ScrollArea, ActionIcon, Tooltip } from "@mantine/core";
|
import { Group, Text, ScrollArea, ActionIcon } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconUser,
|
IconUser,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
@@ -8,49 +8,27 @@ import {
|
|||||||
IconUsersGroup,
|
IconUsersGroup,
|
||||||
IconSpaces,
|
IconSpaces,
|
||||||
IconBrush,
|
IconBrush,
|
||||||
IconCoin,
|
|
||||||
IconLock,
|
|
||||||
IconKey,
|
|
||||||
IconWorld,
|
IconWorld,
|
||||||
IconSparkles,
|
|
||||||
IconHistory,
|
|
||||||
IconShieldCheck,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import classes from "./settings.module.css";
|
import classes from "./settings.module.css";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
|
|
||||||
import { Feature } from "@/ee/features";
|
|
||||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
|
||||||
import {
|
import {
|
||||||
prefetchApiKeyManagement,
|
|
||||||
prefetchApiKeys,
|
|
||||||
prefetchBilling,
|
|
||||||
prefetchGroups,
|
prefetchGroups,
|
||||||
prefetchLicense,
|
|
||||||
prefetchScimTokens,
|
|
||||||
prefetchShares,
|
prefetchShares,
|
||||||
prefetchSpaces,
|
prefetchSpaces,
|
||||||
prefetchSsoProviders,
|
|
||||||
prefetchWorkspaceMembers,
|
prefetchWorkspaceMembers,
|
||||||
prefetchAuditLogs,
|
|
||||||
prefetchVerifiedPages,
|
|
||||||
} from "@/components/settings/settings-queries.tsx";
|
} from "@/components/settings/settings-queries.tsx";
|
||||||
import AppVersion from "@/components/settings/app-version.tsx";
|
import AppVersion from "@/components/settings/app-version.tsx";
|
||||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
|
import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
type DataItem = {
|
type DataItem = {
|
||||||
label: string;
|
label: string;
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
path: string;
|
path: string;
|
||||||
feature?: string;
|
|
||||||
role?: "admin" | "owner";
|
|
||||||
env?: "cloud" | "selfhosted";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type DataGroup = {
|
type DataGroup = {
|
||||||
@@ -68,12 +46,6 @@ const groupedData: DataGroup[] = [
|
|||||||
icon: IconBrush,
|
icon: IconBrush,
|
||||||
path: "/settings/account/preferences",
|
path: "/settings/account/preferences",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "API keys",
|
|
||||||
icon: IconKey,
|
|
||||||
path: "/settings/account/api-keys",
|
|
||||||
feature: Feature.API_KEYS,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -81,60 +53,9 @@ const groupedData: DataGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: "General", icon: IconSettings, path: "/settings/workspace" },
|
{ label: "General", icon: IconSettings, path: "/settings/workspace" },
|
||||||
{ label: "Members", icon: IconUsers, path: "/settings/members" },
|
{ label: "Members", icon: IconUsers, path: "/settings/members" },
|
||||||
{
|
|
||||||
label: "Billing",
|
|
||||||
icon: IconCoin,
|
|
||||||
path: "/settings/billing",
|
|
||||||
role: "admin",
|
|
||||||
env: "cloud",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Security & SSO",
|
|
||||||
icon: IconLock,
|
|
||||||
path: "/settings/security",
|
|
||||||
feature: Feature.SECURITY_SETTINGS,
|
|
||||||
role: "admin",
|
|
||||||
},
|
|
||||||
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
||||||
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
|
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
|
||||||
{
|
|
||||||
label: "Verified pages",
|
|
||||||
icon: IconShieldCheck,
|
|
||||||
path: "/settings/verifications",
|
|
||||||
feature: Feature.PAGE_VERIFICATION,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "API management",
|
|
||||||
icon: IconKey,
|
|
||||||
path: "/settings/api-keys",
|
|
||||||
feature: Feature.API_KEYS,
|
|
||||||
role: "admin",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "AI settings",
|
|
||||||
icon: IconSparkles,
|
|
||||||
path: "/settings/ai",
|
|
||||||
role: "admin",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Audit log",
|
|
||||||
icon: IconHistory,
|
|
||||||
path: "/settings/audit",
|
|
||||||
feature: Feature.AUDIT_LOGS,
|
|
||||||
role: "owner",
|
|
||||||
env: "selfhosted",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: "System",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
label: "License & Edition",
|
|
||||||
icon: IconKey,
|
|
||||||
path: "/settings/license",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -144,9 +65,6 @@ export default function SettingsSidebar() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [active, setActive] = useState(location.pathname);
|
const [active, setActive] = useState(location.pathname);
|
||||||
const { goBack } = useSettingsNavigation();
|
const { goBack } = useSettingsNavigation();
|
||||||
const { isAdmin, isOwner } = useUserRole();
|
|
||||||
const [entitlements] = useAtom(entitlementAtom);
|
|
||||||
const upgradeLabel = useUpgradeLabel();
|
|
||||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||||
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
||||||
|
|
||||||
@@ -154,37 +72,13 @@ export default function SettingsSidebar() {
|
|||||||
setActive(location.pathname);
|
setActive(location.pathname);
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
const hasFeature = (f: string) =>
|
|
||||||
entitlements?.features?.includes(f) ?? false;
|
|
||||||
|
|
||||||
const canShowItem = (item: DataItem) => {
|
|
||||||
if (item.env === "cloud" && !isCloud()) return false;
|
|
||||||
if (item.env === "selfhosted" && isCloud()) return false;
|
|
||||||
if (item.role === "admin" && !isAdmin) return false;
|
|
||||||
if (item.role === "owner" && !isOwner) return false;
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isItemDisabled = (item: DataItem) => {
|
|
||||||
if (!item.feature) return false;
|
|
||||||
return !hasFeature(item.feature);
|
|
||||||
};
|
|
||||||
|
|
||||||
const menuItems = groupedData.map((group) => {
|
const menuItems = groupedData.map((group) => {
|
||||||
if (group.heading === "System" && (!isAdmin || isCloud())) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={group.heading}>
|
<div key={group.heading}>
|
||||||
<Text c="dimmed" className={classes.linkHeader}>
|
<Text c="dimmed" className={classes.linkHeader}>
|
||||||
{t(group.heading)}
|
{t(group.heading)}
|
||||||
</Text>
|
</Text>
|
||||||
{group.items.map((item) => {
|
{group.items.map((item) => {
|
||||||
if (!canShowItem(item)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let prefetchHandler: any;
|
let prefetchHandler: any;
|
||||||
switch (item.label) {
|
switch (item.label) {
|
||||||
case "Members":
|
case "Members":
|
||||||
@@ -196,67 +90,13 @@ export default function SettingsSidebar() {
|
|||||||
case "Groups":
|
case "Groups":
|
||||||
prefetchHandler = prefetchGroups;
|
prefetchHandler = prefetchGroups;
|
||||||
break;
|
break;
|
||||||
case "Billing":
|
|
||||||
prefetchHandler = prefetchBilling;
|
|
||||||
break;
|
|
||||||
case "License & Edition":
|
|
||||||
if (entitlements?.tier !== "free") {
|
|
||||||
prefetchHandler = prefetchLicense;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "Security & SSO":
|
|
||||||
prefetchHandler = () => {
|
|
||||||
prefetchSsoProviders();
|
|
||||||
prefetchScimTokens();
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "Public sharing":
|
case "Public sharing":
|
||||||
prefetchHandler = prefetchShares;
|
prefetchHandler = prefetchShares;
|
||||||
break;
|
break;
|
||||||
case "API keys":
|
|
||||||
prefetchHandler = prefetchApiKeys;
|
|
||||||
break;
|
|
||||||
case "API management":
|
|
||||||
prefetchHandler = prefetchApiKeyManagement;
|
|
||||||
break;
|
|
||||||
case "Audit log":
|
|
||||||
prefetchHandler = prefetchAuditLogs;
|
|
||||||
break;
|
|
||||||
case "Verified pages":
|
|
||||||
prefetchHandler = prefetchVerifiedPages;
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDisabled = isItemDisabled(item);
|
|
||||||
|
|
||||||
if (isDisabled) {
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
key={item.label}
|
|
||||||
label={upgradeLabel}
|
|
||||||
position="right"
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={classes.link}
|
|
||||||
data-disabled
|
|
||||||
role="link"
|
|
||||||
aria-disabled="true"
|
|
||||||
tabIndex={0}
|
|
||||||
style={{
|
|
||||||
opacity: 0.5,
|
|
||||||
cursor: "not-allowed",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<item.icon className={classes.linkIcon} stroke={2} />
|
|
||||||
<span>{t(item.label)}</span>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
onMouseEnter={prefetchHandler}
|
onMouseEnter={prefetchHandler}
|
||||||
@@ -300,20 +140,7 @@ export default function SettingsSidebar() {
|
|||||||
|
|
||||||
<ScrollArea w="100%">{menuItems}</ScrollArea>
|
<ScrollArea w="100%">{menuItems}</ScrollArea>
|
||||||
|
|
||||||
{!isCloud() && <AppVersion />}
|
<AppVersion />
|
||||||
|
|
||||||
{isCloud() && (
|
|
||||||
<div className={classes.text}>
|
|
||||||
<Text
|
|
||||||
size="sm"
|
|
||||||
c="dimmed"
|
|
||||||
component="a"
|
|
||||||
href="mailto:help@docmost.com"
|
|
||||||
>
|
|
||||||
help@docmost.com
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
Files in this directory are subject to the Docmost Enterprise Edition license.
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { useLocation, useNavigate, 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 type { HomeAiPromptInitialState } from "@/features/home/components/home-ai-prompt";
|
|
||||||
import classes from "../styles/ai-chat.module.css";
|
|
||||||
|
|
||||||
export default function AiChatLayout() {
|
|
||||||
const { chatId } = useParams<{ chatId: string }>();
|
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const chatInfoQuery = useChatInfoQuery(chatId);
|
|
||||||
|
|
||||||
// If the URL points at a chat the user does not own, the info fetch 404s.
|
|
||||||
// Bounce them back to /ai so they cannot interact with any chat UI (including
|
|
||||||
// kicking off orphan uploads) tied to a chat they have no access to.
|
|
||||||
useEffect(() => {
|
|
||||||
if (chatId && chatInfoQuery.isError) {
|
|
||||||
navigate("/ai", { replace: true });
|
|
||||||
}
|
|
||||||
}, [chatId, chatInfoQuery.isError, navigate]);
|
|
||||||
const {
|
|
||||||
messages,
|
|
||||||
streamingContent,
|
|
||||||
streamingToolCalls,
|
|
||||||
isStreaming,
|
|
||||||
error,
|
|
||||||
sendMessage,
|
|
||||||
stopGeneration,
|
|
||||||
hydrateFromServer,
|
|
||||||
} = useChatStream(chatId);
|
|
||||||
|
|
||||||
const autoSentRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (chatInfoQuery.data?.messages) {
|
|
||||||
hydrateFromServer(chatInfoQuery.data.messages);
|
|
||||||
}
|
|
||||||
}, [chatInfoQuery.data, hydrateFromServer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoSentRef.current || chatId) return;
|
|
||||||
const state = location.state as HomeAiPromptInitialState | null;
|
|
||||||
if (!state?.initialContent && !state?.initialAttachments?.length) return;
|
|
||||||
|
|
||||||
autoSentRef.current = true;
|
|
||||||
sendMessage(
|
|
||||||
state.initialContent ?? "",
|
|
||||||
state.initialMentions ?? [],
|
|
||||||
state.initialAttachments ?? [],
|
|
||||||
);
|
|
||||||
navigate(location.pathname, { replace: true, state: null });
|
|
||||||
}, [chatId, location, navigate, sendMessage]);
|
|
||||||
|
|
||||||
const hasMessages = messages.length > 0 || isStreaming || !!chatId;
|
|
||||||
|
|
||||||
// While the redirect effect is running (or if the user is still on this
|
|
||||||
// component for any reason) never render the chat UI for a forbidden chat.
|
|
||||||
if (chatId && chatInfoQuery.isError) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classes.main}>
|
|
||||||
{hasMessages ? (
|
|
||||||
<>
|
|
||||||
<ChatMessageList
|
|
||||||
messages={messages}
|
|
||||||
isStreaming={isStreaming}
|
|
||||||
streamingContent={streamingContent}
|
|
||||||
streamingToolCalls={streamingToolCalls}
|
|
||||||
/>
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "var(--mantine-spacing-sm) 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}
|
|
||||||
chatId={chatId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<ChatEmptyState
|
|
||||||
isStreaming={isStreaming}
|
|
||||||
onSend={sendMessage}
|
|
||||||
onStop={stopGeneration}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
|
|
||||||
import { ActionIcon, Menu, TextInput } from "@mantine/core";
|
|
||||||
import { IconDots, IconTrash, IconEdit } from "@tabler/icons-react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
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, title: string | null) => void;
|
|
||||||
onRename: (chatId: string, title: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatChatDate(
|
|
||||||
isoString: string | Date,
|
|
||||||
locale: string | undefined,
|
|
||||||
): string {
|
|
||||||
const date = new Date(isoString);
|
|
||||||
if (Number.isNaN(date.getTime())) return "";
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const startOfToday = new Date(
|
|
||||||
now.getFullYear(),
|
|
||||||
now.getMonth(),
|
|
||||||
now.getDate(),
|
|
||||||
).getTime();
|
|
||||||
const ts = date.getTime();
|
|
||||||
const sameYear = date.getFullYear() === now.getFullYear();
|
|
||||||
|
|
||||||
if (ts >= startOfToday) {
|
|
||||||
return date.toLocaleTimeString(locale, {
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sameYear) {
|
|
||||||
return date.toLocaleDateString(locale, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return date.toLocaleDateString(locale, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AiChatSidebarItem({
|
|
||||||
chat,
|
|
||||||
isActive,
|
|
||||||
onDelete,
|
|
||||||
onRename,
|
|
||||||
}: Props) {
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
const [renaming, setRenaming] = useState(false);
|
|
||||||
const [renameValue, setRenameValue] = useState("");
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const formattedDate = useMemo(
|
|
||||||
() => formatChatDate(chat.updatedAt, i18n.language),
|
|
||||||
[chat.updatedAt, i18n.language],
|
|
||||||
);
|
|
||||||
|
|
||||||
const chatTitle = chat.title || t("Untitled chat");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (renaming) {
|
|
||||||
// Wait for the input to be mounted before selecting.
|
|
||||||
const id = window.setTimeout(() => inputRef.current?.select(), 0);
|
|
||||||
return () => window.clearTimeout(id);
|
|
||||||
}
|
|
||||||
}, [renaming]);
|
|
||||||
|
|
||||||
const startRename = useCallback(() => {
|
|
||||||
setRenameValue(chat.title || "");
|
|
||||||
setRenaming(true);
|
|
||||||
}, [chat.title]);
|
|
||||||
|
|
||||||
const submitRename = useCallback(() => {
|
|
||||||
const trimmed = renameValue.trim();
|
|
||||||
if (trimmed && trimmed !== chat.title) {
|
|
||||||
onRename(chat.id, trimmed);
|
|
||||||
}
|
|
||||||
setRenaming(false);
|
|
||||||
}, [renameValue, chat.id, chat.title, onRename]);
|
|
||||||
|
|
||||||
if (renaming) {
|
|
||||||
return (
|
|
||||||
<div className={classes.chatItem} data-active={isActive || undefined}>
|
|
||||||
<TextInput
|
|
||||||
ref={inputRef}
|
|
||||||
size="xs"
|
|
||||||
variant="unstyled"
|
|
||||||
placeholder={t("Chat name")}
|
|
||||||
value={renameValue}
|
|
||||||
onChange={(e) => setRenameValue(e.currentTarget.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
submitRename();
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
setRenaming(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={submitRename}
|
|
||||||
classNames={{ input: classes.chatItemRenameInput }}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={`/ai/chat/${chat.id}`}
|
|
||||||
className={classes.chatItem}
|
|
||||||
data-active={isActive || undefined}
|
|
||||||
>
|
|
||||||
<span className={classes.chatItemTitle}>{chatTitle}</span>
|
|
||||||
<span className={classes.chatItemDate}>{formattedDate}</span>
|
|
||||||
<div className={classes.chatItemActions}>
|
|
||||||
<Menu position="bottom-end" withinPortal>
|
|
||||||
<Menu.Target>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
color="gray"
|
|
||||||
onClick={(e) => e.preventDefault()}
|
|
||||||
aria-label={t("Chat menu for {{title}}", { title: chatTitle })}
|
|
||||||
>
|
|
||||||
<IconDots size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconEdit size={14} />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
startRename();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("Rename")}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconTrash size={14} />}
|
|
||||||
color="red"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onDelete(chat.id, chat.title);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("Delete")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Center,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Loader,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { modals } from "@mantine/modals";
|
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
|
||||||
import { IconPlus, IconSearch, IconMessageCircle2 } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
useChatsQuery,
|
|
||||||
useDeleteChatMutation,
|
|
||||||
useUpdateChatTitleMutation,
|
|
||||||
useSearchChatsQuery,
|
|
||||||
} from "../queries/ai-chat-query";
|
|
||||||
import AiChatSidebarItem from "./ai-chat-sidebar-item";
|
|
||||||
import { groupChatsByAge } from "../utils/group-chats-by-age";
|
|
||||||
import classes from "../styles/chat-sidebar.module.css";
|
|
||||||
|
|
||||||
export default function AiChatSidebar() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
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 groupedChats = useMemo(() => groupChatsByAge(chats, t), [chats, t]);
|
|
||||||
|
|
||||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
|
||||||
const { hasNextPage, fetchNextPage, isFetchingNextPage } = chatsQuery;
|
|
||||||
const isSearching = Boolean(debouncedSearch);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isSearching) return;
|
|
||||||
const sentinel = sentinelRef.current;
|
|
||||||
if (!sentinel) return;
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
|
||||||
fetchNextPage();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold: 0.1 },
|
|
||||||
);
|
|
||||||
|
|
||||||
observer.observe(sentinel);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [isSearching, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
|
||||||
|
|
||||||
const handleNewChat = useCallback(
|
|
||||||
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
|
||||||
if (
|
|
||||||
event.button !== 0 ||
|
|
||||||
event.ctrlKey ||
|
|
||||||
event.metaKey ||
|
|
||||||
event.shiftKey
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
navigate("/ai");
|
|
||||||
},
|
|
||||||
[navigate],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
|
||||||
(id: string, title: string | null) => {
|
|
||||||
modals.openConfirmModal({
|
|
||||||
title: t("Delete chat"),
|
|
||||||
centered: true,
|
|
||||||
children: (
|
|
||||||
<Text size="sm">
|
|
||||||
{t("Are you sure you want to delete '{{title}}'? This action cannot be undone.", {
|
|
||||||
title: title || t("Untitled"),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
|
||||||
confirmProps: { color: "red" },
|
|
||||||
onConfirm: () => {
|
|
||||||
deleteMutation.mutate(id, {
|
|
||||||
onSuccess: () => {
|
|
||||||
if (chatId === id) {
|
|
||||||
navigate("/ai");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[deleteMutation, chatId, navigate, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
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}>
|
|
||||||
<h2 className={classes.title}>{t("AI Chat")}</h2>
|
|
||||||
<Tooltip label={t("New chat")} openDelay={250} withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
component={Link}
|
|
||||||
to="/ai"
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={handleNewChat}
|
|
||||||
aria-label={t("New chat")}
|
|
||||||
>
|
|
||||||
<IconPlus size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
className={classes.searchInput}
|
|
||||||
placeholder={t("Search chats...")}
|
|
||||||
aria-label={t("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" />}
|
|
||||||
{!isLoading && chats.length === 0 && (
|
|
||||||
<div className={classes.chatListEmpty}>
|
|
||||||
<IconMessageCircle2
|
|
||||||
size={28}
|
|
||||||
stroke={1.5}
|
|
||||||
className={classes.chatListEmptyIcon}
|
|
||||||
/>
|
|
||||||
<div className={classes.chatListEmptyTitle}>
|
|
||||||
{isSearching ? t("No chats found") : t("No conversations yet")}
|
|
||||||
</div>
|
|
||||||
<div className={classes.chatListEmptyHint}>
|
|
||||||
{isSearching
|
|
||||||
? t("Try a different search term.")
|
|
||||||
: t("Start a new chat to see it here.")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isSearching
|
|
||||||
? chats.map((chat) => (
|
|
||||||
<AiChatSidebarItem
|
|
||||||
key={chat.id}
|
|
||||||
chat={chat}
|
|
||||||
isActive={chat.id === chatId}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onRename={handleRename}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
: groupedChats.map((group) => (
|
|
||||||
<div key={group.key} className={classes.chatGroup}>
|
|
||||||
<h3 className={classes.chatGroupLabel}>{group.label}</h3>
|
|
||||||
{group.chats.map((chat) => (
|
|
||||||
<AiChatSidebarItem
|
|
||||||
key={chat.id}
|
|
||||||
chat={chat}
|
|
||||||
isActive={chat.id === chatId}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onRename={handleRename}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!isSearching && (
|
|
||||||
<>
|
|
||||||
<div ref={sentinelRef} style={{ height: 1 }} />
|
|
||||||
{isFetchingNextPage && (
|
|
||||||
<Center py="xs">
|
|
||||||
<Loader size="xs" />
|
|
||||||
</Center>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { TextInput, Loader, Text, ScrollArea } from "@mantine/core";
|
|
||||||
import { IconSearch } from "@tabler/icons-react";
|
|
||||||
import { useChatsQuery, useSearchChatsQuery } from "../queries/ai-chat-query";
|
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import classes from "../styles/aside-chat-panel.module.css";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
activeChatId: string | undefined;
|
|
||||||
onSelect: (chatId: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AsideChatHistory({ activeChatId, onSelect }: Props) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [searchValue, setSearchValue] = useState("");
|
|
||||||
const [debouncedSearch] = useDebouncedValue(searchValue, 300);
|
|
||||||
|
|
||||||
const chatsQuery = useChatsQuery();
|
|
||||||
const searchQuery = useSearchChatsQuery(debouncedSearch);
|
|
||||||
|
|
||||||
const isSearching = debouncedSearch.length > 0;
|
|
||||||
const chats = isSearching
|
|
||||||
? (searchQuery.data ?? [])
|
|
||||||
: (chatsQuery.data?.pages.flatMap((p) => p.items) ?? []);
|
|
||||||
const isLoading = isSearching ? searchQuery.isLoading : chatsQuery.isLoading;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<TextInput
|
|
||||||
placeholder={t("Search chats...")}
|
|
||||||
leftSection={<IconSearch size={14} />}
|
|
||||||
size="xs"
|
|
||||||
mb="xs"
|
|
||||||
value={searchValue}
|
|
||||||
onChange={(e) => setSearchValue(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div style={{ display: "flex", justifyContent: "center", padding: 16 }}>
|
|
||||||
<Loader size="sm" />
|
|
||||||
</div>
|
|
||||||
) : chats.length === 0 ? (
|
|
||||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
|
||||||
{isSearching ? t("No chats found") : t("No chat history")}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<ScrollArea.Autosize mah={300} scrollbars="y">
|
|
||||||
<div className={classes.historyList}>
|
|
||||||
{chats.map((chat) => (
|
|
||||||
<div
|
|
||||||
key={chat.id}
|
|
||||||
className={classes.historyItem}
|
|
||||||
data-active={chat.id === activeChatId || undefined}
|
|
||||||
onClick={() => onSelect(chat.id)}
|
|
||||||
>
|
|
||||||
<span className={classes.historyItemTitle}>
|
|
||||||
{chat.title || t("Untitled chat")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea.Autosize>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
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"
|
|
||||||
aria-label={t("New chat")}
|
|
||||||
onClick={handleNewChat}
|
|
||||||
>
|
|
||||||
<IconPlus size={20} stroke={1.75} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip label={t("Open full page")} openDelay={250}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="dark"
|
|
||||||
aria-label={t("Open full page")}
|
|
||||||
onClick={handleExpand}
|
|
||||||
>
|
|
||||||
<IconArrowsDiagonal size={18} stroke={1.5} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip label={t("Close")} openDelay={250}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="dark"
|
|
||||||
aria-label={t("Close")}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import {
|
|
||||||
IconSparkles,
|
|
||||||
IconSearch,
|
|
||||||
IconFilePlus,
|
|
||||||
IconEdit,
|
|
||||||
IconFileText,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
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 { t } = useTranslation();
|
|
||||||
|
|
||||||
const handleSuggestionClick = (prompt: string) => {
|
|
||||||
onSend(prompt, [], []);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classes.emptyState}>
|
|
||||||
<IconSparkles size={48} stroke={1.5} className={classes.emptyStateIcon} />
|
|
||||||
<div className={classes.emptyStateBrand}>{t("Docmost AI")}</div>
|
|
||||||
<h1 className={classes.emptyStateTitle}>
|
|
||||||
{t("What can I help you with?")}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className={classes.emptyStateInput}>
|
|
||||||
<ChatInput
|
|
||||||
isStreaming={isStreaming}
|
|
||||||
onSend={onSend}
|
|
||||||
onStop={onStop}
|
|
||||||
placeholder={t("Ask anything... Use @ to mention pages")}
|
|
||||||
autofocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={classes.suggestionsSection}>
|
|
||||||
<h2 className={classes.suggestionsLabel}>{t("Get started")}</h2>
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,431 +0,0 @@
|
|||||||
import { useCallback, useId, useRef, useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react";
|
|
||||||
import { Popover } from "@mantine/core";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
|
|
||||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
|
||||||
import { CharacterCount } from "@tiptap/extensions";
|
|
||||||
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";
|
|
||||||
// Kept in sync with MAX_ATTACHMENTS_PER_MESSAGE in apps/server/src/ee/ai-chat/ai-chat-limits.ts
|
|
||||||
const MAX_ATTACHMENTS_PER_MESSAGE = 5;
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
isStreaming: boolean;
|
|
||||||
onSend: (content: string, mentions: PageMention[], attachments: ChatAttachment[]) => void;
|
|
||||||
onStop: () => void;
|
|
||||||
placeholder?: string;
|
|
||||||
autofocus?: boolean;
|
|
||||||
contextPages?: PageMention[];
|
|
||||||
onRemoveContextPage?: (pageId: string) => void;
|
|
||||||
variant?: "card" | "flat";
|
|
||||||
showDisclaimer?: boolean;
|
|
||||||
chatId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
contextPages,
|
|
||||||
onRemoveContextPage,
|
|
||||||
variant = "card",
|
|
||||||
showDisclaimer = true,
|
|
||||||
chatId,
|
|
||||||
}: Props) {
|
|
||||||
const chatIdRef = useRef(chatId);
|
|
||||||
chatIdRef.current = chatId;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isEmpty, setIsEmpty] = useState(true);
|
|
||||||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
|
||||||
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
|
|
||||||
const plusMenuId = useId();
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const onSendRef = useRef(onSend);
|
|
||||||
onSendRef.current = onSend;
|
|
||||||
|
|
||||||
const handleFileSelect = useCallback(async (files: FileList | null) => {
|
|
||||||
if (!files?.length) return;
|
|
||||||
|
|
||||||
const room = MAX_ATTACHMENTS_PER_MESSAGE - pendingAttachments.length;
|
|
||||||
if (room <= 0) {
|
|
||||||
notifications.show({
|
|
||||||
color: "yellow",
|
|
||||||
message: t("You can attach up to {{max}} files per message.", {
|
|
||||||
max: MAX_ATTACHMENTS_PER_MESSAGE,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const incoming = Array.from(files);
|
|
||||||
const accepted = incoming.slice(0, room);
|
|
||||||
|
|
||||||
if (incoming.length > accepted.length) {
|
|
||||||
notifications.show({
|
|
||||||
color: "yellow",
|
|
||||||
message: t(
|
|
||||||
"Only the first {{n}} file(s) were added (max {{max}} per message).",
|
|
||||||
{ n: accepted.length, max: MAX_ATTACHMENTS_PER_MESSAGE },
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const file of accepted) {
|
|
||||||
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, chatIdRef.current);
|
|
||||||
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 = "";
|
|
||||||
}
|
|
||||||
}, [pendingAttachments.length, t]);
|
|
||||||
|
|
||||||
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 || t("Ask anything... Use @ to mention pages"),
|
|
||||||
}),
|
|
||||||
CharacterCount.configure({
|
|
||||||
limit: 50000,
|
|
||||||
}),
|
|
||||||
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: {
|
|
||||||
attributes: {
|
|
||||||
role: "textbox",
|
|
||||||
"aria-label": placeholder || t("Ask anything... Use @ to mention pages"),
|
|
||||||
"aria-multiline": "true",
|
|
||||||
},
|
|
||||||
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) || (contextPages?.length ?? 0) > 0;
|
|
||||||
|
|
||||||
const wrapperClass = variant === "flat" ? classes.inputWrapperFlat : classes.inputWrapper;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={wrapperClass} data-chat-input>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept={ACCEPTED_FILE_TYPES}
|
|
||||||
multiple
|
|
||||||
aria-label={t("Add files")}
|
|
||||||
tabIndex={-1}
|
|
||||||
style={{ display: "none" }}
|
|
||||||
onChange={(e) => handleFileSelect(e.target.files)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{((contextPages?.length ?? 0) > 0 || pendingAttachments.length > 0) && (
|
|
||||||
<div className={classes.attachmentChips}>
|
|
||||||
{contextPages?.map((page) => (
|
|
||||||
<div key={page.id} className={classes.attachmentChip}>
|
|
||||||
<IconFileText size={14} />
|
|
||||||
<span className={classes.attachmentChipName}>
|
|
||||||
{page.title || "Untitled"}
|
|
||||||
</span>
|
|
||||||
{onRemoveContextPage && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={classes.attachmentChipRemove}
|
|
||||||
onClick={() => onRemoveContextPage(page.id)}
|
|
||||||
aria-label={`Remove ${page.title}`}
|
|
||||||
>
|
|
||||||
<IconX size={12} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{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}>
|
|
||||||
<Popover
|
|
||||||
opened={plusMenuOpen}
|
|
||||||
onChange={setPlusMenuOpen}
|
|
||||||
position="top-start"
|
|
||||||
width={220}
|
|
||||||
shadow="md"
|
|
||||||
withRoles={false}
|
|
||||||
trapFocus
|
|
||||||
returnFocus
|
|
||||||
>
|
|
||||||
<Popover.Target>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={classes.plusButton}
|
|
||||||
onClick={() => setPlusMenuOpen((o) => !o)}
|
|
||||||
aria-label="Add content"
|
|
||||||
aria-haspopup="menu"
|
|
||||||
aria-expanded={plusMenuOpen}
|
|
||||||
aria-controls={plusMenuOpen ? plusMenuId : undefined}
|
|
||||||
>
|
|
||||||
<IconPlus size={14} />
|
|
||||||
</button>
|
|
||||||
</Popover.Target>
|
|
||||||
<Popover.Dropdown id={plusMenuId} role="menu" p={4}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
className={classes.plusMenuItem}
|
|
||||||
onClick={() => {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
setPlusMenuOpen(false);
|
|
||||||
}}
|
|
||||||
disabled={pendingAttachments.length >= MAX_ATTACHMENTS_PER_MESSAGE}
|
|
||||||
title={
|
|
||||||
pendingAttachments.length >= MAX_ATTACHMENTS_PER_MESSAGE
|
|
||||||
? t("Max {{max}} files per message", {
|
|
||||||
max: MAX_ATTACHMENTS_PER_MESSAGE,
|
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconPaperclip size={16} className={classes.plusMenuIcon} />
|
|
||||||
{t("Add files")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
className={classes.plusMenuItem}
|
|
||||||
onClick={() => {
|
|
||||||
editor?.commands.insertContent("@");
|
|
||||||
editor?.commands.focus();
|
|
||||||
setPlusMenuOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconAt size={16} className={classes.plusMenuIcon} />
|
|
||||||
{t("Mention a page")}
|
|
||||||
</button>
|
|
||||||
</Popover.Dropdown>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
{showDisclaimer && (
|
|
||||||
<div className={classes.disclaimer}>
|
|
||||||
{t("AI-generated content may not be accurate.")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from "react";
|
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
|
||||||
import { IconArrowDown, IconAlertTriangle } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { VisuallyHidden } from "@mantine/core";
|
|
||||||
import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
|
|
||||||
import ChatMessage from "./chat-message";
|
|
||||||
import classes from "../styles/ai-chat.module.css";
|
|
||||||
|
|
||||||
function ChatMessageErrorFallback() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<div className={classes.messageErrorFallback}>
|
|
||||||
<IconAlertTriangle size={14} />
|
|
||||||
<span>{t("Failed to render this message.")}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
messages: AiChatMessage[];
|
|
||||||
isStreaming: boolean;
|
|
||||||
streamingContent: string;
|
|
||||||
streamingToolCalls: AiChatToolCall[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const BOTTOM_THRESHOLD_PX = 32;
|
|
||||||
const SCROLL_UP_THRESHOLD_PX = 5;
|
|
||||||
const SMOOTH_SCROLL_SETTLE_MS = 600;
|
|
||||||
|
|
||||||
export default function ChatMessageList({
|
|
||||||
messages,
|
|
||||||
isStreaming,
|
|
||||||
streamingContent,
|
|
||||||
streamingToolCalls,
|
|
||||||
}: Props) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
|
||||||
const isAtBottomRef = useRef(true);
|
|
||||||
const isAutoScrollingRef = useRef(false);
|
|
||||||
const prevScrollTopRef = useRef(0);
|
|
||||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
|
||||||
|
|
||||||
// Dedicated status-region announcement for screen readers. Rather than
|
|
||||||
// putting aria-live on the whole transcript (which re-fires for every
|
|
||||||
// streamed token), announce "AI is thinking…" when streaming starts and
|
|
||||||
// the full assistant reply once streaming completes — a single, clean read.
|
|
||||||
const [statusAnnouncement, setStatusAnnouncement] = useState("");
|
|
||||||
const wasStreamingRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const justStartedStreaming = isStreaming && !wasStreamingRef.current;
|
|
||||||
const justFinishedStreaming = !isStreaming && wasStreamingRef.current;
|
|
||||||
|
|
||||||
if (justStartedStreaming) {
|
|
||||||
setStatusAnnouncement(t("AI is thinking..."));
|
|
||||||
} else if (justFinishedStreaming) {
|
|
||||||
const lastMessage = messages[messages.length - 1];
|
|
||||||
if (lastMessage?.role === "assistant" && lastMessage.content) {
|
|
||||||
// Strip markdown punctuation so screen readers don't read symbols
|
|
||||||
// like # * _ ` ~ aloud. A plain-text version is fine — the styled
|
|
||||||
// version stays in the DOM for visual users.
|
|
||||||
const plainText = lastMessage.content
|
|
||||||
.replace(/[#*_`~]/g, "")
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim();
|
|
||||||
setStatusAnnouncement(plainText);
|
|
||||||
} else {
|
|
||||||
setStatusAnnouncement("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wasStreamingRef.current = isStreaming;
|
|
||||||
}, [isStreaming, messages, t]);
|
|
||||||
|
|
||||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
isAutoScrollingRef.current = true;
|
|
||||||
const target = container.scrollHeight - container.clientHeight;
|
|
||||||
container.scrollTo({ top: target, behavior });
|
|
||||||
prevScrollTopRef.current = target;
|
|
||||||
isAtBottomRef.current = true;
|
|
||||||
setShowScrollButton(false);
|
|
||||||
|
|
||||||
if (behavior === "smooth") {
|
|
||||||
setTimeout(() => {
|
|
||||||
isAutoScrollingRef.current = false;
|
|
||||||
if (containerRef.current) {
|
|
||||||
prevScrollTopRef.current = containerRef.current.scrollTop;
|
|
||||||
}
|
|
||||||
}, SMOOTH_SCROLL_SETTLE_MS);
|
|
||||||
} else {
|
|
||||||
isAutoScrollingRef.current = false;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
|
||||||
if (isAutoScrollingRef.current) return;
|
|
||||||
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const currentScrollTop = container.scrollTop;
|
|
||||||
const scrolledUp =
|
|
||||||
currentScrollTop < prevScrollTopRef.current - SCROLL_UP_THRESHOLD_PX;
|
|
||||||
prevScrollTopRef.current = currentScrollTop;
|
|
||||||
|
|
||||||
const distanceFromBottom =
|
|
||||||
container.scrollHeight - currentScrollTop - container.clientHeight;
|
|
||||||
const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX;
|
|
||||||
|
|
||||||
if (scrolledUp) {
|
|
||||||
isAtBottomRef.current = atBottom;
|
|
||||||
} else if (atBottom) {
|
|
||||||
isAtBottomRef.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowScrollButton(!atBottom);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
|
||||||
return () => container.removeEventListener("scroll", handleScroll);
|
|
||||||
}, [handleScroll]);
|
|
||||||
|
|
||||||
// Instant scroll during streaming to keep up with rapid updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (isAtBottomRef.current) {
|
|
||||||
scrollToBottom("instant");
|
|
||||||
}
|
|
||||||
}, [streamingContent, streamingToolCalls.length, scrollToBottom]);
|
|
||||||
|
|
||||||
// Smooth scroll for new messages. Always force-scroll when the latest
|
|
||||||
// message is from the user (they just sent it), even if they were reading
|
|
||||||
// scrollback.
|
|
||||||
useEffect(() => {
|
|
||||||
const lastMessage = messages[messages.length - 1];
|
|
||||||
const lastIsUser = lastMessage?.role === "user";
|
|
||||||
if (lastIsUser || isAtBottomRef.current) {
|
|
||||||
scrollToBottom("smooth");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No auto-scroll: recompute from actual layout so that chat switches to
|
|
||||||
// content that doesn't overflow correctly hide the button even when no
|
|
||||||
// scroll event fires.
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
const distanceFromBottom =
|
|
||||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
|
||||||
const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX;
|
|
||||||
isAtBottomRef.current = atBottom;
|
|
||||||
setShowScrollButton(!atBottom);
|
|
||||||
}, [messages, scrollToBottom]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classes.messageListWrapper}>
|
|
||||||
{/* Single status region for chat announcements. Kept outside the
|
|
||||||
scrolling transcript so changes here trigger one polite read per
|
|
||||||
state change instead of re-announcing every streamed token. */}
|
|
||||||
<VisuallyHidden role="status" aria-live="polite">
|
|
||||||
{statusAnnouncement}
|
|
||||||
</VisuallyHidden>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className={classes.messageList}
|
|
||||||
aria-label={t("Chat transcript")}
|
|
||||||
>
|
|
||||||
{messages.map((msg) => (
|
|
||||||
<ErrorBoundary
|
|
||||||
key={msg.id}
|
|
||||||
fallback={<ChatMessageErrorFallback />}
|
|
||||||
>
|
|
||||||
<ChatMessage message={msg} />
|
|
||||||
</ErrorBoundary>
|
|
||||||
))}
|
|
||||||
{isStreaming && (
|
|
||||||
<ErrorBoundary
|
|
||||||
resetKeys={[streamingContent, streamingToolCalls.length]}
|
|
||||||
fallback={<ChatMessageErrorFallback />}
|
|
||||||
>
|
|
||||||
<ChatMessage
|
|
||||||
message={{
|
|
||||||
id: "streaming",
|
|
||||||
chatId: "",
|
|
||||||
role: "assistant",
|
|
||||||
content: null,
|
|
||||||
toolCalls: null,
|
|
||||||
metadata: null,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
}}
|
|
||||||
isStreaming
|
|
||||||
streamingContent={streamingContent}
|
|
||||||
streamingToolCalls={streamingToolCalls}
|
|
||||||
/>
|
|
||||||
</ErrorBoundary>
|
|
||||||
)}
|
|
||||||
<div ref={bottomRef} />
|
|
||||||
</div>
|
|
||||||
{showScrollButton && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={t("Scroll to bottom")}
|
|
||||||
className={classes.scrollToBottomButton}
|
|
||||||
onClick={() => scrollToBottom("smooth")}
|
|
||||||
>
|
|
||||||
<IconArrowDown size={16} stroke={2} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import DOMPurify from "dompurify";
|
|
||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconCheck,
|
|
||||||
IconCopy,
|
|
||||||
IconFile,
|
|
||||||
IconLoader2,
|
|
||||||
IconPhoto,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { markdownToHtml } from "@docmost/editor-ext";
|
|
||||||
import { CopyButton } from "@/components/common/copy-button";
|
|
||||||
import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
|
|
||||||
import ChatToolGroup from "./chat-tool-group";
|
|
||||||
import classes from "../styles/chat-message.module.css";
|
|
||||||
import CopyTextButton from "@/components/common/copy.tsx";
|
|
||||||
|
|
||||||
const PAGE_PATH_RE = /\/s\/[^/?#]+\/p\/[^/?#]+/;
|
|
||||||
|
|
||||||
const chatSanitizer = DOMPurify();
|
|
||||||
chatSanitizer.addHook("afterSanitizeAttributes", (node) => {
|
|
||||||
if (node.tagName !== "A") return;
|
|
||||||
const href = node.getAttribute("href") || "";
|
|
||||||
|
|
||||||
// Recover the canonical /s/{slug}/p/{slugId} path if the model wrapped it
|
|
||||||
// in a fabricated host (https://s/..., https://yoursite.com/s/..., //s/...).
|
|
||||||
const m = href.match(PAGE_PATH_RE);
|
|
||||||
if (m) {
|
|
||||||
node.setAttribute("href", m[0]);
|
|
||||||
node.removeAttribute("target");
|
|
||||||
node.removeAttribute("rel");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (href.startsWith("http://") || href.startsWith("https://")) {
|
|
||||||
node.setAttribute("target", "_blank");
|
|
||||||
node.setAttribute("rel", "noopener noreferrer");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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 { t } = useTranslation();
|
|
||||||
|
|
||||||
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}
|
|
||||||
role="article"
|
|
||||||
aria-label={t("You said:")}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only label the article when there's something meaningful to announce.
|
|
||||||
// Tool-only assistant turns (no text) shouldn't announce "Assistant said:" with empty content.
|
|
||||||
const hasAnnouncableContent = Boolean(content);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classes.assistantMessage}
|
|
||||||
role="article"
|
|
||||||
aria-label={hasAnnouncableContent ? t("Assistant said:") : undefined}
|
|
||||||
>
|
|
||||||
<div className={classes.messageContent}>
|
|
||||||
{toolCalls && toolCalls.length > 0 && (
|
|
||||||
<ChatToolGroup toolCalls={toolCalls} isStreaming={isStreaming} />
|
|
||||||
)}
|
|
||||||
{content && (
|
|
||||||
<div
|
|
||||||
onClick={handleContentClick}
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: chatSanitizer.sanitize(
|
|
||||||
markdownToHtml(content) as string,
|
|
||||||
{ ADD_ATTR: ["target", "rel"] },
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isStreaming && (
|
|
||||||
<>
|
|
||||||
{!content && (
|
|
||||||
<span className={classes.processingIndicator}>
|
|
||||||
<IconLoader2 size={16} className={classes.processingSpinner} />
|
|
||||||
Thinking
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className={classes.streamingCursor} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!isStreaming && message.content && (
|
|
||||||
<div className={classes.messageActions}>
|
|
||||||
<CopyTextButton
|
|
||||||
text={message?.content}
|
|
||||||
label={t("Copy assistant response")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
IconChevronRight,
|
|
||||||
IconChevronDown,
|
|
||||||
IconLoader2,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import type { AiChatToolCall } from "../types/ai-chat.types";
|
|
||||||
import ChatToolResult, { TOOL_LABELS } from "./chat-tool-result";
|
|
||||||
import classes from "../styles/chat-message.module.css";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
toolCalls: AiChatToolCall[];
|
|
||||||
isStreaming?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ChatToolGroup({ toolCalls, isStreaming }: Props) {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
|
|
||||||
if (!toolCalls || toolCalls.length === 0) return null;
|
|
||||||
|
|
||||||
const activeCall =
|
|
||||||
isStreaming && toolCalls.length > 0
|
|
||||||
? [...toolCalls].reverse().find((tc) => tc.result === undefined)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const activeLabel = activeCall
|
|
||||||
? TOOL_LABELS[activeCall.name] || activeCall.name
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classes.toolGroup}>
|
|
||||||
<div
|
|
||||||
className={classes.toolGroupHeader}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-expanded={expanded}
|
|
||||||
onClick={() => setExpanded((prev) => !prev)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
setExpanded((prev) => !prev);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{activeLabel ? (
|
|
||||||
<IconLoader2 size={12} className={classes.processingSpinner} />
|
|
||||||
) : expanded ? (
|
|
||||||
<IconChevronDown size={12} />
|
|
||||||
) : (
|
|
||||||
<IconChevronRight size={12} />
|
|
||||||
)}
|
|
||||||
<span className={classes.toolGroupLabel}>
|
|
||||||
{activeLabel ? `${activeLabel}…` : `Steps ${toolCalls.length}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{expanded && (
|
|
||||||
<div className={classes.toolGroupSteps}>
|
|
||||||
{toolCalls.map((tc) => (
|
|
||||||
<ChatToolResult key={tc.id} toolCall={tc} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { IconChevronRight, IconChevronDown } from "@tabler/icons-react";
|
|
||||||
import type { AiChatToolCall } from "../types/ai-chat.types";
|
|
||||||
import classes from "../styles/chat-message.module.css";
|
|
||||||
|
|
||||||
export 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.toolStep}>
|
|
||||||
<div
|
|
||||||
className={classes.toolStepRow}
|
|
||||||
onClick={() => setExpanded((prev) => !prev)}
|
|
||||||
>
|
|
||||||
<span className={classes.toolStepBullet}>·</span>
|
|
||||||
{expanded ? (
|
|
||||||
<IconChevronDown size={12} />
|
|
||||||
) : (
|
|
||||||
<IconChevronRight size={12} />
|
|
||||||
)}
|
|
||||||
<span>{label}</span>
|
|
||||||
</div>
|
|
||||||
{expanded && (
|
|
||||||
<div className={classes.toolStepDetails}>
|
|
||||||
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
|
|
||||||
{JSON.stringify(
|
|
||||||
{ args: toolCall.args, result: toolCall.result },
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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 { 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();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
||||||
<div>
|
|
||||||
<Group gap="xs" align="center">
|
|
||||||
<Text size="md">{t("AI Chat")}</Text>
|
|
||||||
<Badge color="gray" variant="light" size="sm" radius="sm">
|
|
||||||
{t("Beta")}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
<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 hasAccess = useHasFeature(Feature.AI);
|
|
||||||
const upgradeLabel = useUpgradeLabel();
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
|
|
||||||
<Switch
|
|
||||||
defaultChecked={checked}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled={!hasAccess}
|
|
||||||
aria-label={t("Toggle AI Chat")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
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";
|
|
||||||
import type {
|
|
||||||
AiChatMessage,
|
|
||||||
AiChatStreamEvent,
|
|
||||||
AiChatToolCall,
|
|
||||||
ChatAttachment,
|
|
||||||
PageMention,
|
|
||||||
} from "../types/ai-chat.types";
|
|
||||||
|
|
||||||
type ChatStreamOptions = {
|
|
||||||
onChatCreated?: (chatId: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useChatStream(
|
|
||||||
chatId: string | undefined,
|
|
||||||
options?: ChatStreamOptions,
|
|
||||||
) {
|
|
||||||
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 [errorCode, setErrorCode] = useState<string | null>(null);
|
|
||||||
const [isRetryable, setIsRetryable] = useState(false);
|
|
||||||
const abortRef = useRef<AbortController | null>(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<string | undefined>(undefined);
|
|
||||||
|
|
||||||
// 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[] = [], contextPageId?: string) => {
|
|
||||||
if (isStreaming || (!content.trim() && attachments.length === 0)) return;
|
|
||||||
|
|
||||||
setError(null);
|
|
||||||
setErrorCode(null);
|
|
||||||
setIsRetryable(false);
|
|
||||||
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),
|
|
||||||
...(contextPageId && { contextPageId }),
|
|
||||||
...(attachmentIds.length && { attachmentIds }),
|
|
||||||
},
|
|
||||||
(event: AiChatStreamEvent) => {
|
|
||||||
switch (event.type) {
|
|
||||||
case "chat_created":
|
|
||||||
currentChatIdRef.current = event.chatId;
|
|
||||||
// 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":
|
|
||||||
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);
|
|
||||||
setErrorCode(event.code || null);
|
|
||||||
setIsRetryable(event.retryable || false);
|
|
||||||
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,
|
|
||||||
errorCode,
|
|
||||||
isRetryable,
|
|
||||||
sendMessage,
|
|
||||||
stopGeneration,
|
|
||||||
hydrateFromServer,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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 (
|
|
||||||
<div className={classes.layout}>
|
|
||||||
<ErrorBoundary
|
|
||||||
resetKeys={[chatId]}
|
|
||||||
fallbackRender={({ resetErrorBoundary }) => (
|
|
||||||
<EmptyState
|
|
||||||
icon={IconAlertTriangle}
|
|
||||||
title={t("Failed to load chat. An error occurred.")}
|
|
||||||
action={
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
mt="xs"
|
|
||||||
onClick={resetErrorBoundary}
|
|
||||||
>
|
|
||||||
{t("Try again")}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<AiChatLayout />
|
|
||||||
</ErrorBoundary>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
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/chats/create");
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listChats(params?: {
|
|
||||||
limit?: number;
|
|
||||||
cursor?: string;
|
|
||||||
}): Promise<IPagination<AiChat>> {
|
|
||||||
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/chats/info", { chatId });
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteChat(chatId: string): Promise<void> {
|
|
||||||
await api.post("/ai/chats/delete", { chatId });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateChatTitle(
|
|
||||||
chatId: string,
|
|
||||||
title: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await api.post("/ai/chats/update", { chatId, title });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function searchChats(query: string): Promise<AiChat[]> {
|
|
||||||
const req = await api.post("/ai/chats/search", { query });
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadChatFile(
|
|
||||||
file: File,
|
|
||||||
chatId?: string,
|
|
||||||
): Promise<ChatAttachment> {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
if (chatId) {
|
|
||||||
formData.append("chatId", chatId);
|
|
||||||
}
|
|
||||||
return await api.post("/ai/chats/upload", formData, {
|
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sendChatMessage(
|
|
||||||
params: {
|
|
||||||
chatId?: string;
|
|
||||||
content: string;
|
|
||||||
mentionedPageIds?: string[];
|
|
||||||
contextPageId?: 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/chats/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;
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
.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%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
|
||||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-lg) var(--mantine-spacing-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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-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: var(--mantine-color-dimmed);
|
|
||||||
margin-bottom: var(--mantine-spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyStateTitle {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
|
||||||
margin-top: 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: var(--mantine-color-dimmed);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
margin-top: 0;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
.inputWrapper {
|
|
||||||
position: relative;
|
|
||||||
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: 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-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: var(--mantine-color-dimmed);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
background-color: transparent;
|
|
||||||
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: var(--mantine-color-placeholder);
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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-6), 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;
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
[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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolGroup {
|
|
||||||
margin: 6px 0;
|
|
||||||
font-size: var(--mantine-font-size-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolGroupHeader {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolGroupHeader:hover {
|
|
||||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
.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 {
|
|
||||||
margin: 0;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: var(--mantine-font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchInput {
|
|
||||||
margin-bottom: var(--mantine-spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatList {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatGroup + .chatGroup {
|
|
||||||
margin-top: var(--mantine-spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatGroupLabel {
|
|
||||||
margin: 0;
|
|
||||||
padding: 4px var(--mantine-spacing-xs);
|
|
||||||
font-size: var(--mantine-font-size-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mantine-color-dimmed);
|
|
||||||
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;
|
|
||||||
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: var(--mantine-color-dimmed);
|
|
||||||
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,
|
|
||||||
.chatItem:focus-within .chatItemDate {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatItemActions {
|
|
||||||
position: absolute;
|
|
||||||
right: var(--mantine-spacing-xs);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 150ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatItem {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatItem:hover .chatItemActions,
|
|
||||||
.chatItem:focus-within .chatItemActions {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatItemActions :global(.mantine-ActionIcon-root):focus-visible {
|
|
||||||
outline: 2px solid var(--mantine-primary-color-filled);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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; code?: string; retryable?: boolean };
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import type { AiChat } from "../types/ai-chat.types";
|
|
||||||
|
|
||||||
export type ChatGroup = { key: string; label: string; chats: AiChat[] };
|
|
||||||
|
|
||||||
export function groupChatsByAge(
|
|
||||||
chats: AiChat[],
|
|
||||||
t: (key: string) => string,
|
|
||||||
): ChatGroup[] {
|
|
||||||
if (chats.length === 0) return [];
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const startOfToday = new Date(
|
|
||||||
now.getFullYear(),
|
|
||||||
now.getMonth(),
|
|
||||||
now.getDate(),
|
|
||||||
).getTime();
|
|
||||||
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
|
|
||||||
const startOfLast7 = startOfToday - 7 * 24 * 60 * 60 * 1000;
|
|
||||||
const startOfLast30 = startOfToday - 30 * 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const buckets: Record<string, ChatGroup> = {
|
|
||||||
today: { key: "today", label: t("Today"), chats: [] },
|
|
||||||
yesterday: { key: "yesterday", label: t("Yesterday"), chats: [] },
|
|
||||||
last7: { key: "last7", label: t("Previous 7 days"), chats: [] },
|
|
||||||
last30: { key: "last30", label: t("Previous 30 days"), chats: [] },
|
|
||||||
older: { key: "older", label: t("Older"), chats: [] },
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const chat of chats) {
|
|
||||||
const ts = new Date(chat.updatedAt).getTime();
|
|
||||||
if (ts >= startOfToday) buckets.today.chats.push(chat);
|
|
||||||
else if (ts >= startOfYesterday) buckets.yesterday.chats.push(chat);
|
|
||||||
else if (ts >= startOfLast7) buckets.last7.chats.push(chat);
|
|
||||||
else if (ts >= startOfLast30) buckets.last30.chats.push(chat);
|
|
||||||
else buckets.older.chats.push(chat);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
buckets.today,
|
|
||||||
buckets.yesterday,
|
|
||||||
buckets.last7,
|
|
||||||
buckets.last30,
|
|
||||||
buckets.older,
|
|
||||||
].filter((b) => b.chats.length > 0);
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import React, { useMemo } from "react";
|
|
||||||
import { Paper, Text, Group, Stack, Loader, Box } from "@mantine/core";
|
|
||||||
import { IconSparkles, IconFileText } from "@tabler/icons-react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { IAiSearchResponse } from "../services/ai-search-service.ts";
|
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
|
||||||
import { markdownToHtml } from "@docmost/editor-ext";
|
|
||||||
import DOMPurify from "dompurify";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
interface AiSearchResultProps {
|
|
||||||
result?: IAiSearchResponse;
|
|
||||||
isLoading?: boolean;
|
|
||||||
streamingAnswer?: string;
|
|
||||||
streamingSources?: any[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AiSearchResult({
|
|
||||||
result,
|
|
||||||
isLoading,
|
|
||||||
streamingAnswer = "",
|
|
||||||
streamingSources = [],
|
|
||||||
}: AiSearchResultProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// Use streaming data if available, otherwise fall back to result
|
|
||||||
const answer = streamingAnswer || result?.answer || "";
|
|
||||||
const sources =
|
|
||||||
streamingSources.length > 0 ? streamingSources : result?.sources || [];
|
|
||||||
|
|
||||||
// Deduplicate sources by pageId, keeping the one with highest similarity
|
|
||||||
const deduplicatedSources = useMemo(() => {
|
|
||||||
if (!sources || sources.length === 0) return [];
|
|
||||||
|
|
||||||
const pageMap = new Map();
|
|
||||||
sources.forEach((source) => {
|
|
||||||
const existing = pageMap.get(source.pageId);
|
|
||||||
if (!existing || source.similarity > existing.similarity) {
|
|
||||||
pageMap.set(source.pageId, source);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(pageMap.values());
|
|
||||||
}, [sources]);
|
|
||||||
|
|
||||||
if (isLoading && !answer) {
|
|
||||||
return (
|
|
||||||
<Paper p="md" radius="md" withBorder>
|
|
||||||
<Group>
|
|
||||||
<Loader size="sm" />
|
|
||||||
<Text size="sm">{t("AI is thinking...")}</Text>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!answer && !isLoading) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="md" p="md">
|
|
||||||
<Paper p="md" radius="md" withBorder>
|
|
||||||
<Group gap="xs" mb="sm">
|
|
||||||
<IconSparkles size={20} color="var(--mantine-color-blue-6)" />
|
|
||||||
<Text fw={600} size="sm">
|
|
||||||
{t("AI Answer")}
|
|
||||||
</Text>
|
|
||||||
{isLoading && <Loader size="xs" />}
|
|
||||||
</Group>
|
|
||||||
<div
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: DOMPurify.sanitize(markdownToHtml(answer) as string),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{deduplicatedSources.length > 0 && (
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text size="xs" fw={600} c="dimmed">
|
|
||||||
{t("Sources")}
|
|
||||||
</Text>
|
|
||||||
{deduplicatedSources.map((source) => (
|
|
||||||
<Box
|
|
||||||
key={source.pageId}
|
|
||||||
component={Link}
|
|
||||||
to={buildPageUrl(source.spaceSlug, source.slugId, source.title)}
|
|
||||||
style={{
|
|
||||||
textDecoration: "none",
|
|
||||||
color: "inherit",
|
|
||||||
display: "block",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Paper
|
|
||||||
p="xs"
|
|
||||||
radius="sm"
|
|
||||||
withBorder
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconFileText size={16} />
|
|
||||||
<Text size="sm" truncate>
|
|
||||||
{source.title}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
.aiMenu {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 600px;
|
|
||||||
min-height: 2.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aiInput {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
& input {
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 22px;
|
|
||||||
padding-left: 20px;
|
|
||||||
padding-right: 40px;
|
|
||||||
border: 1px solid
|
|
||||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
||||||
font-size: var(--mantine-font-size-sm);
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: light-dark(
|
|
||||||
var(--mantine-color-gray-4),
|
|
||||||
var(--mantine-color-dark-3)
|
|
||||||
);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.menuItemSelected {
|
|
||||||
background-color: var(--mantine-color-gray-1);
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
background-color: var(--mantine-color-dark-5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultPreview {
|
|
||||||
background-color: light-dark(
|
|
||||||
var(--mantine-color-white),
|
|
||||||
var(--mantine-color-dark-6)
|
|
||||||
);
|
|
||||||
border: 1px solid
|
|
||||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultPreviewWrapper {
|
|
||||||
font-size: var(--mantine-font-size-md);
|
|
||||||
line-height: 1.6;
|
|
||||||
padding: var(--mantine-spacing-md);
|
|
||||||
|
|
||||||
*:first-child {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
*:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
import { Editor } from "@tiptap/react";
|
|
||||||
import { ActionIcon, TextInput } from "@mantine/core";
|
|
||||||
import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { IconArrowUp } from "@tabler/icons-react";
|
|
||||||
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
|
||||||
import { useAiGenerateStreamMutation } from "@/ee/ai/queries/ai-query.ts";
|
|
||||||
import { AiAction } from "@/ee/ai/types/ai.types.ts";
|
|
||||||
import { CommandItem, commandItems, CommandSet } from "./command-items.ts";
|
|
||||||
import { CommandSelector } from "./command-selector.tsx";
|
|
||||||
import { ResultPreview } from "./result-preview.tsx";
|
|
||||||
import classes from "./ai-menu.module.css";
|
|
||||||
import { marked } from "marked";
|
|
||||||
import { DOMSerializer } from "@tiptap/pm/model";
|
|
||||||
import { copyToClipboard, htmlToMarkdown } from "@docmost/editor-ext";
|
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
|
|
||||||
interface EditorAiMenuProps {
|
|
||||||
editor: Editor | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
|
||||||
const aiGenerateStreamMutation = useAiGenerateStreamMutation();
|
|
||||||
const location = useLocation();
|
|
||||||
const isSmBreakpoint = useMediaQuery("(max-width: 48em)");
|
|
||||||
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
const [prompt, setPrompt] = useState("");
|
|
||||||
const [output, setOutput] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
||||||
const [activeCommandSet, setActiveCommandSet] = useState<CommandSet>("main");
|
|
||||||
const [lastAction, setLastAction] = useState<CommandItem | null>(null);
|
|
||||||
const [menuPlacement, setMenuPlacement] = useState<{
|
|
||||||
top: number;
|
|
||||||
left: number;
|
|
||||||
width: number;
|
|
||||||
}>({
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: 0,
|
|
||||||
});
|
|
||||||
const currentItems = useMemo(() => {
|
|
||||||
return commandItems[activeCommandSet].filter((item) => {
|
|
||||||
return item.name.toLowerCase().includes(prompt.toLowerCase());
|
|
||||||
});
|
|
||||||
}, [prompt, output, activeCommandSet]);
|
|
||||||
const updateMenuPlacement = useCallback(() => {
|
|
||||||
if (!editor || !showAiMenu) return;
|
|
||||||
|
|
||||||
const { view } = editor;
|
|
||||||
const { from, to } = editor.state.selection;
|
|
||||||
const editorRect = view.dom.getBoundingClientRect();
|
|
||||||
const fromCoords = view.coordsAtPos(from);
|
|
||||||
const toCoords = view.coordsAtPos(to);
|
|
||||||
const topOffset = 8;
|
|
||||||
const editorPadding = isSmBreakpoint ? 16 : 48;
|
|
||||||
|
|
||||||
const anchorBottom =
|
|
||||||
toCoords.bottom > 0 && toCoords.bottom < window.innerHeight
|
|
||||||
? toCoords.bottom
|
|
||||||
: fromCoords.bottom;
|
|
||||||
|
|
||||||
const menuMaxWidth = 600;
|
|
||||||
const editorLeft = editorRect.left + editorPadding;
|
|
||||||
const editorRight = editorRect.right - editorPadding;
|
|
||||||
const availableWidth = editorRight - editorLeft;
|
|
||||||
const menuWidth = Math.min(menuMaxWidth, availableWidth);
|
|
||||||
|
|
||||||
let menuLeft = Math.max(editorLeft, fromCoords.left);
|
|
||||||
if (menuLeft + menuWidth > editorRight) {
|
|
||||||
menuLeft = editorRight - menuWidth;
|
|
||||||
}
|
|
||||||
menuLeft = Math.max(editorLeft, menuLeft);
|
|
||||||
|
|
||||||
setMenuPlacement({
|
|
||||||
top: anchorBottom + topOffset + window.scrollY,
|
|
||||||
left: menuLeft + window.scrollX,
|
|
||||||
width: menuWidth,
|
|
||||||
});
|
|
||||||
}, [editor, showAiMenu, isSmBreakpoint]);
|
|
||||||
const resetMenu = useCallback(() => {
|
|
||||||
setPrompt("");
|
|
||||||
setOutput("");
|
|
||||||
setActiveCommandSet("main");
|
|
||||||
setLastAction(null);
|
|
||||||
aiGenerateStreamMutation.reset();
|
|
||||||
}, [aiGenerateStreamMutation.reset]);
|
|
||||||
const debouncedUpdateMenuPlacement = useDebouncedCallback(
|
|
||||||
updateMenuPlacement,
|
|
||||||
60,
|
|
||||||
);
|
|
||||||
const handleGenerate = useCallback(
|
|
||||||
(item?: CommandItem) => {
|
|
||||||
if (!editor || isLoading) return;
|
|
||||||
|
|
||||||
let command: CommandItem | null = item || null;
|
|
||||||
|
|
||||||
if (!command) {
|
|
||||||
if (!prompt) return;
|
|
||||||
|
|
||||||
command = {
|
|
||||||
id: "custom",
|
|
||||||
name: "Custom",
|
|
||||||
action: AiAction.CUSTOM,
|
|
||||||
prompt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const { from, to } = editor.state.selection;
|
|
||||||
const slice = editor.state.doc.slice(from, to);
|
|
||||||
const serializer = DOMSerializer.fromSchema(editor.schema);
|
|
||||||
const fragment = serializer.serializeFragment(slice.content);
|
|
||||||
const wrapper = document.createElement("div");
|
|
||||||
wrapper.appendChild(fragment);
|
|
||||||
const content = htmlToMarkdown(wrapper.innerHTML);
|
|
||||||
|
|
||||||
setOutput("");
|
|
||||||
setIsLoading(true);
|
|
||||||
aiGenerateStreamMutation.mutate({
|
|
||||||
action: command.action,
|
|
||||||
prompt: command.prompt,
|
|
||||||
content,
|
|
||||||
onChunk: (chunk) => {
|
|
||||||
setOutput((output) => output + chunk.content);
|
|
||||||
},
|
|
||||||
onComplete: () => {
|
|
||||||
setPrompt("");
|
|
||||||
setIsLoading(false);
|
|
||||||
setActiveCommandSet("result");
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
setIsLoading(false);
|
|
||||||
resetMenu();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setLastAction(command);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
editor,
|
|
||||||
prompt,
|
|
||||||
isLoading,
|
|
||||||
aiGenerateStreamMutation.mutateAsync,
|
|
||||||
resetMenu,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
const handleCommand = useCallback(
|
|
||||||
(item?: CommandItem) => {
|
|
||||||
setPrompt("");
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
return handleGenerate();
|
|
||||||
}
|
|
||||||
if (item.id === "back") {
|
|
||||||
return setActiveCommandSet("main");
|
|
||||||
}
|
|
||||||
if (item.id === "result-replace") {
|
|
||||||
const chain = editor.chain().focus();
|
|
||||||
|
|
||||||
if (lastAction.action === AiAction.CONTINUE_WRITING) {
|
|
||||||
chain.setTextSelection(editor.state.selection.to);
|
|
||||||
}
|
|
||||||
|
|
||||||
const html = (marked.parse(output) as string).trim();
|
|
||||||
const isSingleParagraph =
|
|
||||||
html.startsWith("<p>") &&
|
|
||||||
html.endsWith("</p>") &&
|
|
||||||
html.lastIndexOf("<p>") === 0;
|
|
||||||
|
|
||||||
// Strip <p> wrapper for single-paragraph output to preserve inline context,
|
|
||||||
// then decode HTML entities via DOMParser since TipTap would otherwise
|
|
||||||
// treat the tagless string as plain text and insert entities literally.
|
|
||||||
const content = isSingleParagraph
|
|
||||||
? new DOMParser().parseFromString(html.slice(3, -4), "text/html")
|
|
||||||
.body.innerHTML
|
|
||||||
: html;
|
|
||||||
|
|
||||||
chain.insertContent(content).run();
|
|
||||||
|
|
||||||
return setShowAiMenu(false);
|
|
||||||
}
|
|
||||||
if (item.id === "result-insert-below") {
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.setTextSelection(editor.state.selection.to)
|
|
||||||
.insertContent(marked.parse(output))
|
|
||||||
.run();
|
|
||||||
|
|
||||||
return setShowAiMenu(false);
|
|
||||||
}
|
|
||||||
if (item.id === "result-copy") {
|
|
||||||
copyToClipboard(output);
|
|
||||||
|
|
||||||
return setShowAiMenu(false);
|
|
||||||
}
|
|
||||||
if (item.id === "result-discard") {
|
|
||||||
setOutput("");
|
|
||||||
|
|
||||||
return resetMenu();
|
|
||||||
}
|
|
||||||
if (item.id === "result-try-again" && lastAction) {
|
|
||||||
return handleGenerate(lastAction);
|
|
||||||
}
|
|
||||||
if (item.subCommandSet) {
|
|
||||||
return setActiveCommandSet(item.subCommandSet);
|
|
||||||
}
|
|
||||||
|
|
||||||
return handleGenerate(item);
|
|
||||||
},
|
|
||||||
[editor, output, lastAction, handleGenerate, resetMenu],
|
|
||||||
);
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
const totalItems = currentItems.length;
|
|
||||||
const cycleSize = totalItems + 1;
|
|
||||||
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
return setShowAiMenu(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
return setSelectedIndex((selectedIndex) => {
|
|
||||||
const direction = event.key === "ArrowDown" ? 1 : -1;
|
|
||||||
const newIndex = selectedIndex + direction;
|
|
||||||
|
|
||||||
if (newIndex < -1) return cycleSize - 1;
|
|
||||||
if (newIndex >= cycleSize) return 0;
|
|
||||||
|
|
||||||
return newIndex;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
return handleCommand(currentItems[selectedIndex]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[currentItems, selectedIndex],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editor) return;
|
|
||||||
|
|
||||||
const handleClose = () => setShowAiMenu(false);
|
|
||||||
const observer = new ResizeObserver(() => {
|
|
||||||
debouncedUpdateMenuPlacement();
|
|
||||||
});
|
|
||||||
|
|
||||||
updateMenuPlacement();
|
|
||||||
editor.on("focus", handleClose);
|
|
||||||
editor.on("blur", handleClose);
|
|
||||||
window.addEventListener("resize", debouncedUpdateMenuPlacement);
|
|
||||||
window.addEventListener("scroll", debouncedUpdateMenuPlacement, true);
|
|
||||||
observer.observe(editor.view.dom);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
editor.off("focus", handleClose);
|
|
||||||
editor.off("blur", handleClose);
|
|
||||||
window.removeEventListener("resize", debouncedUpdateMenuPlacement);
|
|
||||||
window.removeEventListener("scroll", debouncedUpdateMenuPlacement, true);
|
|
||||||
observer.disconnect();
|
|
||||||
};
|
|
||||||
}, [editor, updateMenuPlacement, debouncedUpdateMenuPlacement]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setShowAiMenu(false);
|
|
||||||
}, [location]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (showAiMenu) {
|
|
||||||
resetMenu();
|
|
||||||
}
|
|
||||||
}, [showAiMenu, resetMenu]);
|
|
||||||
useEffect(() => {
|
|
||||||
// Focus input when menu opens or command set changes
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
inputRef.current?.focus({ preventScroll: true });
|
|
||||||
});
|
|
||||||
}, [showAiMenu, isLoading, currentItems]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentItems.length) {
|
|
||||||
setSelectedIndex(-1);
|
|
||||||
}
|
|
||||||
setSelectedIndex(prompt || activeCommandSet !== "main" ? 0 : -1);
|
|
||||||
}, [prompt, activeCommandSet, currentItems]);
|
|
||||||
|
|
||||||
if (!showAiMenu) return null;
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
zIndex: 199,
|
|
||||||
position: "absolute",
|
|
||||||
top: menuPlacement.top,
|
|
||||||
left: menuPlacement.left,
|
|
||||||
width: menuPlacement.width,
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classes.aiMenu}
|
|
||||||
style={{ pointerEvents: "auto" }}
|
|
||||||
tabIndex={0}
|
|
||||||
ref={containerRef}
|
|
||||||
>
|
|
||||||
<ResultPreview output={output} isLoading={isLoading} />
|
|
||||||
<CommandSelector
|
|
||||||
selectedIndex={selectedIndex}
|
|
||||||
isLoading={isLoading}
|
|
||||||
output={output}
|
|
||||||
currentItems={currentItems}
|
|
||||||
handleCommand={handleCommand}
|
|
||||||
>
|
|
||||||
<TextInput
|
|
||||||
ref={inputRef}
|
|
||||||
className={classes.aiInput}
|
|
||||||
placeholder="Ask AI..."
|
|
||||||
data-autofocus
|
|
||||||
value={prompt}
|
|
||||||
disabled={isLoading}
|
|
||||||
onChange={(e) => setPrompt(e.currentTarget.value)}
|
|
||||||
rightSection={
|
|
||||||
<ActionIcon
|
|
||||||
disabled={!prompt || isLoading}
|
|
||||||
variant="filled"
|
|
||||||
color="blue"
|
|
||||||
radius="xl"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleGenerate()}
|
|
||||||
>
|
|
||||||
<IconArrowUp size={14} stroke={2.5} />
|
|
||||||
</ActionIcon>
|
|
||||||
}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
/>
|
|
||||||
</CommandSelector>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { EditorAiMenu };
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
import { AiAction } from "@/ee/ai/types/ai.types.ts";
|
|
||||||
import {
|
|
||||||
IconSparkles,
|
|
||||||
IconArrowsMaximize,
|
|
||||||
IconArrowsMinimize,
|
|
||||||
IconWriting,
|
|
||||||
IconHelp,
|
|
||||||
IconList,
|
|
||||||
IconMoodSmile,
|
|
||||||
IconLanguage,
|
|
||||||
IconTrash,
|
|
||||||
IconRefresh,
|
|
||||||
IconChevronLeft,
|
|
||||||
IconCheck,
|
|
||||||
IconArrowDownLeft,
|
|
||||||
IconCopy,
|
|
||||||
IconTextPlus,
|
|
||||||
IconAlignJustified,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
|
|
||||||
interface CommandItem {
|
|
||||||
name: string;
|
|
||||||
id: string;
|
|
||||||
icon?: typeof IconSparkles;
|
|
||||||
action?: AiAction;
|
|
||||||
prompt?: string;
|
|
||||||
subCommandSet?: CommandSet;
|
|
||||||
}
|
|
||||||
|
|
||||||
type CommandSet = "main" | "tone" | "translate" | "result";
|
|
||||||
|
|
||||||
const mainItems: CommandItem[] = [
|
|
||||||
{
|
|
||||||
id: "improve-writing",
|
|
||||||
name: "Improve writing",
|
|
||||||
icon: IconSparkles,
|
|
||||||
action: AiAction.IMPROVE_WRITING,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "fix-spelling-grammar",
|
|
||||||
name: "Fix spelling & grammar",
|
|
||||||
icon: IconCheck,
|
|
||||||
action: AiAction.FIX_SPELLING_GRAMMAR,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "make-longer",
|
|
||||||
name: "Make longer",
|
|
||||||
icon: IconTextPlus,
|
|
||||||
action: AiAction.MAKE_LONGER,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "make-shorter",
|
|
||||||
name: "Make shorter",
|
|
||||||
icon: IconAlignJustified,
|
|
||||||
action: AiAction.MAKE_SHORTER,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "continue-writing",
|
|
||||||
name: "Continue writing",
|
|
||||||
icon: IconWriting,
|
|
||||||
action: AiAction.CONTINUE_WRITING,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "explain",
|
|
||||||
name: "Explain",
|
|
||||||
icon: IconHelp,
|
|
||||||
action: AiAction.EXPLAIN,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "summarize",
|
|
||||||
name: "Summarize",
|
|
||||||
icon: IconList,
|
|
||||||
action: AiAction.SUMMARIZE,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "change-tone",
|
|
||||||
name: "Change tone",
|
|
||||||
icon: IconMoodSmile,
|
|
||||||
subCommandSet: "tone",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate",
|
|
||||||
name: "Translate",
|
|
||||||
icon: IconLanguage,
|
|
||||||
subCommandSet: "translate",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const toneItems: CommandItem[] = [
|
|
||||||
{
|
|
||||||
id: "back",
|
|
||||||
name: "Back",
|
|
||||||
icon: IconChevronLeft,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "tone-professional",
|
|
||||||
name: "Professional",
|
|
||||||
icon: IconMoodSmile,
|
|
||||||
action: AiAction.CHANGE_TONE,
|
|
||||||
prompt: "Professional",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "tone-casual",
|
|
||||||
name: "Casual",
|
|
||||||
icon: IconMoodSmile,
|
|
||||||
action: AiAction.CHANGE_TONE,
|
|
||||||
prompt: "Casual",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "tone-friendly",
|
|
||||||
name: "Friendly",
|
|
||||||
icon: IconMoodSmile,
|
|
||||||
action: AiAction.CHANGE_TONE,
|
|
||||||
prompt: "Friendly",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const translateItems: CommandItem[] = [
|
|
||||||
{
|
|
||||||
id: "back",
|
|
||||||
name: "Back",
|
|
||||||
icon: IconChevronLeft,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate-english",
|
|
||||||
name: "English",
|
|
||||||
icon: IconLanguage,
|
|
||||||
action: AiAction.TRANSLATE,
|
|
||||||
prompt: "English",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate-spanish",
|
|
||||||
name: "Spanish",
|
|
||||||
icon: IconLanguage,
|
|
||||||
action: AiAction.TRANSLATE,
|
|
||||||
prompt: "Spanish",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate-german",
|
|
||||||
name: "German",
|
|
||||||
icon: IconLanguage,
|
|
||||||
action: AiAction.TRANSLATE,
|
|
||||||
prompt: "German",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate-french",
|
|
||||||
name: "French",
|
|
||||||
icon: IconLanguage,
|
|
||||||
action: AiAction.TRANSLATE,
|
|
||||||
prompt: "French",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate-dutch",
|
|
||||||
name: "Dutch",
|
|
||||||
icon: IconLanguage,
|
|
||||||
action: AiAction.TRANSLATE,
|
|
||||||
prompt: "Dutch",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate-portuguese",
|
|
||||||
name: "Portuguese",
|
|
||||||
icon: IconLanguage,
|
|
||||||
action: AiAction.TRANSLATE,
|
|
||||||
prompt: "Portuguese",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate-italian",
|
|
||||||
name: "Italian",
|
|
||||||
icon: IconLanguage,
|
|
||||||
action: AiAction.TRANSLATE,
|
|
||||||
prompt: "Italian",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate-japanese",
|
|
||||||
name: "Japanese",
|
|
||||||
icon: IconLanguage,
|
|
||||||
action: AiAction.TRANSLATE,
|
|
||||||
prompt: "Japanese",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate-korean",
|
|
||||||
name: "Korean",
|
|
||||||
icon: IconLanguage,
|
|
||||||
action: AiAction.TRANSLATE,
|
|
||||||
prompt: "Korean",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate-swedish",
|
|
||||||
name: "Swedish",
|
|
||||||
icon: IconLanguage,
|
|
||||||
action: AiAction.TRANSLATE,
|
|
||||||
prompt: "Swedish",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate-chinese",
|
|
||||||
name: "Chinese (Simplified)",
|
|
||||||
icon: IconLanguage,
|
|
||||||
action: AiAction.TRANSLATE,
|
|
||||||
prompt: "Simplified Chinese",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const resultItems: CommandItem[] = [
|
|
||||||
{ id: "result-replace", name: "Replace", icon: IconCheck },
|
|
||||||
{ id: "result-insert-below", name: "Insert below", icon: IconArrowDownLeft },
|
|
||||||
{ id: "result-copy", name: "Copy", icon: IconCopy },
|
|
||||||
{ id: "result-discard", name: "Discard", icon: IconTrash },
|
|
||||||
{
|
|
||||||
id: "result-try-again",
|
|
||||||
name: "Try again",
|
|
||||||
icon: IconRefresh,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const commandItems: Record<CommandSet, CommandItem[]> = {
|
|
||||||
main: mainItems,
|
|
||||||
tone: toneItems,
|
|
||||||
translate: translateItems,
|
|
||||||
result: resultItems,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type { CommandItem, CommandSet };
|
|
||||||
export { commandItems };
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { Loader, Menu, ScrollArea } from "@mantine/core";
|
|
||||||
import { IconChevronRight } from "@tabler/icons-react";
|
|
||||||
import { ReactNode } from "react";
|
|
||||||
import { CommandItem } from "./command-items.ts";
|
|
||||||
import classes from "./ai-menu.module.css";
|
|
||||||
|
|
||||||
interface CommandSelectorProps {
|
|
||||||
selectedIndex: number;
|
|
||||||
|
|
||||||
isLoading: boolean;
|
|
||||||
output: string;
|
|
||||||
currentItems: CommandItem[];
|
|
||||||
children: ReactNode;
|
|
||||||
handleCommand(item: CommandItem): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CommandSelector = ({
|
|
||||||
selectedIndex,
|
|
||||||
children,
|
|
||||||
isLoading,
|
|
||||||
output,
|
|
||||||
currentItems,
|
|
||||||
handleCommand,
|
|
||||||
}: CommandSelectorProps) => {
|
|
||||||
return (
|
|
||||||
<Menu
|
|
||||||
opened={!isLoading && currentItems.length > 0}
|
|
||||||
middlewares={{ flip: false }}
|
|
||||||
position="bottom-start"
|
|
||||||
offset={4}
|
|
||||||
width={250}
|
|
||||||
trapFocus={false}
|
|
||||||
shadow="lg"
|
|
||||||
>
|
|
||||||
<Menu.Target>{children}</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<ScrollArea.Autosize type="scroll" scrollbarSize={5} mah={300}>
|
|
||||||
{currentItems.map((item, index) => {
|
|
||||||
const isSelected = selectedIndex === index;
|
|
||||||
const showLoader =
|
|
||||||
isLoading && output === "" && !item.subCommandSet;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Menu.Item
|
|
||||||
key={item.id}
|
|
||||||
className={isSelected ? classes.menuItemSelected : undefined}
|
|
||||||
leftSection={
|
|
||||||
showLoader ? (
|
|
||||||
<Loader size={14} />
|
|
||||||
) : item.icon ? (
|
|
||||||
<item.icon size={16} />
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
rightSection={
|
|
||||||
item.subCommandSet ? (
|
|
||||||
<IconChevronRight size={14} />
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
onClick={() => handleCommand(item)}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Menu.Item>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ScrollArea.Autosize>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { CommandSelector };
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { Loader, Paper, ScrollArea } from "@mantine/core";
|
|
||||||
import DOMPurify from "dompurify";
|
|
||||||
import { marked } from "marked";
|
|
||||||
import { memo } from "react";
|
|
||||||
import classes from "./ai-menu.module.css";
|
|
||||||
|
|
||||||
interface ResultPreviewProps {
|
|
||||||
output: string;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
const ResultPreview = memo(({ output, isLoading }: ResultPreviewProps) => {
|
|
||||||
if (!output && !isLoading) return;
|
|
||||||
|
|
||||||
const parsedOutput = `${marked.parse(output)}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper mb={4} shadow="lg" radius="md" className={classes.resultPreview}>
|
|
||||||
<ScrollArea.Autosize mah={300} type="scroll" scrollbarSize={5}>
|
|
||||||
<div className={classes.resultPreviewWrapper}>
|
|
||||||
{parsedOutput && (
|
|
||||||
<div
|
|
||||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(parsedOutput) }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isLoading && <Loader size={12} ml="xs" display="inline-block" />}
|
|
||||||
</div>
|
|
||||||
</ScrollArea.Autosize>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export { ResultPreview };
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { Group, Text, Switch, MantineSize, Tooltip } from "@mantine/core";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
|
||||||
import { Feature } from "@/ee/features";
|
|
||||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
|
||||||
|
|
||||||
export default function EnableAiSearch() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
||||||
<div>
|
|
||||||
<Text size="md">{t("AI-powered search (AI Answers)")}</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t(
|
|
||||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AiSearchToggle />
|
|
||||||
</Group>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AiSearchToggleProps {
|
|
||||||
size?: MantineSize;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
|
||||||
const [checked, setChecked] = useState(workspace?.settings?.ai?.search);
|
|
||||||
const hasAccess = useHasFeature(Feature.AI);
|
|
||||||
const upgradeLabel = useUpgradeLabel();
|
|
||||||
|
|
||||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = event.currentTarget.checked;
|
|
||||||
try {
|
|
||||||
const updatedWorkspace = await updateWorkspace({ aiSearch: value });
|
|
||||||
setChecked(value);
|
|
||||||
setWorkspace(updatedWorkspace);
|
|
||||||
} catch (err) {
|
|
||||||
notifications.show({
|
|
||||||
message: err?.response?.data?.message,
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
|
|
||||||
<Switch
|
|
||||||
size={size}
|
|
||||||
label={label}
|
|
||||||
labelPosition="left"
|
|
||||||
defaultChecked={checked}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled={!hasAccess}
|
|
||||||
aria-label={t("Toggle AI search")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { Group, Text, Switch, Tooltip } from "@mantine/core";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
|
||||||
import { Feature } from "@/ee/features";
|
|
||||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
|
||||||
|
|
||||||
export default function EnableGenerativeAi() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
|
||||||
const [checked, setChecked] = useState(workspace?.settings?.ai?.generative);
|
|
||||||
const hasAccess = useHasFeature(Feature.AI);
|
|
||||||
const upgradeLabel = useUpgradeLabel();
|
|
||||||
|
|
||||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = event.currentTarget.checked;
|
|
||||||
try {
|
|
||||||
const updatedWorkspace = await updateWorkspace({ generativeAi: value });
|
|
||||||
setChecked(value);
|
|
||||||
setWorkspace(updatedWorkspace);
|
|
||||||
} catch (err) {
|
|
||||||
notifications.show({
|
|
||||||
message: err?.response?.data?.message,
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
||||||
<div>
|
|
||||||
<Text size="md">{t("Generative AI (Ask AI)")}</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t(
|
|
||||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
|
|
||||||
<Switch
|
|
||||||
defaultChecked={checked}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled={!hasAccess}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import {
|
|
||||||
Anchor,
|
|
||||||
Group,
|
|
||||||
List,
|
|
||||||
Text,
|
|
||||||
Switch,
|
|
||||||
TextInput,
|
|
||||||
ActionIcon,
|
|
||||||
Tooltip,
|
|
||||||
Stack,
|
|
||||||
Alert,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
|
||||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
|
||||||
import { Feature } from "@/ee/features";
|
|
||||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
|
||||||
import { getAppUrl } from "@/lib/config.ts";
|
|
||||||
import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react";
|
|
||||||
import { CopyButton } from "@/components/common/copy-button.tsx";
|
|
||||||
|
|
||||||
export default function McpSettings() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
|
||||||
const [checked, setChecked] = useState(workspace?.settings?.ai?.mcp);
|
|
||||||
const hasAccess = useHasFeature(Feature.MCP);
|
|
||||||
const upgradeLabel = useUpgradeLabel();
|
|
||||||
|
|
||||||
const mcpUrl = `${getAppUrl()}/mcp`;
|
|
||||||
|
|
||||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = event.currentTarget.checked;
|
|
||||||
try {
|
|
||||||
const updatedWorkspace = await updateWorkspace({ mcpEnabled: value });
|
|
||||||
setChecked(value);
|
|
||||||
setWorkspace(updatedWorkspace);
|
|
||||||
} catch (err) {
|
|
||||||
notifications.show({
|
|
||||||
message: err?.response?.data?.message,
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="lg">
|
|
||||||
{!hasAccess && (
|
|
||||||
<Alert icon={<IconInfoCircle />} title={upgradeLabel} color="blue">
|
|
||||||
{t(
|
|
||||||
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
|
|
||||||
)}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
||||||
<div>
|
|
||||||
<Text size="md">{t("Model Context Protocol (MCP)")}</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t(
|
|
||||||
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.",
|
|
||||||
)}{" "}
|
|
||||||
<Trans
|
|
||||||
i18nKey="View the <anchor>MCP documentation</anchor>."
|
|
||||||
components={{
|
|
||||||
anchor: <Anchor href="https://docmost.com/docs/user-guide/mcp" target="_blank" size="sm" />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
|
|
||||||
<Switch
|
|
||||||
defaultChecked={checked}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled={!hasAccess}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{checked && (
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={500} mb={4}>
|
|
||||||
{t("MCP Server URL")}
|
|
||||||
</Text>
|
|
||||||
<Group gap="xs">
|
|
||||||
<TextInput value={mcpUrl} readOnly style={{ flex: 1 }} />
|
|
||||||
<CopyButton value={mcpUrl} timeout={2000}>
|
|
||||||
{({ copied, copy }) => (
|
|
||||||
<Tooltip
|
|
||||||
label={copied ? t("Copied") : t("Copy")}
|
|
||||||
withArrow
|
|
||||||
position="right"
|
|
||||||
>
|
|
||||||
<ActionIcon
|
|
||||||
color={copied ? "teal" : "gray"}
|
|
||||||
variant="subtle"
|
|
||||||
onClick={copy}
|
|
||||||
>
|
|
||||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</CopyButton>
|
|
||||||
</Group>
|
|
||||||
<Text size="sm" c="dimmed" mt="xs">
|
|
||||||
{t(
|
|
||||||
"Use your API key for authentication. You can manage API keys in your account settings.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={500} mt="md" mb={4}>
|
|
||||||
{t("Supported tools")}
|
|
||||||
</Text>
|
|
||||||
<List size="sm" spacing={2}>
|
|
||||||
<List.Item>
|
|
||||||
<Text size="sm" c="dimmed" span>
|
|
||||||
search_pages, get_page, create_page, update_page
|
|
||||||
</Text>
|
|
||||||
</List.Item>
|
|
||||||
<List.Item>
|
|
||||||
<Text size="sm" c="dimmed" span>
|
|
||||||
list_pages, list_child_pages, duplicate_page
|
|
||||||
</Text>
|
|
||||||
</List.Item>
|
|
||||||
<List.Item>
|
|
||||||
<Text size="sm" c="dimmed" span>
|
|
||||||
copy_page_to_space, move_page, move_page_to_space
|
|
||||||
</Text>
|
|
||||||
</List.Item>
|
|
||||||
<List.Item>
|
|
||||||
<Text size="sm" c="dimmed" span>
|
|
||||||
get_space, list_spaces, create_space, update_space
|
|
||||||
</Text>
|
|
||||||
</List.Item>
|
|
||||||
<List.Item>
|
|
||||||
<Text size="sm" c="dimmed" span>
|
|
||||||
get_comments, create_comment, update_comment
|
|
||||||
</Text>
|
|
||||||
</List.Item>
|
|
||||||
<List.Item>
|
|
||||||
<Text size="sm" c="dimmed" span>
|
|
||||||
search_attachments, list_workspace_members, get_current_user
|
|
||||||
</Text>
|
|
||||||
</List.Item>
|
|
||||||
</List>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { useMutation, UseMutationResult } from "@tanstack/react-query";
|
|
||||||
import { useState, useCallback } from "react";
|
|
||||||
import { aiAnswers, IAiSearchResponse } from "@/ee/ai/services/ai-search-service.ts";
|
|
||||||
import { IPageSearchParams } from "@/features/search/types/search.types.ts";
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
interface UseAiSearchResult extends UseMutationResult<IAiSearchResponse, Error, IPageSearchParams> {
|
|
||||||
streamingAnswer: string;
|
|
||||||
streamingSources: any[];
|
|
||||||
clearStreaming: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAiSearch(): UseAiSearchResult {
|
|
||||||
const [streamingAnswer, setStreamingAnswer] = useState("");
|
|
||||||
const [streamingSources, setStreamingSources] = useState<any[]>([]);
|
|
||||||
|
|
||||||
const clearStreaming = useCallback(() => {
|
|
||||||
setStreamingAnswer("");
|
|
||||||
setStreamingSources([]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: async (params: IPageSearchParams & { contentType?: string }) => {
|
|
||||||
setStreamingAnswer("");
|
|
||||||
setStreamingSources([]);
|
|
||||||
|
|
||||||
const { contentType, ...apiParams } = params;
|
|
||||||
|
|
||||||
return await aiAnswers(apiParams, (chunk) => {
|
|
||||||
if (chunk.content) {
|
|
||||||
setStreamingAnswer((prev) => prev + chunk.content);
|
|
||||||
}
|
|
||||||
if (chunk.sources) {
|
|
||||||
setStreamingSources(chunk.sources);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...mutation,
|
|
||||||
streamingAnswer,
|
|
||||||
streamingSources,
|
|
||||||
clearStreaming,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { useState, useCallback, useRef } from "react";
|
|
||||||
import { useAiGenerateStreamMutation } from "@/ee/ai/queries/ai-query.ts";
|
|
||||||
import { AiGenerateDto } from "@/ee/ai/types/ai.types.ts";
|
|
||||||
|
|
||||||
export function useAiStream() {
|
|
||||||
const [content, setContent] = useState("");
|
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
|
||||||
const mutation = useAiGenerateStreamMutation();
|
|
||||||
|
|
||||||
const startStream = useCallback(
|
|
||||||
async (data: AiGenerateDto) => {
|
|
||||||
setContent("");
|
|
||||||
setIsStreaming(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const controller = await mutation.mutateAsync({
|
|
||||||
...data,
|
|
||||||
onChunk: (chunk) => {
|
|
||||||
setContent((prev) => prev + chunk.content);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error("AI stream error:", error);
|
|
||||||
setIsStreaming(false);
|
|
||||||
},
|
|
||||||
onComplete: () => {
|
|
||||||
setIsStreaming(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
abortControllerRef.current = controller;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to start stream:", error);
|
|
||||||
setIsStreaming(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[mutation]
|
|
||||||
);
|
|
||||||
|
|
||||||
const stopStream = useCallback(() => {
|
|
||||||
if (abortControllerRef.current) {
|
|
||||||
abortControllerRef.current.abort();
|
|
||||||
abortControllerRef.current = null;
|
|
||||||
setIsStreaming(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetContent = useCallback(() => {
|
|
||||||
setContent("");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content,
|
|
||||||
isStreaming,
|
|
||||||
startStream,
|
|
||||||
stopStream,
|
|
||||||
resetContent,
|
|
||||||
isLoading: mutation.isPending,
|
|
||||||
error: mutation.error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { Helmet } from "react-helmet-async";
|
|
||||||
import { getAppName } from "@/lib/config.ts";
|
|
||||||
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
|
||||||
import React from "react";
|
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
|
|
||||||
import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx";
|
|
||||||
import EnableAiChat from "@/ee/ai-chat/components/enable-ai-chat.tsx";
|
|
||||||
import McpSettings from "@/ee/ai/components/mcp-settings.tsx";
|
|
||||||
import { Alert, Stack, Tabs } from "@mantine/core";
|
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
|
||||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
|
||||||
import { Feature } from "@/ee/features";
|
|
||||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
|
||||||
import { isCloud } from "@/lib/config.ts";
|
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
export default function AiSettings() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { isAdmin } = useUserRole();
|
|
||||||
const hasAccess = useHasFeature(Feature.AI);
|
|
||||||
const upgradeLabel = useUpgradeLabel();
|
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const activeTab = location.pathname.endsWith("/mcp") ? "mcp" : "ai";
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTabChange = (value: string | null) => {
|
|
||||||
if (value === "mcp") {
|
|
||||||
navigate("/settings/ai/mcp");
|
|
||||||
} else {
|
|
||||||
navigate("/settings/ai");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Helmet>
|
|
||||||
<title>AI settings - {getAppName()}</title>
|
|
||||||
</Helmet>
|
|
||||||
<SettingsTitle title={t("AI settings")} />
|
|
||||||
|
|
||||||
<Tabs color="dark" value={activeTab} onChange={handleTabChange}>
|
|
||||||
<Tabs.List>
|
|
||||||
<Tabs.Tab fw={500} value="ai">
|
|
||||||
{t("AI")}
|
|
||||||
</Tabs.Tab>
|
|
||||||
<Tabs.Tab fw={500} value="mcp">
|
|
||||||
{t("MCP")}
|
|
||||||
</Tabs.Tab>
|
|
||||||
</Tabs.List>
|
|
||||||
|
|
||||||
<Tabs.Panel value="ai" pt="md">
|
|
||||||
{!hasAccess && (
|
|
||||||
<Alert
|
|
||||||
icon={<IconInfoCircle />}
|
|
||||||
title={upgradeLabel}
|
|
||||||
color="blue"
|
|
||||||
mb="lg"
|
|
||||||
>
|
|
||||||
{t(
|
|
||||||
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
|
|
||||||
)}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Stack gap="md">
|
|
||||||
{!isCloud() && <EnableAiSearch />}
|
|
||||||
<EnableGenerativeAi />
|
|
||||||
<EnableAiChat />
|
|
||||||
</Stack>
|
|
||||||
</Tabs.Panel>
|
|
||||||
|
|
||||||
<Tabs.Panel value="mcp" pt="md">
|
|
||||||
<McpSettings />
|
|
||||||
</Tabs.Panel>
|
|
||||||
</Tabs>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import {
|
|
||||||
useMutation,
|
|
||||||
UseMutationResult,
|
|
||||||
useQuery,
|
|
||||||
UseQueryResult,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
generateAiContent,
|
|
||||||
generateAiContentStream,
|
|
||||||
} from "@/ee/ai/services/ai-service.ts";
|
|
||||||
import {
|
|
||||||
AiConfigResponse,
|
|
||||||
AiContentResponse,
|
|
||||||
AiGenerateDto,
|
|
||||||
AiStreamChunk,
|
|
||||||
AiStreamError,
|
|
||||||
} from "@/ee/ai/types/ai.types.ts";
|
|
||||||
|
|
||||||
export function useAiGenerateMutation(): UseMutationResult<
|
|
||||||
AiContentResponse,
|
|
||||||
Error,
|
|
||||||
AiGenerateDto
|
|
||||||
> {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: AiGenerateDto) => generateAiContent(data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StreamCallbacks {
|
|
||||||
onChunk: (chunk: AiStreamChunk) => void;
|
|
||||||
onError?: (error: AiStreamError) => void;
|
|
||||||
onComplete?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAiGenerateStreamMutation(): UseMutationResult<
|
|
||||||
AbortController,
|
|
||||||
Error,
|
|
||||||
AiGenerateDto & StreamCallbacks
|
|
||||||
> {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ onChunk, onError, onComplete, ...data }) =>
|
|
||||||
generateAiContentStream(data, onChunk, onError, onComplete),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import api from "@/lib/api-client.ts";
|
|
||||||
import { IPageSearchParams } from "@/features/search/types/search.types.ts";
|
|
||||||
|
|
||||||
export interface IAiSearchResponse {
|
|
||||||
answer: string;
|
|
||||||
sources?: Array<{
|
|
||||||
pageId: string;
|
|
||||||
title: string;
|
|
||||||
slugId: string;
|
|
||||||
spaceSlug: string;
|
|
||||||
similarity: number;
|
|
||||||
distance: number;
|
|
||||||
chunkIndex: number;
|
|
||||||
excerpt: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function aiAnswers(
|
|
||||||
params: IPageSearchParams,
|
|
||||||
onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
|
|
||||||
): Promise<IAiSearchResponse> {
|
|
||||||
const response = await fetch("/api/ai/answers", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify(params),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body?.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
|
|
||||||
let answer = "";
|
|
||||||
let sources: any[] = [];
|
|
||||||
let buffer = "";
|
|
||||||
|
|
||||||
if (reader) {
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const lines = buffer.split("\n");
|
|
||||||
|
|
||||||
// Keep the last incomplete line in the buffer
|
|
||||||
buffer = lines.pop() || "";
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith("data: ")) {
|
|
||||||
const data = line.slice(6);
|
|
||||||
if (data === "[DONE]") break;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(data);
|
|
||||||
if (parsed.error) {
|
|
||||||
throw new Error(parsed.error);
|
|
||||||
}
|
|
||||||
if (parsed.content) {
|
|
||||||
answer += parsed.content;
|
|
||||||
onChunk?.({ content: parsed.content });
|
|
||||||
}
|
|
||||||
if (parsed.sources) {
|
|
||||||
sources = parsed.sources;
|
|
||||||
onChunk?.({ sources: parsed.sources });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error) {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
// Skip invalid JSON
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { answer, sources };
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import api from "@/lib/api-client.ts";
|
|
||||||
import {
|
|
||||||
AiGenerateDto,
|
|
||||||
AiContentResponse,
|
|
||||||
AiStreamChunk,
|
|
||||||
AiStreamError,
|
|
||||||
} from "@/ee/ai/types/ai.types.ts";
|
|
||||||
|
|
||||||
export async function generateAiContent(
|
|
||||||
data: AiGenerateDto,
|
|
||||||
): Promise<AiContentResponse> {
|
|
||||||
const req = await api.post<AiContentResponse>("/ai/generate", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateAiContentStream(
|
|
||||||
data: AiGenerateDto,
|
|
||||||
onChunk: (chunk: AiStreamChunk) => void,
|
|
||||||
onError?: (error: AiStreamError) => void,
|
|
||||||
onComplete?: () => void,
|
|
||||||
): Promise<AbortController> {
|
|
||||||
const abortController = new AbortController();
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/ai/generate/stream", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
signal: abortController.signal,
|
|
||||||
credentials: "include", // This ensures cookies are sent, matching axios withCredentials
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body?.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
|
|
||||||
if (!reader) {
|
|
||||||
throw new Error("Response body is not readable");
|
|
||||||
}
|
|
||||||
|
|
||||||
const processStream = async () => {
|
|
||||||
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);
|
|
||||||
if (parsed.error) {
|
|
||||||
onError?.(parsed);
|
|
||||||
} else {
|
|
||||||
onChunk(parsed);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Skip invalid JSON
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name !== "AbortError") {
|
|
||||||
onError?.({ error: error.message });
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
reader.releaseLock();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
processStream();
|
|
||||||
} catch (error) {
|
|
||||||
onError?.({ error: error.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
return abortController;
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
export enum AiAction {
|
|
||||||
IMPROVE_WRITING = "improve_writing",
|
|
||||||
FIX_SPELLING_GRAMMAR = "fix_spelling_grammar",
|
|
||||||
MAKE_SHORTER = "make_shorter",
|
|
||||||
MAKE_LONGER = "make_longer",
|
|
||||||
SIMPLIFY = "simplify",
|
|
||||||
CHANGE_TONE = "change_tone",
|
|
||||||
SUMMARIZE = "summarize",
|
|
||||||
EXPLAIN = "explain",
|
|
||||||
CONTINUE_WRITING = "continue_writing",
|
|
||||||
TRANSLATE = "translate",
|
|
||||||
CUSTOM = "custom",
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiGenerateDto {
|
|
||||||
action?: AiAction;
|
|
||||||
content: string;
|
|
||||||
prompt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiContentResponse {
|
|
||||||
content: string;
|
|
||||||
usage?: {
|
|
||||||
promptTokens: number;
|
|
||||||
completionTokens: number;
|
|
||||||
totalTokens: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiConfigResponse {
|
|
||||||
configured: boolean;
|
|
||||||
availableActions: AiAction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiStreamChunk {
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiStreamError {
|
|
||||||
error: string;
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import {
|
|
||||||
Modal,
|
|
||||||
Text,
|
|
||||||
Stack,
|
|
||||||
Alert,
|
|
||||||
Group,
|
|
||||||
Button,
|
|
||||||
TextInput,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { IApiKey } from "@/ee/api-key";
|
|
||||||
import CopyTextButton from "@/components/common/copy.tsx";
|
|
||||||
|
|
||||||
interface ApiKeyCreatedModalProps {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
apiKey: IApiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ApiKeyCreatedModal({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
apiKey,
|
|
||||||
}: ApiKeyCreatedModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!apiKey) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={onClose}
|
|
||||||
title={t("{{credential}} created", { credential: t("API key") })}
|
|
||||||
size="lg"
|
|
||||||
closeButtonProps={{ "aria-label": t("Close") }}
|
|
||||||
>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Alert
|
|
||||||
icon={<IconAlertTriangle size={16} />}
|
|
||||||
title={t("Important")}
|
|
||||||
color="red"
|
|
||||||
>
|
|
||||||
{t(
|
|
||||||
"Make sure to copy your {{credential}} now. You won't be able to see it again!",
|
|
||||||
{ credential: t("API key") },
|
|
||||||
)}
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={500} mb="xs">
|
|
||||||
{t("API key")}
|
|
||||||
</Text>
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<TextInput
|
|
||||||
variant="filled"
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
value={apiKey.token}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CopyTextButton text={apiKey.token} />
|
|
||||||
</Group>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button fullWidth onClick={onClose} mt="md">
|
|
||||||
{t("I've saved my {{credential}}", { credential: t("API key") })}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
|
|
||||||
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { IApiKey } from "@/ee/api-key";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
|
||||||
import React from "react";
|
|
||||||
import NoTableResults from "@/components/common/no-table-results";
|
|
||||||
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
|
|
||||||
|
|
||||||
interface ApiKeyTableProps {
|
|
||||||
apiKeys: IApiKey[];
|
|
||||||
isLoading?: boolean;
|
|
||||||
showUserColumn?: boolean;
|
|
||||||
onUpdate?: (apiKey: IApiKey) => void;
|
|
||||||
onRevoke?: (apiKey: IApiKey) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ApiKeyTable({
|
|
||||||
apiKeys,
|
|
||||||
isLoading,
|
|
||||||
showUserColumn = false,
|
|
||||||
onUpdate,
|
|
||||||
onRevoke,
|
|
||||||
}: ApiKeyTableProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const locale = useDateFnsLocale();
|
|
||||||
|
|
||||||
const formatDate = (date: Date | string | null) => {
|
|
||||||
if (!date) return t("Never");
|
|
||||||
return formatLocalized(date, "MMM dd, yyyy", "PP", locale);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isExpired = (expiresAt: string | null) => {
|
|
||||||
if (!expiresAt) return false;
|
|
||||||
return new Date(expiresAt) < new Date();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table.ScrollContainer minWidth={500}>
|
|
||||||
<Table highlightOnHover verticalSpacing="sm">
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>{t("Name")}</Table.Th>
|
|
||||||
{showUserColumn && <Table.Th>{t("User")}</Table.Th>}
|
|
||||||
<Table.Th>{t("Last used")}</Table.Th>
|
|
||||||
<Table.Th>{t("Expires")}</Table.Th>
|
|
||||||
<Table.Th>{t("Created")}</Table.Th>
|
|
||||||
<Table.Th aria-label={t("Action")} />
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
|
|
||||||
<Table.Tbody>
|
|
||||||
{apiKeys && apiKeys.length > 0 ? (
|
|
||||||
apiKeys.map((apiKey: IApiKey, index: number) => (
|
|
||||||
<Table.Tr key={index}>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm" fw={500}>
|
|
||||||
{apiKey.name}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
|
|
||||||
{showUserColumn && apiKey.creator && (
|
|
||||||
<Table.Td>
|
|
||||||
<Group gap="4" wrap="nowrap">
|
|
||||||
<CustomAvatar
|
|
||||||
avatarUrl={apiKey.creator?.avatarUrl}
|
|
||||||
name={apiKey.creator.name}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<Text fz="sm" lineClamp={1}>
|
|
||||||
{apiKey.creator.name}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{formatDate(apiKey.lastUsedAt)}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
|
|
||||||
<Table.Td>
|
|
||||||
{apiKey.expiresAt ? (
|
|
||||||
isExpired(apiKey.expiresAt) ? (
|
|
||||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{t("Expired")}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{formatDate(apiKey.expiresAt)}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{t("Never")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Table.Td>
|
|
||||||
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{formatDate(apiKey.createdAt)}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
|
|
||||||
<Table.Td>
|
|
||||||
<Menu position="bottom-end" withinPortal>
|
|
||||||
<Menu.Target>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
aria-label={t("API key menu")}
|
|
||||||
>
|
|
||||||
<IconDots size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
{onUpdate && (
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconEdit size={16} />}
|
|
||||||
onClick={() => onUpdate(apiKey)}
|
|
||||||
>
|
|
||||||
{t("Rename")}
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{onRevoke && (
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconTrash size={16} />}
|
|
||||||
color="red"
|
|
||||||
onClick={() => onRevoke(apiKey)}
|
|
||||||
>
|
|
||||||
{t("Revoke")}
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<NoTableResults colSpan={showUserColumn ? 6 : 5} />
|
|
||||||
)}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Table.ScrollContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
import { lazy, Suspense, useState } from "react";
|
|
||||||
import { Modal, TextInput, Button, Group, Stack, Select } from "@mantine/core";
|
|
||||||
import { useForm } from "@mantine/form";
|
|
||||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
|
||||||
import { z } from "zod/v4";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useCreateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
|
|
||||||
import { IconCalendar } from "@tabler/icons-react";
|
|
||||||
import { IApiKey } from "@/ee/api-key";
|
|
||||||
|
|
||||||
const DateInput = lazy(() =>
|
|
||||||
import("@mantine/dates").then((module) => ({
|
|
||||||
default: module.DateInput,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
interface CreateApiKeyModalProps {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess: (response: IApiKey) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
name: z.string().min(1, "Name is required"),
|
|
||||||
expiresAt: z.string().optional(),
|
|
||||||
});
|
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
|
||||||
|
|
||||||
export function CreateApiKeyModal({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
onSuccess,
|
|
||||||
}: CreateApiKeyModalProps) {
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
const [expirationOption, setExpirationOption] = useState<string>("30");
|
|
||||||
const createApiKeyMutation = useCreateApiKeyMutation();
|
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
|
||||||
validate: zod4Resolver(formSchema),
|
|
||||||
initialValues: {
|
|
||||||
name: "",
|
|
||||||
expiresAt: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const getExpirationDate = (): string | undefined => {
|
|
||||||
if (expirationOption === "never") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (expirationOption === "custom") {
|
|
||||||
return form.values.expiresAt;
|
|
||||||
}
|
|
||||||
const days = parseInt(expirationOption);
|
|
||||||
const date = new Date();
|
|
||||||
date.setDate(date.getDate() + days);
|
|
||||||
return date.toISOString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const getExpirationLabel = (days: number) => {
|
|
||||||
const date = new Date();
|
|
||||||
date.setDate(date.getDate() + days);
|
|
||||||
const formatted = date.toLocaleDateString(i18n.language, {
|
|
||||||
month: "short",
|
|
||||||
day: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
return `${days} days (${formatted})`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const expirationOptions = [
|
|
||||||
{ value: "30", label: getExpirationLabel(30) },
|
|
||||||
{ value: "60", label: getExpirationLabel(60) },
|
|
||||||
{ value: "90", label: getExpirationLabel(90) },
|
|
||||||
{ value: "365", label: getExpirationLabel(365) },
|
|
||||||
{ value: "custom", label: t("Custom") },
|
|
||||||
{ value: "never", label: t("No expiration") },
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleSubmit = async (data: {
|
|
||||||
name?: string;
|
|
||||||
expiresAt?: string | Date;
|
|
||||||
}) => {
|
|
||||||
const groupData = {
|
|
||||||
name: data.name,
|
|
||||||
expiresAt: getExpirationDate(),
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const createdKey = await createApiKeyMutation.mutateAsync(groupData);
|
|
||||||
onSuccess(createdKey);
|
|
||||||
form.reset();
|
|
||||||
onClose();
|
|
||||||
} catch (err) {
|
|
||||||
//
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
form.reset();
|
|
||||||
setExpirationOption("30");
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={handleClose}
|
|
||||||
title={t("Create {{credential}}", { credential: t("API key") })}
|
|
||||||
size="md"
|
|
||||||
closeButtonProps={{ "aria-label": t("Close") }}
|
|
||||||
>
|
|
||||||
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<TextInput
|
|
||||||
label={t("Name")}
|
|
||||||
placeholder={t("Enter a descriptive name")}
|
|
||||||
data-autofocus
|
|
||||||
required
|
|
||||||
{...form.getInputProps("name")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
label={t("Expiration")}
|
|
||||||
data={expirationOptions}
|
|
||||||
value={expirationOption}
|
|
||||||
onChange={(value) => setExpirationOption(value || "30")}
|
|
||||||
leftSection={<IconCalendar size={16} />}
|
|
||||||
allowDeselect={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{expirationOption === "custom" && (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<DateInput
|
|
||||||
label={t("Custom expiration date")}
|
|
||||||
placeholder={t("Select expiration date")}
|
|
||||||
minDate={new Date()}
|
|
||||||
{...form.getInputProps("expiresAt")}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Group justify="flex-end" mt="md">
|
|
||||||
<Button variant="default" onClick={handleClose}>
|
|
||||||
{t("Cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" loading={createApiKeyMutation.isPending}>
|
|
||||||
{t("Create")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { Text, Switch, Tooltip } from "@mantine/core";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
|
||||||
import { Feature } from "@/ee/features";
|
|
||||||
import {
|
|
||||||
ResponsiveSettingsRow,
|
|
||||||
ResponsiveSettingsContent,
|
|
||||||
ResponsiveSettingsControl,
|
|
||||||
} from "@/components/ui/responsive-settings-row";
|
|
||||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
|
|
||||||
|
|
||||||
export default function RestrictApiToAdmins() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
|
||||||
const [checked, setChecked] = useState(
|
|
||||||
workspace?.settings?.api?.restrictToAdmins === true,
|
|
||||||
);
|
|
||||||
const hasAccess = useHasFeature(Feature.API_KEYS);
|
|
||||||
const upgradeLabel = useUpgradeLabel();
|
|
||||||
|
|
||||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = event.currentTarget.checked;
|
|
||||||
try {
|
|
||||||
const updatedWorkspace = await updateWorkspace({
|
|
||||||
restrictApiToAdmins: value,
|
|
||||||
});
|
|
||||||
setChecked(value);
|
|
||||||
setWorkspace(updatedWorkspace);
|
|
||||||
} catch (err) {
|
|
||||||
notifications.show({
|
|
||||||
message: err?.response?.data?.message,
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResponsiveSettingsRow>
|
|
||||||
<ResponsiveSettingsContent>
|
|
||||||
<Text size="md">
|
|
||||||
{t("Restrict API key creation to admins")}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t(
|
|
||||||
"Only admins and owners can create new API keys. Existing member keys will continue to work.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</ResponsiveSettingsContent>
|
|
||||||
|
|
||||||
<ResponsiveSettingsControl>
|
|
||||||
<Tooltip
|
|
||||||
label={upgradeLabel}
|
|
||||||
disabled={hasAccess}
|
|
||||||
refProp="rootRef"
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
checked={checked}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled={!hasAccess}
|
|
||||||
aria-label={t("Toggle restrict API keys to admins")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</ResponsiveSettingsControl>
|
|
||||||
</ResponsiveSettingsRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useRevokeApiKeyMutation } from "@/ee/api-key/queries/api-key-query.ts";
|
|
||||||
import { IApiKey } from "@/ee/api-key";
|
|
||||||
|
|
||||||
interface RevokeApiKeyModalProps {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
apiKey: IApiKey | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RevokeApiKeyModal({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
apiKey,
|
|
||||||
}: RevokeApiKeyModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const revokeApiKeyMutation = useRevokeApiKeyMutation();
|
|
||||||
|
|
||||||
const handleRevoke = async () => {
|
|
||||||
if (!apiKey) return;
|
|
||||||
await revokeApiKeyMutation.mutateAsync({
|
|
||||||
apiKeyId: apiKey.id,
|
|
||||||
});
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={onClose}
|
|
||||||
title={t("Revoke {{credential}}", { credential: t("API key") })}
|
|
||||||
size="md"
|
|
||||||
closeButtonProps={{ "aria-label": t("Close") }}
|
|
||||||
>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Text>
|
|
||||||
{t("Are you sure you want to revoke this {{credential}}", {
|
|
||||||
credential: t("API key"),
|
|
||||||
})}{" "}
|
|
||||||
<strong>{apiKey?.name}</strong>?
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t(
|
|
||||||
"This action cannot be undone. Any applications using this API key will stop working.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Group justify="flex-end" mt="md">
|
|
||||||
<Button variant="default" onClick={onClose}>
|
|
||||||
{t("Cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="red"
|
|
||||||
onClick={handleRevoke}
|
|
||||||
loading={revokeApiKeyMutation.isPending}
|
|
||||||
>
|
|
||||||
{t("Revoke")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
|
|
||||||
import { useForm } from "@mantine/form";
|
|
||||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
|
||||||
import { z } from "zod/v4";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useUpdateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
|
|
||||||
import { IApiKey } from "@/ee/api-key";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
name: z.string().min(1, "Name is required"),
|
|
||||||
});
|
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
|
||||||
|
|
||||||
interface UpdateApiKeyModalProps {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
apiKey: IApiKey | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UpdateApiKeyModal({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
apiKey,
|
|
||||||
}: UpdateApiKeyModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const updateApiKeyMutation = useUpdateApiKeyMutation();
|
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
|
||||||
validate: zod4Resolver(formSchema),
|
|
||||||
initialValues: {
|
|
||||||
name: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (opened && apiKey) {
|
|
||||||
form.setValues({ name: apiKey.name });
|
|
||||||
}
|
|
||||||
}, [opened, apiKey]);
|
|
||||||
|
|
||||||
const handleSubmit = async (data: { name?: string }) => {
|
|
||||||
const apiKeyData = {
|
|
||||||
apiKeyId: apiKey.id,
|
|
||||||
name: data.name,
|
|
||||||
};
|
|
||||||
|
|
||||||
await updateApiKeyMutation.mutateAsync(apiKeyData);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={onClose}
|
|
||||||
title={t("Update {{credential}}", { credential: t("API key") })}
|
|
||||||
size="md"
|
|
||||||
closeButtonProps={{ "aria-label": t("Close") }}
|
|
||||||
>
|
|
||||||
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<TextInput
|
|
||||||
label={t("Name")}
|
|
||||||
placeholder={t("Enter a descriptive token name")}
|
|
||||||
required
|
|
||||||
{...form.getInputProps("name")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Group justify="flex-end" mt="md">
|
|
||||||
<Button variant="default" onClick={onClose}>
|
|
||||||
{t("Cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" loading={updateApiKeyMutation.isPending}>
|
|
||||||
{t("Update")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export { ApiKeyTable } from "./components/api-key-table";
|
|
||||||
export { CreateApiKeyModal } from "./components/create-api-key-modal";
|
|
||||||
export { ApiKeyCreatedModal } from "./components/api-key-created-modal";
|
|
||||||
export { UpdateApiKeyModal } from "./components/update-api-key-modal";
|
|
||||||
export { RevokeApiKeyModal } from "./components/revoke-api-key-modal";
|
|
||||||
|
|
||||||
// Services
|
|
||||||
export * from "./services/api-key-service";
|
|
||||||
|
|
||||||
// Types
|
|
||||||
export * from "./types/api-key.types";
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core";
|
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
|
||||||
import { Helmet } from "react-helmet-async";
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
|
||||||
import SettingsTitle from "@/components/settings/settings-title";
|
|
||||||
import { getAppName, getAppUrl } from "@/lib/config";
|
|
||||||
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
|
|
||||||
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
|
|
||||||
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
|
|
||||||
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
|
|
||||||
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
|
|
||||||
import Paginate from "@/components/common/paginate";
|
|
||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
|
||||||
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
|
|
||||||
import { IApiKey } from "@/ee/api-key";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
|
||||||
|
|
||||||
export default function UserApiKeys() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
|
||||||
const [createModalOpened, setCreateModalOpened] = useState(false);
|
|
||||||
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
|
|
||||||
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
|
||||||
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
|
|
||||||
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
|
|
||||||
const { data, isLoading } = useGetApiKeysQuery({ cursor });
|
|
||||||
const [workspace] = useAtom(workspaceAtom);
|
|
||||||
const { isAdmin } = useUserRole();
|
|
||||||
const mcpEnabled = workspace?.settings?.ai?.mcp === true;
|
|
||||||
const restrictToAdmins = workspace?.settings?.api?.restrictToAdmins === true;
|
|
||||||
const canCreate = !restrictToAdmins || isAdmin;
|
|
||||||
|
|
||||||
const handleCreateSuccess = (response: IApiKey) => {
|
|
||||||
setCreatedApiKey(response);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdate = (apiKey: IApiKey) => {
|
|
||||||
setSelectedApiKey(apiKey);
|
|
||||||
setUpdateModalOpened(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRevoke = (apiKey: IApiKey) => {
|
|
||||||
setSelectedApiKey(apiKey);
|
|
||||||
setRevokeModalOpened(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Helmet>
|
|
||||||
<title>
|
|
||||||
{t("API keys")} - {getAppName()}
|
|
||||||
</title>
|
|
||||||
</Helmet>
|
|
||||||
|
|
||||||
<SettingsTitle title={t("API keys")} />
|
|
||||||
|
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
|
||||||
<Trans
|
|
||||||
i18nKey="View the <anchor>API documentation</anchor> for usage details."
|
|
||||||
components={{
|
|
||||||
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{mcpEnabled && canCreate && (
|
|
||||||
<Alert variant="light" color="blue" mb="md" p="sm" icon={<IconInfoCircle />}>
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"Your workspace has MCP enabled. Use your API key to connect AI assistants.",
|
|
||||||
)}{" "}
|
|
||||||
<Anchor
|
|
||||||
href="https://docmost.com/docs/user-guide/mcp"
|
|
||||||
target="_blank"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{t("Learn more")}
|
|
||||||
</Anchor>
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" mt={4}>
|
|
||||||
{t("MCP server URL:")}{" "}
|
|
||||||
<Text size="sm" fw={500} span ff="monospace">
|
|
||||||
{`${getAppUrl()}/mcp`}
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canCreate ? (
|
|
||||||
<Group justify="flex-end" mb="md">
|
|
||||||
<Button onClick={() => setCreateModalOpened(true)}>
|
|
||||||
{t("Create API Key")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
) : restrictToAdmins ? (
|
|
||||||
<Alert variant="light" color="yellow" mb="md" p="sm" icon={<IconInfoCircle />}>
|
|
||||||
<Text size="sm">
|
|
||||||
{t("API key creation is restricted to admins by your workspace administrator.")}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ApiKeyTable
|
|
||||||
apiKeys={data?.items || []}
|
|
||||||
isLoading={isLoading}
|
|
||||||
onUpdate={handleUpdate}
|
|
||||||
onRevoke={handleRevoke}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Space h="md" />
|
|
||||||
|
|
||||||
{data?.items.length > 0 && (
|
|
||||||
<Paginate
|
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
|
||||||
onPrev={goPrev}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CreateApiKeyModal
|
|
||||||
opened={createModalOpened}
|
|
||||||
onClose={() => setCreateModalOpened(false)}
|
|
||||||
onSuccess={handleCreateSuccess}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ApiKeyCreatedModal
|
|
||||||
opened={!!createdApiKey}
|
|
||||||
onClose={() => setCreatedApiKey(null)}
|
|
||||||
apiKey={createdApiKey}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UpdateApiKeyModal
|
|
||||||
opened={updateModalOpened}
|
|
||||||
onClose={() => {
|
|
||||||
setUpdateModalOpened(false);
|
|
||||||
setSelectedApiKey(null);
|
|
||||||
}}
|
|
||||||
apiKey={selectedApiKey}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RevokeApiKeyModal
|
|
||||||
opened={revokeModalOpened}
|
|
||||||
onClose={() => {
|
|
||||||
setRevokeModalOpened(false);
|
|
||||||
setSelectedApiKey(null);
|
|
||||||
}}
|
|
||||||
apiKey={selectedApiKey}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { Anchor, Button, Divider, Group, Space, Text } from "@mantine/core";
|
|
||||||
import { Helmet } from "react-helmet-async";
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
|
||||||
import SettingsTitle from "@/components/settings/settings-title";
|
|
||||||
import { getAppName } from "@/lib/config";
|
|
||||||
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
|
|
||||||
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
|
|
||||||
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
|
|
||||||
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
|
|
||||||
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
|
|
||||||
import Paginate from "@/components/common/paginate";
|
|
||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
|
||||||
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
|
|
||||||
import { IApiKey } from "@/ee/api-key";
|
|
||||||
import useUserRole from '@/hooks/use-user-role.tsx';
|
|
||||||
import RestrictApiToAdmins from "@/ee/api-key/components/restrict-api-to-admins";
|
|
||||||
|
|
||||||
export default function WorkspaceApiKeys() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
|
||||||
const [createModalOpened, setCreateModalOpened] = useState(false);
|
|
||||||
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
|
|
||||||
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
|
||||||
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
|
|
||||||
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
|
|
||||||
const { data, isLoading } = useGetApiKeysQuery({ cursor, adminView: true });
|
|
||||||
const { isAdmin } = useUserRole();
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateSuccess = (response: IApiKey) => {
|
|
||||||
setCreatedApiKey(response);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdate = (apiKey: IApiKey) => {
|
|
||||||
setSelectedApiKey(apiKey);
|
|
||||||
setUpdateModalOpened(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRevoke = (apiKey: IApiKey) => {
|
|
||||||
setSelectedApiKey(apiKey);
|
|
||||||
setRevokeModalOpened(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Helmet>
|
|
||||||
<title>
|
|
||||||
{t("API management")} - {getAppName()}
|
|
||||||
</title>
|
|
||||||
</Helmet>
|
|
||||||
|
|
||||||
<SettingsTitle title={t("API management")} />
|
|
||||||
|
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
|
||||||
<Trans
|
|
||||||
i18nKey="Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details."
|
|
||||||
components={{
|
|
||||||
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<RestrictApiToAdmins />
|
|
||||||
<Divider my="lg" />
|
|
||||||
|
|
||||||
<Group justify="flex-end" mb="md">
|
|
||||||
<Button onClick={() => setCreateModalOpened(true)}>
|
|
||||||
{t("Create API Key")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<ApiKeyTable
|
|
||||||
apiKeys={data?.items}
|
|
||||||
isLoading={isLoading}
|
|
||||||
showUserColumn
|
|
||||||
onUpdate={handleUpdate}
|
|
||||||
onRevoke={handleRevoke}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Space h="md" />
|
|
||||||
|
|
||||||
{data?.items.length > 0 && (
|
|
||||||
<Paginate
|
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
|
||||||
onPrev={goPrev}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CreateApiKeyModal
|
|
||||||
opened={createModalOpened}
|
|
||||||
onClose={() => setCreateModalOpened(false)}
|
|
||||||
onSuccess={handleCreateSuccess}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ApiKeyCreatedModal
|
|
||||||
opened={!!createdApiKey}
|
|
||||||
onClose={() => setCreatedApiKey(null)}
|
|
||||||
apiKey={createdApiKey}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UpdateApiKeyModal
|
|
||||||
opened={updateModalOpened}
|
|
||||||
onClose={() => {
|
|
||||||
setUpdateModalOpened(false);
|
|
||||||
setSelectedApiKey(null);
|
|
||||||
}}
|
|
||||||
apiKey={selectedApiKey}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RevokeApiKeyModal
|
|
||||||
opened={revokeModalOpened}
|
|
||||||
onClose={() => {
|
|
||||||
setRevokeModalOpened(false);
|
|
||||||
setSelectedApiKey(null);
|
|
||||||
}}
|
|
||||||
apiKey={selectedApiKey}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
|
||||||
import {
|
|
||||||
keepPreviousData,
|
|
||||||
useMutation,
|
|
||||||
useQuery,
|
|
||||||
useQueryClient,
|
|
||||||
UseQueryResult,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
createApiKey,
|
|
||||||
getApiKeys,
|
|
||||||
IApiKey,
|
|
||||||
ICreateApiKeyRequest,
|
|
||||||
IUpdateApiKeyRequest,
|
|
||||||
revokeApiKey,
|
|
||||||
updateApiKey,
|
|
||||||
} from "@/ee/api-key";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export function useGetApiKeysQuery(
|
|
||||||
params?: QueryParams,
|
|
||||||
): UseQueryResult<IPagination<IApiKey>, Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["api-key-list", params],
|
|
||||||
queryFn: () => getApiKeys(params),
|
|
||||||
staleTime: 0,
|
|
||||||
gcTime: 0,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRevokeApiKeyMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useMutation<
|
|
||||||
void,
|
|
||||||
Error,
|
|
||||||
{
|
|
||||||
apiKeyId: string;
|
|
||||||
}
|
|
||||||
>({
|
|
||||||
mutationFn: (data) => revokeApiKey(data),
|
|
||||||
onSuccess: (data, variables) => {
|
|
||||||
notifications.show({ message: t("Revoked successfully") });
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
predicate: (item) =>
|
|
||||||
["api-key-list"].includes(item.queryKey[0] as string),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({ message: errorMessage, color: "red" });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateApiKeyMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useMutation<IApiKey, Error, ICreateApiKeyRequest>({
|
|
||||||
mutationFn: (data) => createApiKey(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
notifications.show({
|
|
||||||
message: t("{{credential}} created successfully", {
|
|
||||||
credential: t("API key"),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
predicate: (item) =>
|
|
||||||
["api-key-list"].includes(item.queryKey[0] as string),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({ message: errorMessage, color: "red" });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateApiKeyMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useMutation<IApiKey, Error, IUpdateApiKeyRequest>({
|
|
||||||
mutationFn: (data) => updateApiKey(data),
|
|
||||||
onSuccess: (data, variables) => {
|
|
||||||
notifications.show({ message: t("Updated successfully") });
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
predicate: (item) =>
|
|
||||||
["api-key-list"].includes(item.queryKey[0] as string),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({ message: errorMessage, color: "red" });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import api from "@/lib/api-client";
|
|
||||||
import {
|
|
||||||
ICreateApiKeyRequest,
|
|
||||||
IApiKey,
|
|
||||||
IUpdateApiKeyRequest,
|
|
||||||
} from "@/ee/api-key/types/api-key.types";
|
|
||||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
|
||||||
|
|
||||||
export async function getApiKeys(
|
|
||||||
params?: QueryParams,
|
|
||||||
): Promise<IPagination<IApiKey>> {
|
|
||||||
const req = await api.post("/api-keys", { ...params });
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createApiKey(
|
|
||||||
data: ICreateApiKeyRequest,
|
|
||||||
): Promise<IApiKey> {
|
|
||||||
const req = await api.post<IApiKey>("/api-keys/create", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateApiKey(
|
|
||||||
data: IUpdateApiKeyRequest,
|
|
||||||
): Promise<IApiKey> {
|
|
||||||
const req = await api.post<IApiKey>("/api-keys/update", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function revokeApiKey(data: { apiKeyId: string }): Promise<void> {
|
|
||||||
await api.post("/api-keys/revoke", data);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { IUser } from "@/features/user/types/user.types.ts";
|
|
||||||
|
|
||||||
export interface IApiKey {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
token?: string;
|
|
||||||
creatorId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
expiresAt: string | null;
|
|
||||||
lastUsedAt: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
creator: Partial<IUser>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ICreateApiKeyRequest {
|
|
||||||
name: string;
|
|
||||||
expiresAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IUpdateApiKeyRequest {
|
|
||||||
apiKeyId: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
import { Fragment, useState } from "react";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
Group,
|
|
||||||
Skeleton,
|
|
||||||
Anchor,
|
|
||||||
Collapse,
|
|
||||||
Box,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
IconChevronRight,
|
|
||||||
IconChevronDown,
|
|
||||||
IconArrowRight,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { IAuditLog } from "@/ee/audit/types/audit.types";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
|
||||||
import { getEventLabel } from "@/ee/audit/lib/audit-event-labels";
|
|
||||||
import { formattedDate } from "@/lib/time";
|
|
||||||
import NoTableResults from "@/components/common/no-table-results";
|
|
||||||
import classes from "./audit-logs.module.css";
|
|
||||||
|
|
||||||
type AuditLogsTableProps = {
|
|
||||||
items?: IAuditLog[];
|
|
||||||
isLoading: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function hasDetails(entry: IAuditLog): boolean {
|
|
||||||
return !!(entry.changes?.before || entry.changes?.after || entry.metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getResourceUrl(entry: IAuditLog): string | null {
|
|
||||||
if (!entry.resource) return null;
|
|
||||||
|
|
||||||
switch (entry.resourceType) {
|
|
||||||
case "group":
|
|
||||||
return `/settings/groups/${entry.resource.id}`;
|
|
||||||
case "space":
|
|
||||||
case "space_member":
|
|
||||||
return entry.resource.slug ? `/s/${entry.resource.slug}` : null;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatValue(value: unknown): string {
|
|
||||||
if (value === null || value === undefined) return "—";
|
|
||||||
if (typeof value === "boolean") return value ? "true" : "false";
|
|
||||||
if (typeof value === "object") return JSON.stringify(value);
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChangesDiff({ changes }: { changes: IAuditLog["changes"] }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
if (!changes) return null;
|
|
||||||
|
|
||||||
const { before, after } = changes;
|
|
||||||
const allKeys = new Set([
|
|
||||||
...Object.keys(before ?? {}),
|
|
||||||
...Object.keys(after ?? {}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (allKeys.size === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Text fz="xs" fw={600} mb={4}>
|
|
||||||
{t("Changes")}
|
|
||||||
</Text>
|
|
||||||
{[...allKeys].map((key) => {
|
|
||||||
const hasBefore = before && key in before;
|
|
||||||
const hasAfter = after && key in after;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group key={key} gap={6} mb={2} wrap="nowrap" align="center">
|
|
||||||
<Text
|
|
||||||
fz="xs"
|
|
||||||
c="dimmed"
|
|
||||||
fw={500}
|
|
||||||
style={{ minWidth: "fit-content" }}
|
|
||||||
>
|
|
||||||
{key}:
|
|
||||||
</Text>
|
|
||||||
{hasBefore && (
|
|
||||||
<Text fz="xs" component="span">
|
|
||||||
{formatValue(before[key])}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{hasBefore && hasAfter && (
|
|
||||||
<IconArrowRight size={10} color="var(--mantine-color-dimmed)" />
|
|
||||||
)}
|
|
||||||
{hasAfter && (
|
|
||||||
<Text fz="xs" component="span">
|
|
||||||
{formatValue(after[key])}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetadataDisplay({ metadata }: { metadata: Record<string, any> }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const entries = Object.entries(metadata);
|
|
||||||
if (entries.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Text fz="xs" fw={600} mb={4}>
|
|
||||||
{t("Metadata")}
|
|
||||||
</Text>
|
|
||||||
{entries.map(([key, value]) => (
|
|
||||||
<Group key={key} gap={6} mb={2} wrap="nowrap">
|
|
||||||
<Text fz="xs" c="dimmed" fw={500}>
|
|
||||||
{key}:
|
|
||||||
</Text>
|
|
||||||
<Text fz="xs">{formatValue(value)}</Text>
|
|
||||||
</Group>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TableSkeleton() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
|
||||||
<Table.Tr key={i}>
|
|
||||||
<Table.Td>
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<Skeleton circle height={36} />
|
|
||||||
<div>
|
|
||||||
<Skeleton height={14} width={120} mb={4} />
|
|
||||||
<Skeleton height={10} width={160} />
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={140} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={120} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={120} />
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResourceCell({ entry }: { entry: IAuditLog }) {
|
|
||||||
if (!entry.resource?.name) {
|
|
||||||
return (
|
|
||||||
<Text fz="sm" c="dimmed">
|
|
||||||
—
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = getResourceUrl(entry);
|
|
||||||
|
|
||||||
if (url) {
|
|
||||||
return (
|
|
||||||
<Anchor
|
|
||||||
size="sm"
|
|
||||||
underline="never"
|
|
||||||
style={{
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "var(--mantine-color-text)",
|
|
||||||
}}
|
|
||||||
component={Link}
|
|
||||||
to={url}
|
|
||||||
>
|
|
||||||
<div className={classes.resourceLinkText}>
|
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
|
||||||
{entry.resource.name}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Anchor>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text fz="sm" lineClamp={1}>
|
|
||||||
{entry.resource.name}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AuditLogsTable({
|
|
||||||
items,
|
|
||||||
isLoading,
|
|
||||||
}: AuditLogsTableProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const toggleExpanded = (id: string) => {
|
|
||||||
setExpanded((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(id)) {
|
|
||||||
next.delete(id);
|
|
||||||
} else {
|
|
||||||
next.add(id);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table.ScrollContainer minWidth={700}>
|
|
||||||
<Table highlightOnHover verticalSpacing="xs" className={classes.table}>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>{t("Actor")}</Table.Th>
|
|
||||||
<Table.Th>{t("Event")}</Table.Th>
|
|
||||||
<Table.Th>{t("Resource")}</Table.Th>
|
|
||||||
<Table.Th>{t("Date")}</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
|
|
||||||
<Table.Tbody>
|
|
||||||
{isLoading ? (
|
|
||||||
<TableSkeleton />
|
|
||||||
) : items && items.length > 0 ? (
|
|
||||||
items.map((entry) => {
|
|
||||||
const expandable = hasDetails(entry);
|
|
||||||
const isExpanded = expanded.has(entry.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment key={entry.id}>
|
|
||||||
<Table.Tr
|
|
||||||
onClick={
|
|
||||||
expandable ? () => toggleExpanded(entry.id) : undefined
|
|
||||||
}
|
|
||||||
style={{ cursor: expandable ? "pointer" : undefined }}
|
|
||||||
>
|
|
||||||
<Table.Td>
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
{expandable ? (
|
|
||||||
isExpanded ? (
|
|
||||||
<IconChevronDown
|
|
||||||
size={16}
|
|
||||||
color="var(--mantine-color-dimmed)"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<IconChevronRight
|
|
||||||
size={16}
|
|
||||||
color="var(--mantine-color-dimmed)"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<Box w={16} />
|
|
||||||
)}
|
|
||||||
{entry.actor ? (
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<CustomAvatar
|
|
||||||
avatarUrl={entry.actor.avatarUrl}
|
|
||||||
name={entry.actor.name}
|
|
||||||
size={36}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
|
||||||
{entry.actor.name}
|
|
||||||
</Text>
|
|
||||||
<Text fz="xs" c="dimmed">
|
|
||||||
{entry.actor.email}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
) : (
|
|
||||||
<Text fz="sm" c="dimmed" fs="italic">
|
|
||||||
{entry.actorType === "system"
|
|
||||||
? t("System")
|
|
||||||
: t("System")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm">{t(getEventLabel(entry.event))}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
|
|
||||||
<Table.Td>
|
|
||||||
<ResourceCell entry={entry} />
|
|
||||||
</Table.Td>
|
|
||||||
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{formattedDate(new Date(entry.createdAt))}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
|
|
||||||
{expandable && (
|
|
||||||
<Table.Tr className={classes.detailRow}>
|
|
||||||
<Table.Td colSpan={4} p={0}>
|
|
||||||
<Collapse in={isExpanded}>
|
|
||||||
<Box
|
|
||||||
px="md"
|
|
||||||
py="sm"
|
|
||||||
className={classes.detailContent}
|
|
||||||
>
|
|
||||||
<Group gap="xl" align="flex-start">
|
|
||||||
{entry.changes && (
|
|
||||||
<ChangesDiff changes={entry.changes} />
|
|
||||||
)}
|
|
||||||
{entry.metadata && (
|
|
||||||
<MetadataDisplay metadata={entry.metadata} />
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Box>
|
|
||||||
</Collapse>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<NoTableResults colSpan={4} />
|
|
||||||
)}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Table.ScrollContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
.table {
|
|
||||||
--table-border-color: var(--mantine-color-gray-2);
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
--table-border-color: var(--mantine-color-dark-5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.resourceLinkText {
|
|
||||||
width: fit-content;
|
|
||||||
|
|
||||||
@mixin light {
|
|
||||||
border-bottom: 0.05em solid var(--mantine-color-dark-0);
|
|
||||||
}
|
|
||||||
@mixin dark {
|
|
||||||
border-bottom: 0.05em solid var(--mantine-color-dark-2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailRow {
|
|
||||||
&:hover {
|
|
||||||
background: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailContent {
|
|
||||||
@mixin light {
|
|
||||||
background: var(--mantine-color-gray-0);
|
|
||||||
}
|
|
||||||
@mixin dark {
|
|
||||||
background: var(--mantine-color-dark-7);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
type EventOption = {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type EventGroup = {
|
|
||||||
group: string;
|
|
||||||
items: EventOption[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const auditEventLabels: Record<string, string> = {
|
|
||||||
"workspace.created": "Created workspace",
|
|
||||||
"workspace.updated": "Updated workspace",
|
|
||||||
"workspace.invite_created": "Created invitation",
|
|
||||||
"workspace.invite_resent": "Resent invitation",
|
|
||||||
"workspace.invite_revoked": "Revoked invitation",
|
|
||||||
|
|
||||||
"user.created": "Created user",
|
|
||||||
"user.deleted": "Deleted user",
|
|
||||||
"user.login": "Logged in",
|
|
||||||
"user.logout": "Logged out",
|
|
||||||
"user.role_changed": "Changed user role",
|
|
||||||
"user.password_changed": "Changed password",
|
|
||||||
"user.password_reset": "Reset password",
|
|
||||||
"user.updated": "Updated user",
|
|
||||||
"user.deactivated": "Deactivated user",
|
|
||||||
"user.activated": "Activated user",
|
|
||||||
"user.mfa_enabled": "Enabled MFA",
|
|
||||||
"user.mfa_disabled": "Disabled MFA",
|
|
||||||
"user.mfa_backup_code_generated": "Generated MFA backup codes",
|
|
||||||
|
|
||||||
"api_key.created": "Created API key",
|
|
||||||
"api_key.updated": "Updated API key",
|
|
||||||
"api_key.deleted": "Deleted API key",
|
|
||||||
|
|
||||||
"scim_token.created": "Created SCIM token",
|
|
||||||
"scim_token.updated": "Updated SCIM token",
|
|
||||||
"scim_token.deleted": "Deleted SCIM token",
|
|
||||||
|
|
||||||
"space.created": "Created space",
|
|
||||||
"space.updated": "Updated space",
|
|
||||||
"space.deleted": "Deleted space",
|
|
||||||
"space.member_added": "Added space member",
|
|
||||||
"space.member_removed": "Removed space member",
|
|
||||||
"space.member_role_changed": "Changed space member role",
|
|
||||||
"space.exported": "Exported space",
|
|
||||||
|
|
||||||
"group.created": "Created group",
|
|
||||||
"group.updated": "Updated group",
|
|
||||||
"group.deleted": "Deleted group",
|
|
||||||
"group.member_added": "Added group member",
|
|
||||||
"group.member_removed": "Removed group member",
|
|
||||||
|
|
||||||
"comment.deleted": "Deleted comment",
|
|
||||||
|
|
||||||
"page.trashed": "Trashed page",
|
|
||||||
"page.deleted": "Deleted page",
|
|
||||||
"page.restored": "Restored page",
|
|
||||||
"page.imported": "Imported page",
|
|
||||||
"page.exported": "Exported page",
|
|
||||||
"page.restricted": "Restricted page",
|
|
||||||
"page.restriction_removed": "Removed page restriction",
|
|
||||||
"page.permission_added": "Added page permission",
|
|
||||||
"page.permission_removed": "Removed page permission",
|
|
||||||
"page.verification_created": "Created page verification",
|
|
||||||
"page.verification_updated": "Updated page verification",
|
|
||||||
"page.verification_removed": "Removed page verification",
|
|
||||||
"page.verified": "Verified page",
|
|
||||||
"page.approval_requested": "Requested page approval",
|
|
||||||
"page.approval_rejected": "Rejected page approval",
|
|
||||||
"page.marked_obsolete": "Marked page as obsolete",
|
|
||||||
|
|
||||||
"share.created": "Created share link",
|
|
||||||
"share.deleted": "Deleted share link",
|
|
||||||
|
|
||||||
"sso.provider_created": "Created SSO provider",
|
|
||||||
"sso.provider_updated": "Updated SSO provider",
|
|
||||||
"sso.provider_deleted": "Deleted SSO provider",
|
|
||||||
|
|
||||||
"license.activated": "Activated license",
|
|
||||||
"license.removed": "Removed license",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getEventLabel(event: string): string {
|
|
||||||
return auditEventLabels[event] ?? event;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const eventFilterOptions: EventGroup[] = [
|
|
||||||
{
|
|
||||||
group: "Workspace",
|
|
||||||
items: [
|
|
||||||
{ value: "workspace.updated", label: "Updated workspace" },
|
|
||||||
{ value: "workspace.invite_created", label: "Created invitation" },
|
|
||||||
{ value: "workspace.invite_revoked", label: "Revoked invitation" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "User",
|
|
||||||
items: [
|
|
||||||
{ value: "user.login", label: "Logged in" },
|
|
||||||
{ value: "user.logout", label: "Logged out" },
|
|
||||||
{ value: "user.created", label: "Created user" },
|
|
||||||
{ value: "user.deleted", label: "Deleted user" },
|
|
||||||
{ value: "user.deactivated", label: "Deactivated user" },
|
|
||||||
{ value: "user.activated", label: "Activated user" },
|
|
||||||
{ value: "user.role_changed", label: "Changed user role" },
|
|
||||||
{ value: "user.password_changed", label: "Changed password" },
|
|
||||||
{ value: "user.mfa_enabled", label: "Enabled MFA" },
|
|
||||||
{ value: "user.mfa_disabled", label: "Disabled MFA" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "Space",
|
|
||||||
items: [
|
|
||||||
{ value: "space.created", label: "Created space" },
|
|
||||||
{ value: "space.updated", label: "Updated space" },
|
|
||||||
{ value: "space.deleted", label: "Deleted space" },
|
|
||||||
{ value: "space.member_added", label: "Added space member" },
|
|
||||||
{ value: "space.member_removed", label: "Removed space member" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "Group",
|
|
||||||
items: [
|
|
||||||
{ value: "group.created", label: "Created group" },
|
|
||||||
{ value: "group.updated", label: "Updated group" },
|
|
||||||
{ value: "group.deleted", label: "Deleted group" },
|
|
||||||
{ value: "group.member_added", label: "Added group member" },
|
|
||||||
{ value: "group.member_removed", label: "Removed group member" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "Comment",
|
|
||||||
items: [
|
|
||||||
{ value: "comment.deleted", label: "Deleted comment" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "Page",
|
|
||||||
items: [
|
|
||||||
{ value: "page.trashed", label: "Trashed page" },
|
|
||||||
{ value: "page.deleted", label: "Deleted page" },
|
|
||||||
{ value: "page.restored", label: "Restored page" },
|
|
||||||
{ value: "page.imported", label: "Imported page" },
|
|
||||||
{ value: "page.exported", label: "Exported page" },
|
|
||||||
{ value: "page.restricted", label: "Restricted page" },
|
|
||||||
{ value: "page.restriction_removed", label: "Removed page restriction" },
|
|
||||||
{ value: "page.permission_added", label: "Added page permission" },
|
|
||||||
{ value: "page.permission_removed", label: "Removed page permission" },
|
|
||||||
{ value: "page.verification_created", label: "Created page verification" },
|
|
||||||
{ value: "page.verification_updated", label: "Updated page verification" },
|
|
||||||
{ value: "page.verification_removed", label: "Removed page verification" },
|
|
||||||
{ value: "page.verified", label: "Verified page" },
|
|
||||||
{ value: "page.approval_requested", label: "Requested page approval" },
|
|
||||||
{ value: "page.approval_rejected", label: "Rejected page approval" },
|
|
||||||
{ value: "page.marked_obsolete", label: "Marked page as obsolete" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "Share",
|
|
||||||
items: [
|
|
||||||
{ value: "share.created", label: "Created share link" },
|
|
||||||
{ value: "share.deleted", label: "Deleted share link" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "SSO",
|
|
||||||
items: [
|
|
||||||
{ value: "sso.provider_created", label: "Created SSO provider" },
|
|
||||||
{ value: "sso.provider_updated", label: "Updated SSO provider" },
|
|
||||||
{ value: "sso.provider_deleted", label: "Deleted SSO provider" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "API key",
|
|
||||||
items: [
|
|
||||||
{ value: "api_key.created", label: "Created API key" },
|
|
||||||
{ value: "api_key.deleted", label: "Deleted API key" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "SCIM token",
|
|
||||||
items: [
|
|
||||||
{ value: "scim_token.created", label: "Created SCIM token" },
|
|
||||||
{ value: "scim_token.updated", label: "Updated SCIM token" },
|
|
||||||
{ value: "scim_token.deleted", label: "Deleted SCIM token" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "License",
|
|
||||||
items: [
|
|
||||||
{ value: "license.activated", label: "Activated license" },
|
|
||||||
{ value: "license.removed", label: "Removed license" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
import { useState, useMemo, useEffect } from "react";
|
|
||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
NumberInput,
|
|
||||||
Popover,
|
|
||||||
Select,
|
|
||||||
Space,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { Helmet } from "react-helmet-async";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { IconSettings } from "@tabler/icons-react";
|
|
||||||
import SettingsTitle from "@/components/settings/settings-title";
|
|
||||||
import { getAppName } from "@/lib/config";
|
|
||||||
import Paginate from "@/components/common/paginate";
|
|
||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
|
||||||
import {
|
|
||||||
useAuditLogsQuery,
|
|
||||||
useAuditRetentionQuery,
|
|
||||||
useUpdateAuditRetentionMutation,
|
|
||||||
} from "@/ee/audit/queries/audit-query";
|
|
||||||
import { IAuditLogParams } from "@/ee/audit/types/audit.types";
|
|
||||||
import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels";
|
|
||||||
import AuditLogsTable from "@/ee/audit/components/audit-logs-table";
|
|
||||||
import useUserRole from "@/hooks/use-user-role";
|
|
||||||
|
|
||||||
type RetentionUnit = "days" | "months" | "years";
|
|
||||||
|
|
||||||
function daysToRetention(days: number): { amount: number; unit: RetentionUnit } {
|
|
||||||
if (days >= 365 && days % 365 === 0) {
|
|
||||||
return { amount: days / 365, unit: "years" };
|
|
||||||
}
|
|
||||||
if (days >= 30 && days % 30 === 0) {
|
|
||||||
return { amount: days / 30, unit: "months" };
|
|
||||||
}
|
|
||||||
return { amount: days, unit: "days" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function retentionToDays(amount: number, unit: RetentionUnit): number {
|
|
||||||
if (unit === "years") return amount * 365;
|
|
||||||
if (unit === "months") return amount * 30;
|
|
||||||
return amount;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AuditLogs() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { isOwner } = useUserRole();
|
|
||||||
const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate();
|
|
||||||
|
|
||||||
const [eventFilter, setEventFilter] = useState<string | null>(null);
|
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
||||||
|
|
||||||
const { data: retentionData } = useAuditRetentionQuery();
|
|
||||||
const updateRetention = useUpdateAuditRetentionMutation();
|
|
||||||
|
|
||||||
const currentDays = retentionData?.retentionDays ?? 365;
|
|
||||||
const parsed = daysToRetention(currentDays);
|
|
||||||
const [retentionAmount, setRetentionAmount] = useState<number | string>(parsed.amount);
|
|
||||||
const [retentionUnit, setRetentionUnit] = useState<RetentionUnit>(parsed.unit);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (retentionData) {
|
|
||||||
const { amount, unit } = daysToRetention(retentionData.retentionDays);
|
|
||||||
setRetentionAmount(amount);
|
|
||||||
setRetentionUnit(unit);
|
|
||||||
}
|
|
||||||
}, [retentionData?.retentionDays]);
|
|
||||||
|
|
||||||
const resetRetentionForm = () => {
|
|
||||||
const { amount, unit } = daysToRetention(currentDays);
|
|
||||||
setRetentionAmount(amount);
|
|
||||||
setRetentionUnit(unit);
|
|
||||||
};
|
|
||||||
|
|
||||||
const params: IAuditLogParams = useMemo(
|
|
||||||
() => ({
|
|
||||||
cursor,
|
|
||||||
limit: 50,
|
|
||||||
event: eventFilter ?? undefined,
|
|
||||||
}),
|
|
||||||
[cursor, eventFilter],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, isLoading } = useAuditLogsQuery(params);
|
|
||||||
|
|
||||||
if (!isOwner) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEventChange = (value: string | null) => {
|
|
||||||
setEventFilter(value);
|
|
||||||
resetCursor();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Helmet>
|
|
||||||
<title>
|
|
||||||
{t("Audit log")} - {getAppName()}
|
|
||||||
</title>
|
|
||||||
</Helmet>
|
|
||||||
|
|
||||||
<SettingsTitle title={t("Audit log")} />
|
|
||||||
|
|
||||||
<Group mb="md" gap="sm">
|
|
||||||
<Select
|
|
||||||
placeholder={t("Filter by event")}
|
|
||||||
data={eventFilterOptions.map((group) => ({
|
|
||||||
group: t(group.group),
|
|
||||||
items: group.items.map((item) => ({
|
|
||||||
value: item.value,
|
|
||||||
label: t(item.label),
|
|
||||||
})),
|
|
||||||
}))}
|
|
||||||
value={eventFilter}
|
|
||||||
onChange={handleEventChange}
|
|
||||||
clearable
|
|
||||||
searchable
|
|
||||||
w={220}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Popover
|
|
||||||
position="bottom-end"
|
|
||||||
shadow="md"
|
|
||||||
width={260}
|
|
||||||
withArrow
|
|
||||||
opened={settingsOpen}
|
|
||||||
onChange={(opened) => {
|
|
||||||
if (!opened) resetRetentionForm();
|
|
||||||
setSettingsOpen(opened);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Popover.Target>
|
|
||||||
<Tooltip label={t("Audit settings")}>
|
|
||||||
<ActionIcon variant="default" size="input-sm" ml="auto" onClick={() => setSettingsOpen((o) => !o)}>
|
|
||||||
<IconSettings size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Popover.Target>
|
|
||||||
<Popover.Dropdown>
|
|
||||||
<Text fz="sm" fw={500} mb={4}>
|
|
||||||
{t("Retention")}
|
|
||||||
</Text>
|
|
||||||
<Text fz="xs" c="dimmed" mb="sm">
|
|
||||||
{t("Logs older than this period are automatically deleted.")}
|
|
||||||
</Text>
|
|
||||||
<Group gap="xs" wrap="nowrap" mb="sm">
|
|
||||||
<NumberInput
|
|
||||||
value={retentionAmount}
|
|
||||||
onChange={(val) => setRetentionAmount(val)}
|
|
||||||
min={1}
|
|
||||||
hideControls
|
|
||||||
size="sm"
|
|
||||||
w={60}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
data={[
|
|
||||||
{ value: "days", label: t("days") },
|
|
||||||
{ value: "months", label: t("months") },
|
|
||||||
{ value: "years", label: t("years") },
|
|
||||||
]}
|
|
||||||
value={retentionUnit}
|
|
||||||
onChange={(value) => {
|
|
||||||
if (value === "days" || value === "months" || value === "years") {
|
|
||||||
setRetentionUnit(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
size="sm"
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
comboboxProps={{ withinPortal: false }}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs" grow>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="default"
|
|
||||||
onClick={() => {
|
|
||||||
resetRetentionForm();
|
|
||||||
setSettingsOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("Cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
onClick={() => {
|
|
||||||
const num = typeof retentionAmount === "number" ? retentionAmount : 1;
|
|
||||||
const clamped = Math.max(1, num);
|
|
||||||
setRetentionAmount(clamped);
|
|
||||||
const days = retentionToDays(clamped, retentionUnit);
|
|
||||||
if (days !== currentDays) {
|
|
||||||
updateRetention.mutate({ auditRetentionDays: days });
|
|
||||||
}
|
|
||||||
setSettingsOpen(false);
|
|
||||||
}}
|
|
||||||
loading={updateRetention.isPending}
|
|
||||||
>
|
|
||||||
{t("Save")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Popover.Dropdown>
|
|
||||||
</Popover>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<AuditLogsTable items={data?.items} isLoading={isLoading} />
|
|
||||||
|
|
||||||
<Space h="md" />
|
|
||||||
|
|
||||||
{data?.items && data.items.length > 0 && (
|
|
||||||
<Paginate
|
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
|
||||||
onPrev={goPrev}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import {
|
|
||||||
keepPreviousData,
|
|
||||||
useMutation,
|
|
||||||
useQuery,
|
|
||||||
useQueryClient,
|
|
||||||
UseQueryResult,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
getAuditLogs,
|
|
||||||
getAuditRetention,
|
|
||||||
updateAuditRetention,
|
|
||||||
} from "@/ee/audit/services/audit-service";
|
|
||||||
import { IAuditLog, IAuditLogParams } from "@/ee/audit/types/audit.types";
|
|
||||||
import { IPagination } from "@/lib/types";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export function useAuditLogsQuery(
|
|
||||||
params?: IAuditLogParams,
|
|
||||||
): UseQueryResult<IPagination<IAuditLog>, Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["audit-logs", params],
|
|
||||||
queryFn: () => getAuditLogs(params),
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuditRetentionQuery() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["audit-retention"],
|
|
||||||
queryFn: () => getAuditRetention(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateAuditRetentionMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: { auditRetentionDays: number }) =>
|
|
||||||
updateAuditRetention(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
notifications.show({ message: t("Audit retention updated") });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["audit-retention"] });
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({ message: errorMessage, color: "red" });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import api from "@/lib/api-client";
|
|
||||||
import { IAuditLog, IAuditLogParams } from "@/ee/audit/types/audit.types";
|
|
||||||
import { IPagination } from "@/lib/types";
|
|
||||||
|
|
||||||
export async function getAuditLogs(
|
|
||||||
params?: IAuditLogParams,
|
|
||||||
): Promise<IPagination<IAuditLog>> {
|
|
||||||
const req = await api.post("/audit", { ...params });
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAuditRetention(): Promise<{ retentionDays: number }> {
|
|
||||||
const req = await api.post("/audit/retention");
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateAuditRetention(data: {
|
|
||||||
auditRetentionDays: number;
|
|
||||||
}): Promise<{ retentionDays: number }> {
|
|
||||||
const req = await api.post("/audit/retention/update", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
export type IAuditLog = {
|
|
||||||
id: string;
|
|
||||||
workspaceId: string;
|
|
||||||
actorId?: string;
|
|
||||||
actorType: string;
|
|
||||||
event: string;
|
|
||||||
resourceType: string;
|
|
||||||
resourceId?: string;
|
|
||||||
spaceId?: string;
|
|
||||||
changes?: {
|
|
||||||
before?: Record<string, any>;
|
|
||||||
after?: Record<string, any>;
|
|
||||||
};
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
ipAddress?: string;
|
|
||||||
createdAt: string;
|
|
||||||
actor?: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
avatarUrl?: string;
|
|
||||||
};
|
|
||||||
resource?: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
slug?: string;
|
|
||||||
slugId?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IAuditLogParams = {
|
|
||||||
event?: string;
|
|
||||||
resourceType?: string;
|
|
||||||
actorId?: string;
|
|
||||||
spaceId?: string;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
cursor?: string;
|
|
||||||
limit?: number;
|
|
||||||
};
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
import {
|
|
||||||
useBillingPlans,
|
|
||||||
useBillingQuery,
|
|
||||||
} from "@/ee/billing/queries/billing-query.ts";
|
|
||||||
import { Group, Text, SimpleGrid, Paper } from "@mantine/core";
|
|
||||||
import classes from "./billing.module.css";
|
|
||||||
import { formatInterval } from "@/ee/billing/utils.ts";
|
|
||||||
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
|
|
||||||
|
|
||||||
export default function BillingDetails() {
|
|
||||||
const { data: billing } = useBillingQuery();
|
|
||||||
const { data: plans } = useBillingPlans();
|
|
||||||
const locale = useDateFnsLocale();
|
|
||||||
|
|
||||||
if (!billing || !plans) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classes.root}>
|
|
||||||
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>
|
|
||||||
<Paper p="md" radius="md">
|
|
||||||
<Group justify="apart">
|
|
||||||
<div>
|
|
||||||
<Text
|
|
||||||
c="dimmed"
|
|
||||||
tt="uppercase"
|
|
||||||
fw={700}
|
|
||||||
fz="xs"
|
|
||||||
className={classes.label}
|
|
||||||
>
|
|
||||||
Plan
|
|
||||||
</Text>
|
|
||||||
<Text fw={700} fz="lg" tt="capitalize">
|
|
||||||
{plans.find(
|
|
||||||
(plan) => plan.productId === billing.stripeProductId,
|
|
||||||
)?.name ||
|
|
||||||
billing.planName ||
|
|
||||||
"Standard"}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Paper p="md" radius="md">
|
|
||||||
<Group justify="apart">
|
|
||||||
<div>
|
|
||||||
<Text
|
|
||||||
c="dimmed"
|
|
||||||
tt="uppercase"
|
|
||||||
fw={700}
|
|
||||||
fz="xs"
|
|
||||||
className={classes.label}
|
|
||||||
>
|
|
||||||
Billing Period
|
|
||||||
</Text>
|
|
||||||
<Text fw={700} fz="lg" tt="capitalize">
|
|
||||||
{formatInterval(billing.interval)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Paper p="md" radius="md">
|
|
||||||
<Group justify="apart">
|
|
||||||
<div>
|
|
||||||
<Text
|
|
||||||
c="dimmed"
|
|
||||||
tt="uppercase"
|
|
||||||
fw={700}
|
|
||||||
fz="xs"
|
|
||||||
className={classes.label}
|
|
||||||
>
|
|
||||||
{billing.cancelAtPeriodEnd
|
|
||||||
? "Cancellation date"
|
|
||||||
: "Renewal date"}
|
|
||||||
</Text>
|
|
||||||
<Text fw={700} fz="lg">
|
|
||||||
{formatLocalized(
|
|
||||||
billing.periodEndAt,
|
|
||||||
"dd MMM, yyyy",
|
|
||||||
"PP",
|
|
||||||
locale,
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>
|
|
||||||
<Paper p="md" radius="md">
|
|
||||||
<Group justify="apart">
|
|
||||||
<div>
|
|
||||||
<Text
|
|
||||||
c="dimmed"
|
|
||||||
tt="uppercase"
|
|
||||||
fw={700}
|
|
||||||
fz="xs"
|
|
||||||
className={classes.label}
|
|
||||||
>
|
|
||||||
Seat count
|
|
||||||
</Text>
|
|
||||||
<Text fw={700} fz="lg">
|
|
||||||
{billing.quantity}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Paper p="md" radius="md">
|
|
||||||
<Group justify="apart">
|
|
||||||
<div>
|
|
||||||
<Text
|
|
||||||
c="dimmed"
|
|
||||||
tt="uppercase"
|
|
||||||
fw={700}
|
|
||||||
fz="xs"
|
|
||||||
className={classes.label}
|
|
||||||
>
|
|
||||||
Cost
|
|
||||||
</Text>
|
|
||||||
{billing.billingScheme === "tiered" && (
|
|
||||||
<>
|
|
||||||
<Text fw={700} fz="lg">
|
|
||||||
${billing.amount / 100} {billing.currency.toUpperCase()} /{" "}
|
|
||||||
{billing.interval}
|
|
||||||
</Text>
|
|
||||||
<Text c="dimmed" fz="sm">
|
|
||||||
per {billing.interval}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{billing.billingScheme !== "tiered" && (
|
|
||||||
<>
|
|
||||||
<Text fw={700} fz="lg">
|
|
||||||
{(billing.amount / 100) * billing.quantity}{" "}
|
|
||||||
{billing.currency.toUpperCase()} / {billing.interval}
|
|
||||||
</Text>
|
|
||||||
<Text c="dimmed" fz="sm">
|
|
||||||
${billing.amount / 100} /user/{billing.interval}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{billing.billingScheme === "tiered" && billing.tieredUpTo && (
|
|
||||||
<Paper p="md" radius="md">
|
|
||||||
<Group justify="apart">
|
|
||||||
<div>
|
|
||||||
<Text
|
|
||||||
c="dimmed"
|
|
||||||
tt="uppercase"
|
|
||||||
fw={700}
|
|
||||||
fz="xs"
|
|
||||||
className={classes.label}
|
|
||||||
>
|
|
||||||
Current Tier
|
|
||||||
</Text>
|
|
||||||
<Text fw={700} fz="lg">
|
|
||||||
For {billing.tieredUpTo} users
|
|
||||||
</Text>
|
|
||||||
{/*billing.tieredFlatAmount && (
|
|
||||||
<Text c="dimmed" fz="sm">
|
|
||||||
</Text>
|
|
||||||
)*/}
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
</SimpleGrid>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Alert } from "@mantine/core";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export default function BillingIncomplete() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Alert variant="light" color="blue">
|
|
||||||
Your subscription is in an incomplete state. Please refresh this page if
|
|
||||||
you recently made your payment.
|
|
||||||
</Alert>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
List,
|
|
||||||
ThemeIcon,
|
|
||||||
Title,
|
|
||||||
Text,
|
|
||||||
Group,
|
|
||||||
Select,
|
|
||||||
Container,
|
|
||||||
Stack,
|
|
||||||
Badge,
|
|
||||||
Flex,
|
|
||||||
Switch,
|
|
||||||
Alert,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { IconCheck, IconInfoCircle } from "@tabler/icons-react";
|
|
||||||
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
|
||||||
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
|
||||||
|
|
||||||
export default function BillingPlans() {
|
|
||||||
const { data: plans } = useBillingPlans();
|
|
||||||
const workspace = useAtomValue(workspaceAtom);
|
|
||||||
const [isAnnual, setIsAnnual] = useState(true);
|
|
||||||
const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCheckout = async (priceId: string) => {
|
|
||||||
try {
|
|
||||||
const checkoutLink = await getCheckoutLink({
|
|
||||||
priceId: priceId,
|
|
||||||
});
|
|
||||||
window.location.href = checkoutLink.url;
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to get checkout link", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: remove by July 30.
|
|
||||||
// Check if workspace was created between June 28 and July 14, 2025
|
|
||||||
const showTieredPricingNotice = (() => {
|
|
||||||
if (!workspace?.createdAt) return false;
|
|
||||||
const createdDate = new Date(workspace.createdAt);
|
|
||||||
const startDate = new Date('2025-06-20');
|
|
||||||
const endDate = new Date('2025-07-14');
|
|
||||||
return createdDate >= startDate && createdDate <= endDate;
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (!plans || plans.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if any plan is tiered
|
|
||||||
const hasTieredPlans = plans.some(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0);
|
|
||||||
const firstTieredPlan = plans.find(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0);
|
|
||||||
|
|
||||||
// Set initial tier value if not set and we have tiered plans
|
|
||||||
if (hasTieredPlans && !selectedTierValue && firstTieredPlan) {
|
|
||||||
setSelectedTierValue(firstTieredPlan.pricingTiers[0].upTo.toString());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For tiered plans, ensure we have a selected tier
|
|
||||||
if (hasTieredPlans && !selectedTierValue) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectData = firstTieredPlan?.pricingTiers
|
|
||||||
?.filter((tier) => !tier.custom)
|
|
||||||
.map((tier, index) => {
|
|
||||||
const prevMaxUsers =
|
|
||||||
index > 0 ? firstTieredPlan.pricingTiers[index - 1].upTo : 0;
|
|
||||||
return {
|
|
||||||
value: tier.upTo.toString(),
|
|
||||||
label: `${prevMaxUsers + 1}-${tier.upTo} users`,
|
|
||||||
};
|
|
||||||
}) || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container size="xl" py="xl">
|
|
||||||
{/* Tiered pricing notice for eligible workspaces */}
|
|
||||||
{showTieredPricingNotice && !hasTieredPlans && (
|
|
||||||
<Alert
|
|
||||||
icon={<IconInfoCircle size={16} />}
|
|
||||||
title="Want the old tiered pricing?"
|
|
||||||
color="blue"
|
|
||||||
mb="lg"
|
|
||||||
>
|
|
||||||
Contact support to switch back to our tiered pricing model.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Controls Section */}
|
|
||||||
<Stack gap="xl" mb="md">
|
|
||||||
{/* Team Size and Billing Controls */}
|
|
||||||
<Group justify="center" align="center" gap="sm">
|
|
||||||
{hasTieredPlans && (
|
|
||||||
<Select
|
|
||||||
label="Team size"
|
|
||||||
description="Select the number of users"
|
|
||||||
value={selectedTierValue}
|
|
||||||
onChange={setSelectedTierValue}
|
|
||||||
data={selectData}
|
|
||||||
w={250}
|
|
||||||
size="md"
|
|
||||||
allowDeselect={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Group justify="center" align="start">
|
|
||||||
<Flex justify="center" gap="md" align="center">
|
|
||||||
<Text size="md">Monthly</Text>
|
|
||||||
<Switch
|
|
||||||
defaultChecked={isAnnual}
|
|
||||||
onChange={(event) => setIsAnnual(event.target.checked)}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<Text size="md">
|
|
||||||
Annually
|
|
||||||
<Badge component="span" variant="light" color="blue">
|
|
||||||
15% OFF
|
|
||||||
</Badge>
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* Plans Grid */}
|
|
||||||
<Group justify="center" gap="lg" align="stretch">
|
|
||||||
{plans.map((plan, index) => {
|
|
||||||
let price;
|
|
||||||
let displayPrice;
|
|
||||||
const priceId = isAnnual ? plan.yearlyId : plan.monthlyId;
|
|
||||||
|
|
||||||
if (plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0) {
|
|
||||||
// Tiered billing logic
|
|
||||||
const planSelectedTier =
|
|
||||||
plan.pricingTiers.find(
|
|
||||||
(tier) => tier.upTo.toString() === selectedTierValue,
|
|
||||||
) || plan.pricingTiers[0];
|
|
||||||
|
|
||||||
price = isAnnual
|
|
||||||
? planSelectedTier.yearly
|
|
||||||
: planSelectedTier.monthly;
|
|
||||||
displayPrice = isAnnual ? (price / 12).toFixed(0) : price;
|
|
||||||
} else {
|
|
||||||
// Per-unit billing logic
|
|
||||||
const monthlyPrice = parseFloat(plan.price?.monthly || '0');
|
|
||||||
const yearlyPrice = parseFloat(plan.price?.yearly || '0');
|
|
||||||
price = isAnnual ? yearlyPrice : monthlyPrice;
|
|
||||||
displayPrice = isAnnual ? (yearlyPrice / 12).toFixed(0) : monthlyPrice;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={plan.name}
|
|
||||||
withBorder
|
|
||||||
radius="lg"
|
|
||||||
shadow="sm"
|
|
||||||
p="xl"
|
|
||||||
w={350}
|
|
||||||
miw={300}
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack gap="lg">
|
|
||||||
{/* Plan Header */}
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Title order={3} size="h4">
|
|
||||||
{plan.name}
|
|
||||||
</Title>
|
|
||||||
{plan.description && (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{plan.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* Pricing */}
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Group align="baseline" gap="xs">
|
|
||||||
<Title order={1} size="h1">
|
|
||||||
${displayPrice}
|
|
||||||
</Title>
|
|
||||||
<Text size="lg" c="dimmed">
|
|
||||||
{plan.billingScheme === 'per_unit'
|
|
||||||
? `per user/month`
|
|
||||||
: `per month`}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{isAnnual ? "Billed annually" : "Billed monthly"}
|
|
||||||
</Text>
|
|
||||||
{plan.billingScheme === 'tiered' && plan.pricingTiers && (
|
|
||||||
<Text size="md" fw={500}>
|
|
||||||
For {plan.pricingTiers.find(tier => tier.upTo.toString() === selectedTierValue)?.upTo || plan.pricingTiers[0].upTo} users
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* CTA Button */}
|
|
||||||
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
|
||||||
Subscribe
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Features */}
|
|
||||||
<List
|
|
||||||
spacing="xs"
|
|
||||||
size="sm"
|
|
||||||
icon={
|
|
||||||
<ThemeIcon size={20} radius="xl">
|
|
||||||
<IconCheck size={14} />
|
|
||||||
</ThemeIcon>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{plan.features.map((feature, featureIndex) => (
|
|
||||||
<List.Item key={featureIndex}>{feature}</List.Item>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Group>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { Alert } from "@mantine/core";
|
|
||||||
import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts";
|
|
||||||
import useTrial from "@/ee/hooks/use-trial.tsx";
|
|
||||||
import { getBillingTrialDays } from '@/lib/config.ts';
|
|
||||||
|
|
||||||
export default function BillingTrial() {
|
|
||||||
const { data: billing, isLoading } = useBillingQuery();
|
|
||||||
const { trialDaysLeft } = useTrial();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{trialDaysLeft > 0 && !billing && (
|
|
||||||
<Alert title="Your Trial is Active 🎉" color="blue" radius="md">
|
|
||||||
You have {trialDaysLeft} {trialDaysLeft === 1 ? "day" : "days"} left
|
|
||||||
in your {getBillingTrialDays()}-day free trial. Please subscribe to a paid plan before your trial
|
|
||||||
ends.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{trialDaysLeft === 0 && (
|
|
||||||
<Alert title="Your Trial has ended" color="red" radius="md">
|
|
||||||
Your {getBillingTrialDays()}-day free trial has come to an end. Please subscribe to a paid plan to
|
|
||||||
continue using this service.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
.root {
|
|
||||||
padding-top: var(--mantine-spacing-xs);
|
|
||||||
padding-bottom: var(--mantine-spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-family:
|
|
||||||
Greycliff CF,
|
|
||||||
var(--mantine-font-family);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { Button, Group, Text } from "@mantine/core";
|
|
||||||
import React from "react";
|
|
||||||
import { getBillingPortalLink } from "@/ee/billing/services/billing-service.ts";
|
|
||||||
|
|
||||||
export default function ManageBilling() {
|
|
||||||
const handleBillingPortal = async () => {
|
|
||||||
try {
|
|
||||||
const portalLink = await getBillingPortalLink();
|
|
||||||
window.location.href = portalLink.url;
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to get billing portal link", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Group justify="space-between" wrap="wrap" gap="xl">
|
|
||||||
<div style={{ flex: 1, minWidth: "200px" }}>
|
|
||||||
<Text size="md" fw={500}>
|
|
||||||
Manage subscription
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
Manage your your subscription, invoices, update payment details, and
|
|
||||||
more.
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button style={{ flexShrink: 0 }} onClick={handleBillingPortal}>
|
|
||||||
Manage
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { Helmet } from "react-helmet-async";
|
|
||||||
import { getAppName } from "@/lib/config.ts";
|
|
||||||
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
|
||||||
import BillingPlans from "@/ee/billing/components/billing-plans.tsx";
|
|
||||||
import BillingTrial from "@/ee/billing/components/billing-trial.tsx";
|
|
||||||
import ManageBilling from "@/ee/billing/components/manage-billing.tsx";
|
|
||||||
import { Divider } from "@mantine/core";
|
|
||||||
import React from "react";
|
|
||||||
import BillingDetails from "@/ee/billing/components/billing-details.tsx";
|
|
||||||
import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts";
|
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
|
||||||
|
|
||||||
export default function Billing() {
|
|
||||||
const { data: billing, isError: isBillingError } = useBillingQuery();
|
|
||||||
const { isAdmin } = useUserRole();
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Helmet>
|
|
||||||
<title>Billing - {getAppName()}</title>
|
|
||||||
</Helmet>
|
|
||||||
<SettingsTitle title="Billing" />
|
|
||||||
|
|
||||||
<BillingTrial />
|
|
||||||
<BillingDetails />
|
|
||||||
|
|
||||||
{isBillingError && <BillingPlans />}
|
|
||||||
|
|
||||||
{billing && (
|
|
||||||
<>
|
|
||||||
<Divider my="lg" />
|
|
||||||
<ManageBilling />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
getBilling,
|
|
||||||
getBillingPlans,
|
|
||||||
} from "@/ee/billing/services/billing-service.ts";
|
|
||||||
import { IBilling, IBillingPlan } from "@/ee/billing/types/billing.types.ts";
|
|
||||||
|
|
||||||
export function useBillingQuery(): UseQueryResult<IBilling, Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["billing"],
|
|
||||||
queryFn: () => getBilling(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBillingPlans(): UseQueryResult<IBillingPlan[], Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["billing-plans"],
|
|
||||||
queryFn: () => getBillingPlans(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import api from "@/lib/api-client.ts";
|
|
||||||
import {
|
|
||||||
IBilling,
|
|
||||||
IBillingPlan,
|
|
||||||
IBillingPortal,
|
|
||||||
ICheckoutLink,
|
|
||||||
} from "@/ee/billing/types/billing.types.ts";
|
|
||||||
|
|
||||||
export async function getBilling(): Promise<IBilling> {
|
|
||||||
const req = await api.post<IBilling>("/billing/info");
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getBillingPlans(): Promise<IBillingPlan[]> {
|
|
||||||
const req = await api.post<IBillingPlan[]>("/billing/plans");
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCheckoutLink(data: {
|
|
||||||
priceId: string;
|
|
||||||
}): Promise<ICheckoutLink> {
|
|
||||||
const req = await api.post<ICheckoutLink>("/billing/checkout", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getBillingPortalLink(): Promise<IBillingPortal> {
|
|
||||||
const req = await api.post<IBillingPortal>("/billing/portal");
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
export enum BillingPlan {
|
|
||||||
STANDARD = "standard",
|
|
||||||
BUSINESS = "business",
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IBilling {
|
|
||||||
id: string;
|
|
||||||
stripeSubscriptionId: string;
|
|
||||||
stripeCustomerId: string;
|
|
||||||
status: string;
|
|
||||||
quantity: number;
|
|
||||||
amount: number;
|
|
||||||
interval: string;
|
|
||||||
currency: string;
|
|
||||||
metadata: Record<string, any>;
|
|
||||||
stripePriceId: string;
|
|
||||||
stripeItemId: string;
|
|
||||||
stripeProductId: string;
|
|
||||||
periodStartAt: Date;
|
|
||||||
periodEndAt: Date;
|
|
||||||
cancelAtPeriodEnd: boolean;
|
|
||||||
cancelAt: Date;
|
|
||||||
canceledAt: Date;
|
|
||||||
workspaceId: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
deletedAt: Date;
|
|
||||||
billingScheme: string | null;
|
|
||||||
tieredUpTo: string | null;
|
|
||||||
tieredFlatAmount: number | null;
|
|
||||||
tieredUnitAmount: number | null;
|
|
||||||
planName: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ICheckoutLink {
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IBillingPortal {
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IBillingPlan {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
productId: string;
|
|
||||||
monthlyId: string;
|
|
||||||
yearlyId: string;
|
|
||||||
currency: string;
|
|
||||||
price?: {
|
|
||||||
monthly: string;
|
|
||||||
yearly: string;
|
|
||||||
};
|
|
||||||
features: string[];
|
|
||||||
billingScheme: string | null;
|
|
||||||
pricingTiers?: PricingTier[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PricingTier {
|
|
||||||
upTo: number;
|
|
||||||
monthly?: number;
|
|
||||||
yearly?: number;
|
|
||||||
custom?: boolean;
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { differenceInCalendarDays } from "date-fns";
|
|
||||||
|
|
||||||
export function formatInterval(interval: string): string {
|
|
||||||
if (interval === "month") {
|
|
||||||
return "monthly";
|
|
||||||
}
|
|
||||||
if (interval === "year") {
|
|
||||||
return "yearly";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTrialDaysLeft(trialEndAt: Date) {
|
|
||||||
if (!trialEndAt) return null;
|
|
||||||
|
|
||||||
const daysLeft = differenceInCalendarDays(trialEndAt, new Date());
|
|
||||||
return daysLeft > 0 ? daysLeft : 0;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
|
||||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
|
||||||
import { getJoinedWorkspaces } from "@/ee/cloud/service/cloud-service.ts";
|
|
||||||
|
|
||||||
export function useJoinedWorkspacesQuery(): UseQueryResult<
|
|
||||||
Partial<IWorkspace[]>,
|
|
||||||
Error
|
|
||||||
> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["joined-workspaces"],
|
|
||||||
queryFn: () => getJoinedWorkspaces(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
|
||||||
import api from "@/lib/api-client.ts";
|
|
||||||
|
|
||||||
export async function getJoinedWorkspaces(): Promise<Partial<IWorkspace[]>> {
|
|
||||||
const req = await api.post<Partial<IWorkspace[]>>("/workspace/joined");
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function findWorkspacesByEmail(email: string): Promise<void> {
|
|
||||||
await api.post("/workspace/find-by-email", { email });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyEmail(data: { token: string }): Promise<void> {
|
|
||||||
await api.post("/workspace/verify-email", data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resendVerificationEmail(data: { email: string; sig: string }): Promise<void> {
|
|
||||||
await api.post("/workspace/resend-verification", data);
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
|
||||||
import { IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
|
|
||||||
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Editor } from "@tiptap/react";
|
|
||||||
|
|
||||||
interface ResolveCommentProps {
|
|
||||||
editor: Editor;
|
|
||||||
commentId: string;
|
|
||||||
pageId: string;
|
|
||||||
resolvedAt?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResolveComment({
|
|
||||||
editor,
|
|
||||||
commentId,
|
|
||||||
pageId,
|
|
||||||
resolvedAt,
|
|
||||||
}: ResolveCommentProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const resolveCommentMutation = useResolveCommentMutation();
|
|
||||||
|
|
||||||
const isResolved = resolvedAt != null;
|
|
||||||
const iconColor = isResolved ? "green" : "gray";
|
|
||||||
|
|
||||||
const handleResolveToggle = async () => {
|
|
||||||
try {
|
|
||||||
await resolveCommentMutation.mutateAsync({
|
|
||||||
commentId,
|
|
||||||
pageId,
|
|
||||||
resolved: !isResolved,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (editor) {
|
|
||||||
editor.commands.setCommentResolved(commentId, !isResolved);
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to toggle resolved state:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
label={isResolved ? t("Re-Open comment") : t("Resolve comment")}
|
|
||||||
position="top"
|
|
||||||
>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={handleResolveToggle}
|
|
||||||
variant="subtle"
|
|
||||||
color={isResolved ? "green" : "gray"}
|
|
||||||
size="sm"
|
|
||||||
loading={resolveCommentMutation.isPending}
|
|
||||||
disabled={resolveCommentMutation.isPending}
|
|
||||||
>
|
|
||||||
{isResolved ? (
|
|
||||||
<IconCircleCheckFilled size={18} />
|
|
||||||
) : (
|
|
||||||
<IconCircleCheck size={18} />
|
|
||||||
)}
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ResolveComment;
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import {
|
|
||||||
useMutation,
|
|
||||||
useQueryClient,
|
|
||||||
InfiniteData,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import { resolveComment } from "@/features/comment/services/comment-service";
|
|
||||||
import {
|
|
||||||
IComment,
|
|
||||||
IResolveComment,
|
|
||||||
} from "@/features/comment/types/comment.types";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { IPagination } from "@/lib/types.ts";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { RQ_KEY } from "@/features/comment/queries/comment-query";
|
|
||||||
|
|
||||||
function updateCommentInCache(
|
|
||||||
cache: InfiniteData<IPagination<IComment>>,
|
|
||||||
commentId: string,
|
|
||||||
updater: (comment: IComment) => IComment,
|
|
||||||
): InfiniteData<IPagination<IComment>> {
|
|
||||||
return {
|
|
||||||
...cache,
|
|
||||||
pages: cache.pages.map((page) => ({
|
|
||||||
...page,
|
|
||||||
items: page.items.map((comment) =>
|
|
||||||
comment.id === commentId ? updater(comment) : comment,
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useResolveCommentMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: IResolveComment) => resolveComment(data),
|
|
||||||
onMutate: async (variables) => {
|
|
||||||
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
|
|
||||||
const previousCache = queryClient.getQueryData(RQ_KEY(variables.pageId));
|
|
||||||
|
|
||||||
const cache = previousCache as InfiniteData<IPagination<IComment>> | undefined;
|
|
||||||
if (cache) {
|
|
||||||
queryClient.setQueryData(
|
|
||||||
RQ_KEY(variables.pageId),
|
|
||||||
updateCommentInCache(cache, variables.commentId, (comment) => ({
|
|
||||||
...comment,
|
|
||||||
resolvedAt: variables.resolved ? new Date() : null,
|
|
||||||
resolvedById: variables.resolved ? "optimistic" : null,
|
|
||||||
resolvedBy: variables.resolved
|
|
||||||
? ({ id: "optimistic", name: "", avatarUrl: null } as IComment["resolvedBy"])
|
|
||||||
: null,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { previousCache };
|
|
||||||
},
|
|
||||||
onError: (_err, variables, context) => {
|
|
||||||
if (context?.previousCache) {
|
|
||||||
queryClient.setQueryData(RQ_KEY(variables.pageId), context.previousCache);
|
|
||||||
}
|
|
||||||
notifications.show({
|
|
||||||
message: t("Failed to resolve comment"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: (data: IComment, variables) => {
|
|
||||||
const cache = queryClient.getQueryData(
|
|
||||||
RQ_KEY(data.pageId),
|
|
||||||
) as InfiniteData<IPagination<IComment>> | undefined;
|
|
||||||
|
|
||||||
if (cache) {
|
|
||||||
queryClient.setQueryData(
|
|
||||||
RQ_KEY(data.pageId),
|
|
||||||
updateCommentInCache(cache, variables.commentId, (comment) => ({
|
|
||||||
...comment,
|
|
||||||
resolvedAt: data.resolvedAt,
|
|
||||||
resolvedById: data.resolvedById,
|
|
||||||
resolvedBy: data.resolvedBy,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
notifications.show({
|
|
||||||
message: variables.resolved
|
|
||||||
? t("Comment resolved successfully")
|
|
||||||
: t("Comment re-opened successfully"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import { z } from "zod/v4";
|
|
||||||
import { useForm } from "@mantine/form";
|
|
||||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
|
||||||
import {
|
|
||||||
Container,
|
|
||||||
Title,
|
|
||||||
TextInput,
|
|
||||||
Button,
|
|
||||||
Box,
|
|
||||||
Text,
|
|
||||||
Anchor,
|
|
||||||
Divider,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import classes from "../../features/auth/components/auth.module.css";
|
|
||||||
import { getCheckHostname } from "@/features/workspace/services/workspace-service.ts";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { getSubdomainHost } from "@/lib/config.ts";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import JoinedWorkspaces from "@/ee/components/joined-workspaces.tsx";
|
|
||||||
import { useJoinedWorkspacesQuery } from "@/ee/cloud/query/cloud-query.ts";
|
|
||||||
import { findWorkspacesByEmail } from "@/ee/cloud/service/cloud-service.ts";
|
|
||||||
import { AuthLayout } from "@/features/auth/components/auth-layout.tsx";
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
hostname: z.string().min(1, { message: "subdomain is required" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const findWorkspaceSchema = z.object({
|
|
||||||
email: z.string().email({ message: "Please enter a valid email" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
export function CloudLoginForm() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
|
||||||
const [isFindLoading, setIsFindLoading] = useState<boolean>(false);
|
|
||||||
const [findEmailSent, setFindEmailSent] = useState<boolean>(false);
|
|
||||||
const { data: joinedWorkspaces } = useJoinedWorkspacesQuery();
|
|
||||||
|
|
||||||
const form = useForm<any>({
|
|
||||||
validate: zod4Resolver(formSchema),
|
|
||||||
initialValues: {
|
|
||||||
hostname: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const findForm = useForm<any>({
|
|
||||||
validate: zod4Resolver(findWorkspaceSchema),
|
|
||||||
initialValues: {
|
|
||||||
email: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function onSubmit(data: { hostname: string }) {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const checkHostname = await getCheckHostname(data.hostname);
|
|
||||||
window.location.href = checkHostname.hostname;
|
|
||||||
} catch (err) {
|
|
||||||
if (err?.status === 404) {
|
|
||||||
form.setFieldError("hostname", "We could not find this workspace");
|
|
||||||
} else {
|
|
||||||
form.setFieldError("hostname", "An error occurred");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onFindSubmit(data: { email: string }) {
|
|
||||||
setIsFindLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await findWorkspacesByEmail(data.email);
|
|
||||||
setFindEmailSent(true);
|
|
||||||
} catch {
|
|
||||||
findForm.setFieldError("email", "An error occurred. Please try again.");
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsFindLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthLayout>
|
|
||||||
<Container size={420} className={classes.container}>
|
|
||||||
<Box p="xl" className={classes.containerBox}>
|
|
||||||
<Title order={2} ta="center" fw={500} mb="md">
|
|
||||||
{t("Login")}
|
|
||||||
</Title>
|
|
||||||
|
|
||||||
<JoinedWorkspaces />
|
|
||||||
|
|
||||||
{joinedWorkspaces?.length > 0 && (
|
|
||||||
<Divider my="xs" label="OR" labelPosition="center" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
|
||||||
<TextInput
|
|
||||||
type="text"
|
|
||||||
placeholder="my-team"
|
|
||||||
description="Enter your workspace hostname"
|
|
||||||
label="Workspace hostname"
|
|
||||||
rightSection={<Text fw={500}>.{getSubdomainHost()}</Text>}
|
|
||||||
rightSectionWidth={150}
|
|
||||||
withErrorStyles={false}
|
|
||||||
{...form.getInputProps("hostname")}
|
|
||||||
/>
|
|
||||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
|
||||||
{t("Continue")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<Divider my="lg" label="or" labelPosition="center" />
|
|
||||||
|
|
||||||
{findEmailSent ? (
|
|
||||||
<Text ta="center" size="sm" c="dimmed">
|
|
||||||
{t("We've sent you an email with your associated workspaces.")}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={findForm.onSubmit(onFindSubmit)}>
|
|
||||||
<Text fw={600} mb="xs">
|
|
||||||
{t("Find your workspaces")}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
type="email"
|
|
||||||
placeholder="name@company.com"
|
|
||||||
description={t(
|
|
||||||
"We'll send a list of your workspaces to this email.",
|
|
||||||
)}
|
|
||||||
withErrorStyles={false}
|
|
||||||
{...findForm.getInputProps("email")}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
fullWidth
|
|
||||||
mt="md"
|
|
||||||
variant="light"
|
|
||||||
loading={isFindLoading}
|
|
||||||
>
|
|
||||||
{t("Send")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
|
|
||||||
<Text ta="center" mb="xl">
|
|
||||||
{t("Don't have a workspace?")}{" "}
|
|
||||||
<Anchor component={Link} to={APP_ROUTE.AUTH.CREATE_WORKSPACE} fw={500}>
|
|
||||||
{t("Create new workspace")}
|
|
||||||
</Anchor>
|
|
||||||
</Text>
|
|
||||||
</AuthLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
.workspace {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--mantine-spacing-xs);
|
|
||||||
margin-bottom: var(--mantine-spacing-xs);
|
|
||||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
|
||||||
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
|
||||||
border-radius: var(--mantine-spacing-xs);
|
|
||||||
|
|
||||||
@mixin hover {
|
|
||||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Group, Text, UnstyledButton } from "@mantine/core";
|
|
||||||
import { useJoinedWorkspacesQuery } from "../cloud/query/cloud-query";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
|
||||||
import classes from "./joined-workspaces.module.css";
|
|
||||||
import { IconChevronRight } from "@tabler/icons-react";
|
|
||||||
import { getHostnameUrl } from "@/ee/utils.ts";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
|
||||||
|
|
||||||
export default function JoinedWorkspaces() {
|
|
||||||
const { data, isLoading } = useJoinedWorkspacesQuery();
|
|
||||||
if (isLoading || !data || data?.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{data
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
.map((workspace: Partial<IWorkspace>, index) => (
|
|
||||||
<UnstyledButton
|
|
||||||
key={index}
|
|
||||||
component={Link}
|
|
||||||
to={getHostnameUrl(workspace?.hostname) + "/home"}
|
|
||||||
className={classes.workspace}
|
|
||||||
>
|
|
||||||
<Group wrap="nowrap">
|
|
||||||
<CustomAvatar
|
|
||||||
avatarUrl={workspace?.logo}
|
|
||||||
name={workspace?.name}
|
|
||||||
variant="filled"
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<Text size="sm" fw={500} lineClamp={1}>
|
|
||||||
{workspace?.name}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
{getHostnameUrl(workspace?.hostname)?.split("//")[1]}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<IconChevronRight size={16} />
|
|
||||||
</Group>
|
|
||||||
</UnstyledButton>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { Modal, TextInput, PasswordInput, Button, Stack } from "@mantine/core";
|
|
||||||
import { useForm } from "@mantine/form";
|
|
||||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
|
||||||
import { z } from "zod/v4";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { IAuthProvider } from "@/ee/security/types/security.types";
|
|
||||||
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
|
|
||||||
import { ldapLogin } from "@/ee/security/services/ldap-auth-service";
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
username: z.string().min(1, { message: "Username is required" }),
|
|
||||||
password: z.string().min(1, { message: "Password is required" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
interface LdapLoginModalProps {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
provider: IAuthProvider;
|
|
||||||
workspaceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LdapLoginModal({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
provider,
|
|
||||||
workspaceId,
|
|
||||||
}: LdapLoginModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
validate: zod4Resolver(formSchema),
|
|
||||||
initialValues: {
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = async (values: {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await ldapLogin({
|
|
||||||
username: values.username,
|
|
||||||
password: values.password,
|
|
||||||
providerId: provider.id,
|
|
||||||
workspaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle MFA like the regular login
|
|
||||||
if (response?.userHasMfa) {
|
|
||||||
onClose();
|
|
||||||
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + window.location.search);
|
|
||||||
} else if (response?.requiresMfaSetup) {
|
|
||||||
onClose();
|
|
||||||
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + window.location.search);
|
|
||||||
} else {
|
|
||||||
onClose();
|
|
||||||
navigate(getPostLoginRedirect());
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setIsLoading(false);
|
|
||||||
const errorMessage =
|
|
||||||
err.response?.data?.message || "Authentication failed";
|
|
||||||
setError(errorMessage);
|
|
||||||
|
|
||||||
notifications.show({
|
|
||||||
message: errorMessage,
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
form.reset();
|
|
||||||
setError(null);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={handleClose}
|
|
||||||
title={`LDAP Login - ${provider.name}`}
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
||||||
<Stack>
|
|
||||||
<TextInput
|
|
||||||
id="ldap-username"
|
|
||||||
type="text"
|
|
||||||
label={t("LDAP username")}
|
|
||||||
placeholder="Enter your LDAP username"
|
|
||||||
variant="filled"
|
|
||||||
disabled={isLoading}
|
|
||||||
data-autofocus
|
|
||||||
{...form.getInputProps("username")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PasswordInput
|
|
||||||
label={t("LDAP password")}
|
|
||||||
placeholder={t("Enter your LDAP password")}
|
|
||||||
variant="filled"
|
|
||||||
disabled={isLoading}
|
|
||||||
visibilityToggleButtonProps={{
|
|
||||||
"aria-label": t("Toggle password visibility"),
|
|
||||||
"aria-hidden": false,
|
|
||||||
tabIndex: 0,
|
|
||||||
}}
|
|
||||||
{...form.getInputProps("password")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button type="submit" fullWidth mt="md" loading={isLoading}>
|
|
||||||
{t("Sign in with LDAP")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import { Button, Group, Text, Modal, TextInput } from "@mantine/core";
|
|
||||||
import { z } from "zod/v4";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
|
||||||
import * as React from "react";
|
|
||||||
import { useForm } from "@mantine/form";
|
|
||||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { getSubdomainHost } from "@/lib/config.ts";
|
|
||||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
|
||||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
|
||||||
import { getHostnameUrl } from "@/ee/utils.ts";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import {
|
|
||||||
currentUserAtom,
|
|
||||||
workspaceAtom,
|
|
||||||
} from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
|
||||||
import { RESET } from "jotai/utils";
|
|
||||||
|
|
||||||
export default function ManageHostname() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
|
||||||
const [workspace] = useAtom(workspaceAtom);
|
|
||||||
const { isAdmin } = useUserRole();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
||||||
<div>
|
|
||||||
<Text size="md">{t("Hostname")}</Text>
|
|
||||||
<Text size="sm" c="dimmed" fw={500}>
|
|
||||||
{workspace?.hostname}.{getSubdomainHost()}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isAdmin && (
|
|
||||||
<Button onClick={open} variant="default">
|
|
||||||
{t("Change hostname")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={close}
|
|
||||||
title={t("Change hostname")}
|
|
||||||
centered
|
|
||||||
>
|
|
||||||
<ChangeHostnameForm onClose={close} />
|
|
||||||
</Modal>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
hostname: z.string().min(4),
|
|
||||||
});
|
|
||||||
|
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
|
||||||
|
|
||||||
interface ChangeHostnameFormProps {
|
|
||||||
onClose?: () => void;
|
|
||||||
}
|
|
||||||
function ChangeHostnameForm({ onClose }: ChangeHostnameFormProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [currentUser, setCurrentUser] = useAtom(currentUserAtom);
|
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
|
||||||
validate: zod4Resolver(formSchema),
|
|
||||||
initialValues: {
|
|
||||||
hostname: currentUser?.workspace?.hostname,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleSubmit(data: Partial<IWorkspace>) {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
if (data.hostname === currentUser?.workspace?.hostname) {
|
|
||||||
onClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateWorkspace({
|
|
||||||
hostname: data.hostname,
|
|
||||||
});
|
|
||||||
setCurrentUser(RESET);
|
|
||||||
window.location.href = getHostnameUrl(data.hostname.toLowerCase());
|
|
||||||
} catch (err) {
|
|
||||||
notifications.show({
|
|
||||||
message: err?.response?.data?.message,
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
||||||
<TextInput
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g my-team"
|
|
||||||
label="Hostname"
|
|
||||||
variant="filled"
|
|
||||||
rightSection={<Text fw={500}>.{getSubdomainHost()}</Text>}
|
|
||||||
rightSectionWidth={150}
|
|
||||||
withErrorStyles={false}
|
|
||||||
width={200}
|
|
||||||
{...form.getInputProps("hostname")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Group justify="flex-end" mt="md">
|
|
||||||
<Button type="submit" disabled={isLoading} loading={isLoading}>
|
|
||||||
{t("Change hostname")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { usePostHog } from "posthog-js/react";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
|
|
||||||
export function PosthogUser() {
|
|
||||||
const posthog = usePostHog();
|
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentUser) {
|
|
||||||
const user = currentUser?.user;
|
|
||||||
const workspace = currentUser?.workspace;
|
|
||||||
if (!user || !workspace) return;
|
|
||||||
|
|
||||||
posthog?.identify(user.id, {
|
|
||||||
name: user.name,
|
|
||||||
email: user.email,
|
|
||||||
workspaceId: user.workspaceId,
|
|
||||||
workspaceHostname: workspace.hostname,
|
|
||||||
lastActiveAt: new Date().toISOString(),
|
|
||||||
createdAt: user.createdAt,
|
|
||||||
source: "docmost-app",
|
|
||||||
});
|
|
||||||
posthog?.group("workspace", workspace.id, {
|
|
||||||
name: workspace.name,
|
|
||||||
hostname: workspace.hostname,
|
|
||||||
plan: workspace?.plan,
|
|
||||||
status: workspace.status,
|
|
||||||
isOnTrial: !!workspace.trialEndAt,
|
|
||||||
hasStripeCustomerId: !!workspace.stripeCustomerId,
|
|
||||||
memberCount: workspace.memberCount,
|
|
||||||
lastActiveAt: new Date().toISOString(),
|
|
||||||
createdAt: workspace.createdAt,
|
|
||||||
source: "docmost-app",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [posthog, currentUser]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { Button, Divider, Stack } from "@mantine/core";
|
|
||||||
import { getGoogleSignupUrl } from "@/ee/security/sso.utils.ts";
|
|
||||||
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
|
|
||||||
|
|
||||||
export default function SsoCloudSignup() {
|
|
||||||
const handleSsoLogin = () => {
|
|
||||||
window.location.href = getGoogleSignupUrl();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Stack align="stretch" justify="center" gap="sm">
|
|
||||||
<Button
|
|
||||||
onClick={handleSsoLogin}
|
|
||||||
leftSection={<GoogleIcon size={16} />}
|
|
||||||
variant="default"
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
Signup with Google
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
<Divider my="xs" label="OR" labelPosition="center" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
|
|
||||||
import { Button, Divider, Stack } from "@mantine/core";
|
|
||||||
import { IconLock, IconServer } from "@tabler/icons-react";
|
|
||||||
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
|
||||||
import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
|
|
||||||
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
|
|
||||||
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
|
|
||||||
import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx";
|
|
||||||
import { getRedirectParam } from "@/lib/app-route.ts";
|
|
||||||
import useCurrentUser from "@/features/user/hooks/use-current-user.ts";
|
|
||||||
|
|
||||||
const SSO_AUTO_ATTEMPT_KEY = "docmost:ssoAutoAttempt";
|
|
||||||
const SSO_AUTO_ATTEMPT_TTL_MS = 5 * 60_000;
|
|
||||||
|
|
||||||
function recentAutoAttempt(): boolean {
|
|
||||||
try {
|
|
||||||
const raw = window.sessionStorage.getItem(SSO_AUTO_ATTEMPT_KEY);
|
|
||||||
if (!raw) return false;
|
|
||||||
const ts = Number(raw);
|
|
||||||
return Number.isFinite(ts) && Date.now() - ts < SSO_AUTO_ATTEMPT_TTL_MS;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function markAutoAttempt(): void {
|
|
||||||
try {
|
|
||||||
window.sessionStorage.setItem(SSO_AUTO_ATTEMPT_KEY, String(Date.now()));
|
|
||||||
} catch {
|
|
||||||
/* sessionStorage unavailable (private mode, etc.) — best effort */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SsoLogin() {
|
|
||||||
const { data, isLoading } = useWorkspacePublicDataQuery();
|
|
||||||
const { data: currentUser } = useCurrentUser();
|
|
||||||
const [ldapModalOpened, setLdapModalOpened] = useState(false);
|
|
||||||
const [selectedLdapProvider, setSelectedLdapProvider] = useState<IAuthProvider | null>(null);
|
|
||||||
const autoRedirectedRef = useRef(false);
|
|
||||||
|
|
||||||
const handleSsoLogin = (provider: IAuthProvider) => {
|
|
||||||
if (provider.type === SSO_PROVIDER.LDAP) {
|
|
||||||
// Open modal for LDAP instead of redirecting
|
|
||||||
setSelectedLdapProvider(provider);
|
|
||||||
setLdapModalOpened(true);
|
|
||||||
} else {
|
|
||||||
// Redirect for other SSO providers
|
|
||||||
window.location.href = buildSsoLoginUrl({
|
|
||||||
providerId: provider.id,
|
|
||||||
type: provider.type,
|
|
||||||
workspaceId: data.id,
|
|
||||||
redirect: getRedirectParam() ?? undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Auto-redirect when SSO is enforced and there is exactly one non-LDAP
|
|
||||||
// provider. The user has no other option, so skip the extra click.
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoRedirectedRef.current) return;
|
|
||||||
if (!data?.enforceSso) return;
|
|
||||||
if (!data.authProviders || data.authProviders.length !== 1) return;
|
|
||||||
const onlyProvider = data.authProviders[0];
|
|
||||||
if (onlyProvider.type === SSO_PROVIDER.LDAP) return;
|
|
||||||
|
|
||||||
// Already signed in: let useRedirectIfAuthenticated handle navigation
|
|
||||||
// instead of racing it through the IdP.
|
|
||||||
if (currentUser?.user) return;
|
|
||||||
|
|
||||||
// Explicit logout: don't immediately bounce them back to the IdP.
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
if (params.has("logout")) return;
|
|
||||||
|
|
||||||
// Circuit-breaker: if we already auto-redirected within the TTL, the
|
|
||||||
// user came back (likely from an IdP failure). Show the page so they
|
|
||||||
// can read errors or pick a different account.
|
|
||||||
if (recentAutoAttempt()) return;
|
|
||||||
|
|
||||||
autoRedirectedRef.current = true;
|
|
||||||
markAutoAttempt();
|
|
||||||
window.location.href = buildSsoLoginUrl({
|
|
||||||
providerId: onlyProvider.id,
|
|
||||||
type: onlyProvider.type,
|
|
||||||
workspaceId: data.id,
|
|
||||||
redirect: getRedirectParam() ?? undefined,
|
|
||||||
});
|
|
||||||
}, [data, currentUser]);
|
|
||||||
|
|
||||||
if (!data?.authProviders || data?.authProviders?.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getProviderIcon = (provider: IAuthProvider) => {
|
|
||||||
if (provider.type === SSO_PROVIDER.GOOGLE) {
|
|
||||||
return <GoogleIcon size={16} />;
|
|
||||||
} else if (provider.type === SSO_PROVIDER.LDAP) {
|
|
||||||
return <IconServer size={16} />;
|
|
||||||
} else {
|
|
||||||
return <IconLock size={16} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{selectedLdapProvider && (
|
|
||||||
<LdapLoginModal
|
|
||||||
opened={ldapModalOpened}
|
|
||||||
onClose={() => {
|
|
||||||
setLdapModalOpened(false);
|
|
||||||
setSelectedLdapProvider(null);
|
|
||||||
}}
|
|
||||||
provider={selectedLdapProvider}
|
|
||||||
workspaceId={data.id}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data.authProviders.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Stack align="stretch" justify="center" gap="sm">
|
|
||||||
{data.authProviders.map((provider) => (
|
|
||||||
<div key={provider.id}>
|
|
||||||
<Button
|
|
||||||
onClick={() => handleSsoLogin(provider)}
|
|
||||||
leftSection={getProviderIcon(provider)}
|
|
||||||
variant="default"
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{provider.name}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{!data.enforceSso && (
|
|
||||||
<Divider my="xs" label="OR" labelPosition="center" />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { atomWithStorage } from "jotai/utils";
|
|
||||||
import type { Entitlements } from "./entitlement.types";
|
|
||||||
|
|
||||||
export const entitlementAtom = atomWithStorage<Entitlements | null>(
|
|
||||||
"entitlements",
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import api from "@/lib/api-client";
|
|
||||||
import { Entitlements } from "./entitlement.types";
|
|
||||||
|
|
||||||
export async function getEntitlements(): Promise<Entitlements> {
|
|
||||||
const req = await api.post<Entitlements>("/workspace/entitlements");
|
|
||||||
return req.data as Entitlements;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export type Tier = "free" | "standard" | "business" | "enterprise";
|
|
||||||
|
|
||||||
export type Entitlements = {
|
|
||||||
cloud: boolean;
|
|
||||||
tier: Tier;
|
|
||||||
features: string[];
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
|
||||||
import { getEntitlements } from "./entitlement-service";
|
|
||||||
import { Entitlements } from "./entitlement.types";
|
|
||||||
|
|
||||||
export function useEntitlements(): UseQueryResult<Entitlements> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["entitlements"],
|
|
||||||
queryFn: getEntitlements,
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
export const Feature = {
|
|
||||||
SSO_CUSTOM: 'sso:custom',
|
|
||||||
SSO_GOOGLE: 'sso:google',
|
|
||||||
MFA: 'mfa',
|
|
||||||
API_KEYS: 'api:keys',
|
|
||||||
COMMENT_RESOLUTION: 'comment:resolution',
|
|
||||||
PAGE_PERMISSIONS: 'page:permissions',
|
|
||||||
AI: 'ai',
|
|
||||||
CONFLUENCE_IMPORT: 'import:confluence',
|
|
||||||
DOCX_IMPORT: 'import:docx',
|
|
||||||
PDF_IMPORT: 'import:pdf',
|
|
||||||
ATTACHMENT_INDEXING: 'attachment:indexing',
|
|
||||||
SECURITY_SETTINGS: 'security:settings',
|
|
||||||
MCP: 'mcp',
|
|
||||||
SCIM: 'scim',
|
|
||||||
PAGE_VERIFICATION: 'page:verification',
|
|
||||||
AUDIT_LOGS: 'audit:logs',
|
|
||||||
RETENTION: 'retention',
|
|
||||||
SHARING_CONTROLS: 'sharing:controls',
|
|
||||||
TEMPLATES: 'templates',
|
|
||||||
VIEWER_COMMENTS: 'comment:viewer',
|
|
||||||
} as const;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { useAtom } from "jotai";
|
|
||||||
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
|
|
||||||
|
|
||||||
export const useHasFeature = (feature: string): boolean => {
|
|
||||||
const [entitlements] = useAtom(entitlementAtom);
|
|
||||||
return entitlements?.features?.includes(feature) ?? false;
|
|
||||||
};
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { useAtom } from "jotai";
|
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
import { BillingPlan } from "@/ee/billing/types/billing.types.ts";
|
|
||||||
|
|
||||||
const usePlan = () => {
|
|
||||||
const [workspace] = useAtom(workspaceAtom);
|
|
||||||
|
|
||||||
const isStandard =
|
|
||||||
typeof workspace?.plan === "string" &&
|
|
||||||
workspace?.plan.toLowerCase() === BillingPlan.STANDARD.toLowerCase();
|
|
||||||
|
|
||||||
const isBusiness =
|
|
||||||
typeof workspace?.plan === "string" &&
|
|
||||||
workspace?.plan.toLowerCase() === BillingPlan.BUSINESS.toLowerCase();
|
|
||||||
|
|
||||||
return { isStandard, isBusiness };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default usePlan;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
|
||||||
import { getAppUrl, getServerAppUrl, isCloud } from "@/lib/config.ts";
|
|
||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
|
||||||
|
|
||||||
export const useRedirectToCloudSelect = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const pathname = useLocation().pathname;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const pathsToRedirect = ["/login", "/home"];
|
|
||||||
if (isCloud() && pathsToRedirect.includes(pathname)) {
|
|
||||||
const frontendUrl = getAppUrl();
|
|
||||||
const serverUrl = getServerAppUrl();
|
|
||||||
if (frontendUrl === serverUrl) {
|
|
||||||
navigate(APP_ROUTE.AUTH.SELECT_WORKSPACE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [navigate]);
|
|
||||||
};
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
|
||||||
import { getBillingTrialDays, isCloud } from "@/lib/config.ts";
|
|
||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import useTrial from "@/ee/hooks/use-trial.tsx";
|
|
||||||
|
|
||||||
export const useTrialEndAction = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const pathname = useLocation().pathname;
|
|
||||||
const { isAdmin } = useUserRole();
|
|
||||||
const { trialDaysLeft } = useTrial();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isCloud() && trialDaysLeft === 0) {
|
|
||||||
if (!pathname.startsWith("/settings")) {
|
|
||||||
notifications.show({
|
|
||||||
position: "top-right",
|
|
||||||
color: "red",
|
|
||||||
title: `Your ${getBillingTrialDays()}-day trial has ended`,
|
|
||||||
message:
|
|
||||||
"Please upgrade to a paid plan or contact your workspace admin.",
|
|
||||||
autoClose: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// only admins can access the billing page
|
|
||||||
if (isAdmin) {
|
|
||||||
navigate(APP_ROUTE.SETTINGS.WORKSPACE.BILLING);
|
|
||||||
} else {
|
|
||||||
navigate(APP_ROUTE.SETTINGS.ACCOUNT.PROFILE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [navigate]);
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user