Compare commits

...

23 Commits

Author SHA1 Message Date
0cf44914ad POC 2025-06-30 01:21:01 -07:00
232cea8cc9 sync 2025-06-27 03:20:01 -07:00
b9643d3584 sync 2025-06-27 03:07:51 -07:00
9f144d35fb posthog integration (cloud) (#1304) 2025-06-27 10:58:36 +01:00
e44c170873 fix editor flickers on collab reconnection (#1295)
* fix editor flickers on reconnection

* cleanup

* adjust copy
2025-06-27 10:58:18 +01:00
1be39d4353 sync 2025-06-27 02:22:11 -07:00
36d028ef4d sync 2025-06-24 05:53:59 -07:00
f5a36c60e8 feat: tiered billing (cloud) (#1294)
* feat: tiered billing (cloud)

* custom tier
2025-06-24 13:22:38 +01:00
d5b84ae0b8 Only allow changing the email if the correct password is provided (#1288)
* fix

* fix overwriting password

* finalize

* BadRequestException

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-06-24 09:02:55 +01:00
e775e4dd8c fix(editor): prevent text color removal from other list items when setting color in lists (#1289)
Only unset color when 'Default' is selected. This ensures setting color on one list item does not remove it from others.
2025-06-23 19:31:30 +01:00
65b01038d7 v0.21.0 2025-06-18 14:28:14 -07:00
e07cb57b01 sync 2025-06-18 14:25:40 -07:00
2b53e0a455 fix: add import size limit to static window config 2025-06-18 13:58:41 -07:00
b9b3406b28 Fix: Prevent premature focus change in TitleEditor when pressing Enter during IME composition (#730)
* fix: Prevents key events during text composition

Stops handling title key events when composing text,
ensuring proper input behavior during IME use.

* Refines IME composition event checks

Separates IME composition control from shift key logic and adds a Safari-specific keyCode check to prevent premature focus shifts during IME input.
2025-06-18 21:33:35 +01:00
728cac0a34 fix word counter (#1269) 2025-06-18 21:32:11 +01:00
d35e16010b handle empty invitation 2025-06-18 13:10:32 -07:00
15791d4e59 sync 2025-06-18 12:50:43 -07:00
3318e13225 fix: use JWT expiry time for cookie duration (#1268)
* Set default jwt expiry to 90 days.
2025-06-18 20:50:11 +01:00
080900610d cleanup 2025-06-17 16:14:06 -07:00
d1dc6977ab feat: edit mode preference (#666)
* lock/unlock pages

* remove using isLocked column - add default page edit state preference

* * Move state management to editors (avoids flickers on edit mode switch)
* Rename variables
* Add strings to translation file
* Memoize components in page component
* Fix title editor sending update request on editable state change

* fixed errors merging main

* Fix embed view in read-only mode

* remove unused line

* sync

* fix responsiveness on mobile

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-06-18 00:11:47 +01:00
5f62448894 less create workspace form fields in cloud (#1265)
* sync

* less signup form fields in cloud

* min length
2025-06-17 23:56:07 +01:00
44445fbf46 fix: enforce SSO in invitation signups (#1258) 2025-06-15 20:25:15 +01:00
1c674efddd fix: revert tiptap version (#1255) 2025-06-13 21:38:49 +01:00
67 changed files with 3662 additions and 461 deletions

View File

@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.20.4",
"version": "0.21.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@ -24,7 +24,8 @@
"@mantine/spotlight": "^7.17.0",
"@tabler/icons-react": "^3.34.0",
"@tanstack/react-query": "^5.80.6",
"@tiptap/extension-character-count": "^2.14.0",
"@tiptap/extension-character-count": "^2.10.3",
"alfaaz": "^1.1.0",
"axios": "^1.9.0",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
@ -40,6 +41,7 @@
"lowlight": "^3.3.0",
"mermaid": "^11.6.0",
"mitt": "^3.0.1",
"posthog-js": "^1.255.1",
"react": "^18.3.1",
"react-arborist": "3.4.0",
"react-clear-modal": "^2.0.15",

View File

@ -354,6 +354,9 @@
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
"New update": "New update",
"{{latestVersion}} is available": "{{latestVersion}} is available",
"Default page edit mode": "Default page edit mode",
"Choose your preferred page edit mode. Avoid accidental edits.": "Choose your preferred page edit mode. Avoid accidental edits.",
"Reading": "Reading"
"Delete member": "Delete member",
"Member deleted successfully": "Member deleted successfully",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Are you sure you want to delete this workspace member? This action is irreversible.",

View File

@ -1,6 +1,8 @@
import { UserProvider } from "@/features/user/user-provider.tsx";
import { Outlet } from "react-router-dom";
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
import { PosthogUser } from "@/ee/components/posthog-user.tsx";
import { isCloud } from "@/lib/config.ts";
export default function Layout() {
return (
@ -8,6 +10,7 @@ export default function Layout() {
<GlobalAppShell>
<Outlet />
</GlobalAppShell>
{isCloud() && <PosthogUser />}
</UserProvider>
);
}

View File

@ -30,12 +30,12 @@ export default function BillingDetails() {
>
Plan
</Text>
<Text fw={700} fz="lg">
{
plans.find(
(plan) => plan.productId === billing.stripeProductId,
)?.name
}
<Text fw={700} fz="lg" tt="capitalize">
{plans.find(
(plan) => plan.productId === billing.stripeProductId,
)?.name ||
billing.planName ||
"Standard"}
</Text>
</div>
</Group>
@ -112,18 +112,58 @@ export default function BillingDetails() {
fz="xs"
className={classes.label}
>
Total
</Text>
<Text fw={700} fz="lg">
{(billing.amount / 100) * billing.quantity}{" "}
{billing.currency.toUpperCase()}
</Text>
<Text c="dimmed" fz="sm">
${billing.amount / 100} /user/{billing.interval}
Cost
</Text>
{billing.billingScheme === "tiered" && (
<>
<Text fw={700} fz="lg">
${billing.amount / 100} {billing.currency.toUpperCase()}
</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()}
</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>
);

View File

@ -2,24 +2,28 @@ import {
Button,
Card,
List,
SegmentedControl,
ThemeIcon,
Title,
Text,
Group,
Select,
Container,
Stack,
Badge,
Flex,
Switch,
} from "@mantine/core";
import { useState } from "react";
import { IconCheck } from "@tabler/icons-react";
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
export default function BillingPlans() {
const { data: plans } = useBillingPlans();
const [interval, setInterval] = useState("yearly");
if (!plans) {
return null;
}
const [isAnnual, setIsAnnual] = useState(true);
const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
null,
);
const handleCheckout = async (priceId: string) => {
try {
@ -32,84 +36,153 @@ export default function BillingPlans() {
}
};
if (!plans || plans.length === 0) {
return null;
}
const firstPlan = plans[0];
// Set initial tier value if not set
if (!selectedTierValue && firstPlan.pricingTiers.length > 0) {
setSelectedTierValue(firstPlan.pricingTiers[0].upTo.toString());
return null;
}
if (!selectedTierValue) {
return null;
}
const selectData = firstPlan.pricingTiers
.filter((tier) => !tier.custom)
.map((tier, index) => {
const prevMaxUsers =
index > 0 ? firstPlan.pricingTiers[index - 1].upTo : 0;
return {
value: tier.upTo.toString(),
label: `${prevMaxUsers + 1}-${tier.upTo} users`,
};
});
return (
<Group justify="center" p="xl">
{plans.map((plan) => {
const price =
interval === "monthly" ? plan.price.monthly : plan.price.yearly;
const priceId = interval === "monthly" ? plan.monthlyId : plan.yearlyId;
const yearlyMonthPrice = parseInt(plan.price.yearly) / 12;
<Container size="xl" py="xl">
{/* Controls Section */}
<Stack gap="xl" mb="md">
{/* Team Size and Billing Controls */}
<Group justify="center" align="center" gap="sm">
<Select
label="Team size"
description="Select the number of users"
value={selectedTierValue}
onChange={setSelectedTierValue}
data={selectData}
w={250}
size="md"
allowDeselect={false}
/>
return (
<Card
key={plan.name}
withBorder
radius="md"
shadow="sm"
p="xl"
w={300}
>
<SegmentedControl
value={interval}
onChange={setInterval}
fullWidth
data={[
{ label: "Monthly", value: "monthly" },
{ label: "Yearly (25% OFF)", value: "yearly" },
]}
/>
<Title order={3} ta="center" mt="sm" mb="xs">
{plan.name}
</Title>
<Text ta="center" size="lg" fw={700}>
{interval === "monthly" && (
<>
${price}{" "}
<Text span size="sm" fw={500} c="dimmed">
/user/month
</Text>
</>
)}
{interval === "yearly" && (
<>
${yearlyMonthPrice}{" "}
<Text span size="sm" fw={500} c="dimmed">
/user/month
</Text>
</>
)}
<br/>
<Text span ta="center" size="md" fw={500} c="dimmed">
billed {interval}
</Text>
</Text>
<Card.Section mt="lg">
<Button onClick={() => handleCheckout(priceId)} fullWidth>
Subscribe
</Button>
</Card.Section>
<Card.Section mt="md">
<List
spacing="xs"
<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"
center
icon={
<ThemeIcon variant="light" size={24} radius="xl">
<IconCheck size={16} />
</ThemeIcon>
}
>
{plan.features.map((feature, index) => (
<List.Item key={index}>{feature}</List.Item>
))}
</List>
</Card.Section>
</Card>
);
})}
</Group>
/>
<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) => {
const tieredPlan = plan;
const planSelectedTier =
tieredPlan.pricingTiers.find(
(tier) => tier.upTo.toString() === selectedTierValue,
) || tieredPlan.pricingTiers[0];
const price = isAnnual
? planSelectedTier.yearly
: planSelectedTier.monthly;
const priceId = isAnnual ? plan.yearlyId : plan.monthlyId;
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">
${isAnnual ? (price / 12).toFixed(0) : price}
</Title>
<Text size="lg" c="dimmed">
per {isAnnual ? "month" : "month"}
</Text>
</Group>
{isAnnual && (
<Text size="sm" c="dimmed">
Billed annually
</Text>
)}
<Text size="md" fw={500}>
For {planSelectedTier.upTo} users
</Text>
</Stack>
{/* CTA Button */}
<Button onClick={() => handleCheckout(priceId)} fullWidth>
Upgrade
</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>
);
}

View File

@ -25,6 +25,11 @@ export interface IBilling {
createdAt: Date;
updatedAt: Date;
deletedAt: Date;
billingScheme: string | null;
tieredUpTo: string | null;
tieredFlatAmount: number | null;
tieredUnitAmount: number | null;
planName: string | null;
}
export interface ICheckoutLink {
@ -42,9 +47,18 @@ export interface IBillingPlan {
monthlyId: string;
yearlyId: string;
currency: string;
price: {
price?: {
monthly: string;
yearly: string;
};
features: string[];
billingScheme: string | null;
pricingTiers: PricingTier[];
}
interface PricingTier {
upTo: number;
monthly?: number;
yearly?: number;
custom?: boolean;
}

View File

@ -0,0 +1,41 @@
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;
}

View File

@ -18,6 +18,7 @@ import classes from "@/features/auth/components/auth.module.css";
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { useTranslation } from "react-i18next";
import SsoLogin from "@/ee/components/sso-login.tsx";
const formSchema = z.object({
name: z.string().trim().min(1),
@ -71,39 +72,43 @@ export function InviteSignUpForm() {
{t("Join the workspace")}
</Title>
<Stack align="stretch" justify="center" gap="xl">
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="name"
type="text"
label={t("Name")}
placeholder={t("enter your full name")}
variant="filled"
{...form.getInputProps("name")}
/>
<SsoLogin />
<TextInput
id="email"
type="email"
label={t("Email")}
value={invitation.email}
disabled
variant="filled"
mt="md"
/>
{!invitation.enforceSso && (
<Stack align="stretch" justify="center" gap="xl">
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="name"
type="text"
label={t("Name")}
placeholder={t("enter your full name")}
variant="filled"
{...form.getInputProps("name")}
/>
<PasswordInput
label={t("Password")}
placeholder={t("Your password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
{t("Sign Up")}
</Button>
</form>
</Stack>
<TextInput
id="email"
type="email"
label={t("Email")}
value={invitation.email}
disabled
variant="filled"
mt="md"
/>
<PasswordInput
label={t("Password")}
placeholder={t("Your password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
{t("Sign Up")}
</Button>
</form>
</Stack>
)}
</Box>
</Container>
);

View File

@ -21,7 +21,7 @@ import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
const formSchema = z.object({
workspaceName: z.string().trim().min(3).max(50),
workspaceName: z.string().trim().max(50).optional(),
name: z.string().min(1).max(50),
email: z
.string()
@ -60,15 +60,17 @@ export function SetupWorkspaceForm() {
{isCloud() && <SsoCloudSignup />}
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="workspaceName"
type="text"
label={t("Workspace Name")}
placeholder={t("e.g ACME Inc")}
variant="filled"
mt="md"
{...form.getInputProps("workspaceName")}
/>
{!isCloud() && (
<TextInput
id="workspaceName"
type="text"
label={t("Workspace Name")}
placeholder={t("e.g ACME Inc")}
variant="filled"
mt="md"
{...form.getInputProps("workspaceName")}
/>
)}
<TextInput
id="name"

View File

@ -10,7 +10,7 @@ export interface IRegister {
}
export interface ISetupWorkspace {
workspaceName: string;
workspaceName?: string;
name: string;
email: string;
password: string;

View File

@ -156,13 +156,11 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
)
}
onClick={() => {
editor.commands.unsetColor();
name !== "Default" &&
editor
.chain()
.focus()
.setColor(color || "")
.run();
if (name === "Default") {
editor.commands.unsetColor();
} else {
editor.chain().focus().setColor(color || "").run();
}
setIsOpen(false);
}}
style={{ border: "none" }}

View File

@ -32,7 +32,7 @@ const schema = z.object({
export default function EmbedView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, selected, updateAttributes } = props;
const { node, selected, updateAttributes, editor } = props;
const { src, provider } = node.attrs;
const embedUrl = useMemo(() => {
@ -50,6 +50,10 @@ export default function EmbedView(props: NodeViewProps) {
});
async function onSubmit(data: { url: string }) {
if (!editor.isEditable) {
return;
}
if (provider) {
const embedProvider = getEmbedProviderById(provider);
if (embedProvider.id === "iframe") {
@ -85,7 +89,13 @@ export default function EmbedView(props: NodeViewProps) {
</AspectRatio>
</>
) : (
<Popover width={300} position="bottom" withArrow shadow="md">
<Popover
width={300}
position="bottom"
withArrow
shadow="md"
disabled={!editor.isEditable}
>
<Popover.Target>
<Card
radius="md"

View File

@ -73,6 +73,7 @@ import i18n from "@/i18n.ts";
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
import EmojiCommand from "./emoji-command";
import { CharacterCount } from "@tiptap/extension-character-count";
import { countWords } from "alfaaz";
const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext);
@ -213,7 +214,9 @@ export const mainExtensions = [
MarkdownClipboard.configure({
transformPastedText: true,
}),
CharacterCount
CharacterCount.configure({
wordCounter: (text) => countWords(text),
}),
] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
@ -229,4 +232,4 @@ export const collabExtensions: CollabExtensions = (provider, user) => [
color: randomElement(userColors),
},
}),
];
];

View File

@ -42,7 +42,11 @@ export function FullEditor({
spaceSlug={spaceSlug}
editable={editable}
/>
<MemoizedPageEditor pageId={pageId} editable={editable} content={content} />
<MemoizedPageEditor
pageId={pageId}
editable={editable}
content={content}
/>
</Container>
);
}

View File

@ -1,7 +1,6 @@
import "@/features/editor/styles/index.css";
import React, {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@ -52,6 +51,7 @@ import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom";
import { extractPageSlugId } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
interface PageEditorProps {
@ -71,7 +71,11 @@ export default function PageEditor({
const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const ydoc = useMemo(() => new Y.Doc(), [pageId]);
const ydocRef = useRef<Y.Doc | null>(null);
if (!ydocRef.current) {
ydocRef.current = new Y.Doc();
}
const ydoc = ydocRef.current;
const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
@ -85,67 +89,103 @@ export default function PageEditor({
const [isCollabReady, setIsCollabReady] = useState(false);
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const localProvider = useMemo(() => {
const provider = new IndexeddbPersistence(documentName, ydoc);
// Providers only created once per pageId
const providersRef = useRef<{
local: IndexeddbPersistence;
remote: HocuspocusProvider;
} | null>(null);
const [providersReady, setProvidersReady] = useState(false);
provider.on("synced", () => {
setLocalSynced(true);
});
const localProvider = providersRef.current?.local;
const remoteProvider = providersRef.current?.remote;
return provider;
}, [pageId, ydoc]);
// Track when collaborative provider is ready and synced
const [collabReady, setCollabReady] = useState(false);
useEffect(() => {
if (
remoteProvider?.status === WebSocketStatus.Connected &&
isLocalSynced &&
isRemoteSynced
) {
setCollabReady(true);
}
}, [remoteProvider?.status, isLocalSynced, isRemoteSynced]);
const remoteProvider = useMemo(() => {
const provider = new HocuspocusProvider({
name: documentName,
url: collaborationURL,
document: ydoc,
token: collabQuery?.token,
connect: false,
preserveConnection: false,
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken();
}
},
onStatus: (status) => {
if (status.status === "connected") {
setYjsConnectionStatus(status.status);
}
},
});
provider.on("synced", () => {
setRemoteSynced(true);
});
provider.on("disconnect", () => {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
});
return provider;
}, [ydoc, pageId, collabQuery?.token]);
useLayoutEffect(() => {
remoteProvider.connect();
useEffect(() => {
if (!providersRef.current) {
const local = new IndexeddbPersistence(documentName, ydoc);
local.on("synced", () => setLocalSynced(true));
const remote = new HocuspocusProvider({
name: documentName,
url: collaborationURL,
document: ydoc,
token: collabQuery?.token,
connect: true,
preserveConnection: false,
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken();
}
},
onStatus: (status) => {
if (status.status === "connected") {
setYjsConnectionStatus(status.status);
}
},
});
remote.on("synced", () => setRemoteSynced(true));
remote.on("disconnect", () => {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
});
providersRef.current = { local, remote };
setProvidersReady(true);
} else {
setProvidersReady(true);
}
// Only destroy on final unmount
return () => {
setRemoteSynced(false);
setLocalSynced(false);
remoteProvider.destroy();
localProvider.destroy();
providersRef.current?.remote.destroy();
providersRef.current?.local.destroy();
providersRef.current = null;
};
}, [remoteProvider, localProvider]);
}, [pageId]);
// Only connect/disconnect on tab/idle, not destroy
useEffect(() => {
if (!providersReady || !providersRef.current) return;
const remoteProvider = providersRef.current.remote;
if (
isIdle &&
documentState === "hidden" &&
remoteProvider.status === WebSocketStatus.Connected
) {
remoteProvider.disconnect();
setIsCollabReady(false);
return;
}
if (
documentState === "visible" &&
remoteProvider.status === WebSocketStatus.Disconnected
) {
resetIdle();
remoteProvider.connect();
setTimeout(() => setIsCollabReady(true), 500);
}
}, [isIdle, documentState, providersReady, resetIdle]);
const extensions = useMemo(() => {
if (!remoteProvider || !currentUser?.user) return mainExtensions;
return [
...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user),
];
}, [ydoc, pageId, remoteProvider, currentUser?.user]);
}, [remoteProvider, currentUser?.user]);
const editor = useEditor(
{
@ -199,7 +239,7 @@ export default function PageEditor({
debouncedUpdateContent(editorJson);
},
},
[pageId, editable, remoteProvider?.status],
[pageId, editable, remoteProvider],
);
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
@ -252,29 +292,6 @@ export default function PageEditor({
}
}, [remoteProvider?.status]);
useEffect(() => {
if (
isIdle &&
documentState === "hidden" &&
remoteProvider?.status === WebSocketStatus.Connected
) {
remoteProvider.disconnect();
setIsCollabReady(false);
return;
}
if (
documentState === "visible" &&
remoteProvider?.status === WebSocketStatus.Disconnected
) {
resetIdle();
remoteProvider.connect();
setTimeout(() => {
setIsCollabReady(true);
}, 600);
}
}, [isIdle, documentState, remoteProvider]);
const isSynced = isLocalSynced && isRemoteSynced;
useEffect(() => {
@ -290,11 +307,49 @@ export default function PageEditor({
return () => clearTimeout(collabReadyTimeout);
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
return isCollabReady ? (
<div>
useEffect(() => {
// Only honor user default page edit mode preference and permissions
if (editor) {
if (userPageEditMode && editable) {
if (userPageEditMode === PageEditMode.Edit) {
editor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
editor.setEditable(false);
}
} else {
editor.setEditable(false);
}
}
}, [userPageEditMode, editor, editable]);
const hasConnectedOnceRef = useRef(false);
const [showStatic, setShowStatic] = useState(true);
useEffect(() => {
if (
!hasConnectedOnceRef.current &&
remoteProvider?.status === WebSocketStatus.Connected
) {
hasConnectedOnceRef.current = true;
setShowStatic(false);
}
}, [remoteProvider?.status]);
if (showStatic) {
return (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
/>
);
}
return (
<div style={{ position: "relative" }}>
<div ref={menuContainerRef}>
<EditorContent editor={editor} />
{editor && editor.isEditable && (
<div>
<EditorBubbleMenu editor={editor} />
@ -308,21 +363,12 @@ export default function PageEditor({
<LinkMenu editor={editor} appendTo={menuContainerRef} />
</div>
)}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
</div>
<div
onClick={() => editor.commands.focus("end")}
style={{ paddingBottom: "20vh" }}
></div>
</div>
) : (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
></EditorProvider>
);
}

View File

@ -21,6 +21,8 @@ import { useTranslation } from "react-i18next";
import EmojiCommand from "@/features/editor/extensions/emoji-command.ts";
import { UpdateEvent } from "@/features/websocket/types";
import localEmitter from "@/lib/local-emitter.ts";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
export interface TitleEditorProps {
pageId: string;
@ -44,6 +46,9 @@ export function TitleEditor({
const emit = useQueryEmit();
const navigate = useNavigate();
const [activePageId, setActivePageId] = useState(pageId);
const [currentUser] = useAtom(currentUserAtom);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const titleEditor = useEditor({
extensions: [
@ -136,9 +141,24 @@ export function TitleEditor({
};
}, [pageId]);
function handleTitleKeyDown(event) {
if (!titleEditor || !pageEditor || event.shiftKey) return;
useEffect(() => {
// honor user default page edit mode preference
if (userPageEditMode && titleEditor && editable) {
if (userPageEditMode === PageEditMode.Edit) {
titleEditor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
titleEditor.setEditable(false);
}
}
}, [userPageEditMode, titleEditor, editable]);
function handleTitleKeyDown(event: any) {
if (!titleEditor || !pageEditor || event.shiftKey) return;
// Prevent focus shift when IME composition is active
// `keyCode === 229` is added to support Safari where `isComposing` may not be reliable
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
const { key } = event;
const { $head } = titleEditor.state.selection;

View File

@ -1,24 +1,30 @@
.breadcrumbs {
display: flex;
align-items: center;
display: flex;
align-items: center;
overflow: hidden;
flex-wrap: nowrap;
a {
color: var(--mantine-color-default-color);
line-height: inherit;
}
.mantine-Breadcrumbs-breadcrumb {
min-width: 1px;
overflow: hidden;
flex-wrap: nowrap;
a {
color: var(--mantine-color-default-color);
line-height: inherit;
}
.mantine-Breadcrumbs-breadcrumb {
min-width: 1px;
overflow: hidden;
}
}
}
.truncatedText {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.breadcrumbDiv {
overflow: hidden;
@media (max-width: $mantine-breakpoint-sm) {
overflow: visible;
}
}

View File

@ -161,7 +161,7 @@ export default function Breadcrumb() {
};
return (
<div style={{ overflow: "hidden" }}>
<div className={classes.breadcrumbDiv}>
{breadcrumbNodes && (
<Breadcrumbs className={classes.breadcrumbs}>
{isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()}

View File

@ -33,6 +33,7 @@ import {
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { formattedDate, timeAgo } from "@/lib/time.ts";
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import ShareModal from "@/features/share/components/share-modal.tsx";
@ -59,6 +60,8 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
</Tooltip>
)}
{!readOnly && <PageStateSegmentedControl size="xs" />}
<ShareModal readOnly={readOnly} />
<Tooltip label={t("Comments")} openDelay={250} withArrow>

View File

@ -1,15 +1,27 @@
.header {
height: 45px;
background-color: var(--mantine-color-body);
padding-left: var(--mantine-spacing-md);
padding-right: var(--mantine-spacing-md);
position: fixed;
z-index: 99;
top: var(--app-shell-header-offset, 0rem);
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem);
height: 45px;
background-color: var(--mantine-color-body);
padding-left: var(--mantine-spacing-md);
padding-right: var(--mantine-spacing-md);
position: fixed;
z-index: 99;
top: var(--app-shell-header-offset, 0rem);
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem);
@media print {
display: none;
}
@media (max-width: $mantine-breakpoint-sm) {
padding-left: var(--mantine-spacing-xs);
padding-right: var(--mantine-spacing-xs);
}
@media print {
display: none;
}
}
.group {
@media (max-width: $mantine-breakpoint-sm) {
gap: var(--mantine-spacing-sm);
padding-inline: 0 !important;
}
}

View File

@ -9,10 +9,10 @@ interface Props {
export default function PageHeader({ readOnly }: Props) {
return (
<div className={classes.header}>
<Group justify="space-between" h="100%" px="md" wrap="nowrap">
<Group justify="space-between" h="100%" px="md" wrap="nowrap" className={classes.group}>
<Breadcrumb />
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
<Group justify="flex-end" h="100%" px="md" wrap="nowrap" gap="var(--mantine-spacing-xs)">
<PageHeaderMenu readOnly={readOnly} />
</Group>
</Group>

View File

@ -65,6 +65,7 @@ export interface IPageInput {
icon: string;
coverPhoto: string;
position: string;
isLocked: boolean;
}
export interface IExportPageParams {

View File

@ -11,7 +11,7 @@ import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().min(2).max(40),
name: z.string().min(1).max(40),
});
type FormValues = z.infer<typeof formSchema>;

View File

@ -0,0 +1,65 @@
import { Group, Text, MantineSize, SegmentedControl } from "@mantine/core";
import { useAtom } from "jotai";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { updateUser } from "@/features/user/services/user-service.ts";
import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { PageEditMode } from "@/features/user/types/user.types.ts";
export default function PageStatePref() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Default page edit mode")}</Text>
<Text size="sm" c="dimmed">
{t("Choose your preferred page edit mode. Avoid accidental edits.")}
</Text>
</div>
<PageStateSegmentedControl />
</Group>
);
}
interface PageStateSegmentedControlProps {
size?: MantineSize;
}
export function PageStateSegmentedControl({
size,
}: PageStateSegmentedControlProps) {
const { t } = useTranslation();
const [user, setUser] = useAtom(userAtom);
const pageEditMode =
user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const [value, setValue] = useState(pageEditMode);
const handleChange = useCallback(
async (value: string) => {
const updatedUser = await updateUser({ pageEditMode: value });
setValue(value);
setUser(updatedUser);
},
[user, setUser],
);
useEffect(() => {
if (pageEditMode !== value) {
setValue(pageEditMode);
}
}, [pageEditMode, value]);
return (
<SegmentedControl
size={size}
value={value}
onChange={handleChange}
data={[
{ label: t("Edit"), value: PageEditMode.Edit },
{ label: t("Read"), value: PageEditMode.Read },
]}
/>
);
}

View File

@ -19,6 +19,7 @@ export interface IUser {
deactivatedAt: Date;
deletedAt: Date;
fullPageWidth: boolean; // used for update
pageEditMode: string; // used for update
}
export interface ICurrentUser {
@ -29,5 +30,11 @@ export interface ICurrentUser {
export interface IUserSettings {
preferences: {
fullPageWidth: boolean;
pageEditMode: string;
};
}
}
export enum PageEditMode {
Read = "read",
Edit = "edit",
}

View File

@ -11,7 +11,7 @@ import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().min(4),
name: z.string().min(1),
});
type FormValues = z.infer<typeof formSchema>;

View File

@ -173,7 +173,7 @@ export function useRevokeInvitationMutation() {
export function useGetInvitationQuery(
invitationId: string,
): UseQueryResult<any, Error> {
): UseQueryResult<IInvitation, Error> {
return useQuery({
queryKey: ["invitations", invitationId],
queryFn: () => getInvitationById({ invitationId }),

View File

@ -12,6 +12,7 @@ export interface IWorkspace {
settings: any;
status: string;
enforceSso: boolean;
stripeCustomerId: string;
billingEmail: string;
trialEndAt: Date;
createdAt: Date;
@ -35,6 +36,7 @@ export interface IInvitation {
workspaceId: string;
invitedById: string;
createdAt: Date;
enforceSso: boolean;
}
export interface IInvitationLink {

View File

@ -83,6 +83,18 @@ export function getBillingTrialDays() {
return getConfigValue("BILLING_TRIAL_DAYS");
}
export function getPostHogHost() {
return getConfigValue("POSTHOG_HOST");
}
export function isPostHogEnabled(): boolean {
return Boolean(getPostHogHost() && getPostHogKey());
}
export function getPostHogKey() {
return getConfigValue("POSTHOG_KEY");
}
function getConfigValue(key: string, defaultValue: string = undefined): string {
const rawValue = import.meta.env.DEV
? process?.env?.[key]

View File

@ -3,7 +3,7 @@ import "@mantine/spotlight/styles.css";
import "@mantine/notifications/styles.css";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { mantineCssResolver, theme } from '@/theme';
import { mantineCssResolver, theme } from "@/theme";
import { MantineProvider } from "@mantine/core";
import { BrowserRouter } from "react-router-dom";
import { ModalsProvider } from "@mantine/modals";
@ -11,6 +11,14 @@ import { Notifications } from "@mantine/notifications";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HelmetProvider } from "react-helmet-async";
import "./i18n";
import { PostHogProvider } from "posthog-js/react";
import {
getPostHogHost,
getPostHogKey,
isCloud,
isPostHogEnabled,
} from "@/lib/config.ts";
import posthog from "posthog-js";
export const queryClient = new QueryClient({
defaultOptions: {
@ -23,9 +31,16 @@ export const queryClient = new QueryClient({
},
});
if (isCloud() && isPostHogEnabled) {
posthog.init(getPostHogKey(), {
api_host: getPostHogHost(),
defaults: "2025-05-24",
disable_session_recording: true,
});
}
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
document.getElementById("root") as HTMLElement,
);
root.render(
@ -35,10 +50,12 @@ root.render(
<QueryClientProvider client={queryClient}>
<Notifications position="bottom-center" limit={3} />
<HelmetProvider>
<App />
<PostHogProvider client={posthog}>
<App />
</PostHogProvider>
</HelmetProvider>
</QueryClientProvider>
</ModalsProvider>
</MantineProvider>
</BrowserRouter>
</BrowserRouter>,
);

View File

@ -12,6 +12,11 @@ import {
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
import { useTranslation } from "react-i18next";
import React from "react";
const MemoizedFullEditor = React.memo(FullEditor);
const MemoizedPageHeader = React.memo(PageHeader);
const MemoizedHistoryModal = React.memo(HistoryModal);
export default function Page() {
const { t } = useTranslation();
@ -49,14 +54,14 @@ export default function Page() {
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
</Helmet>
<PageHeader
<MemoizedPageHeader
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
)}
/>
<FullEditor
<MemoizedFullEditor
key={page.id}
pageId={page.id}
title={page.title}
@ -68,7 +73,7 @@ export default function Page() {
SpaceCaslSubject.Page,
)}
/>
<HistoryModal pageId={page.id} />
<MemoizedHistoryModal pageId={page.id} />
</div>
)
);

View File

@ -2,6 +2,7 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
import AccountLanguage from "@/features/user/components/account-language.tsx";
import AccountTheme from "@/features/user/components/account-theme.tsx";
import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
import PageEditPref from "@/features/user/components/page-state-pref";
import { getAppName } from "@/lib/config.ts";
import { Divider } from "@mantine/core";
import { Helmet } from "react-helmet-async";
@ -28,6 +29,10 @@ export default function AccountPreferences() {
<Divider my={"md"} />
<PageWidthPref />
<Divider my={"md"} />
<PageEditPref />
</>
);
}

View File

@ -14,6 +14,8 @@ export default defineConfig(({ mode }) => {
SUBDOMAIN_HOST,
COLLAB_URL,
BILLING_TRIAL_DAYS,
POSTHOG_HOST,
POSTHOG_KEY,
} = loadEnv(mode, envPath, "");
return {
@ -27,6 +29,8 @@ export default defineConfig(({ mode }) => {
SUBDOMAIN_HOST,
COLLAB_URL,
BILLING_TRIAL_DAYS,
POSTHOG_HOST,
POSTHOG_KEY,
},
APP_VERSION: JSON.stringify(process.env.npm_package_version),
},

View File

@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.20.4",
"version": "0.21.0",
"description": "",
"author": "",
"private": true,
@ -70,6 +70,7 @@
"nanoid": "3.3.11",
"nestjs-kysely": "^1.2.0",
"nodemailer": "^7.0.3",
"openai": "^5.8.2",
"openid-client": "^5.7.1",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
@ -77,6 +78,7 @@
"pg-tsquery": "^8.4.2",
"postmark": "^4.0.5",
"react": "^18.3.1",
"redis": "^5.5.6",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"sanitize-filename-ts": "^1.0.2",

View File

@ -156,6 +156,7 @@ export class PersistenceExtension implements Extension {
page: {
...page,
content: tiptapJson,
textContent: textContent,
lastUpdatedById: context.user.id,
},
});

View File

@ -16,6 +16,12 @@ export async function comparePasswordHash(
return bcrypt.compare(plainPassword, passwordHash);
}
export function generateRandomSuffixNumbers(length: number) {
return Math.random()
.toFixed(length)
.substring(2, 2 + length);
}
export type RedisConfig = {
host: string;
port: number;

View File

@ -0,0 +1,444 @@
# AI Search Integration Guide
This guide shows how to integrate the AI Search module with your existing page operations for automatic indexing.
## Event-Based Auto-Indexing
The AI Search module uses event listeners to automatically index pages when they are created, updated, or deleted.
### Emitting Events in Page Service
Update your existing `PageService` to emit events for AI search indexing:
```typescript
// In your page.service.ts
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Injectable } from '@nestjs/common';
@Injectable()
export class PageService {
constructor(
private readonly eventEmitter: EventEmitter2,
// ... other dependencies
) {}
async createPage(createPageDto: CreatePageDto): Promise<Page> {
// Your existing page creation logic
const page = await this.pageRepo.create(createPageDto);
// Emit event for AI search indexing
this.eventEmitter.emit('page.created', {
pageId: page.id,
workspaceId: page.workspaceId,
spaceId: page.spaceId,
title: page.title,
textContent: page.textContent,
operation: 'create'
});
return page;
}
async updatePage(pageId: string, updatePageDto: UpdatePageDto): Promise<Page> {
// Your existing page update logic
const page = await this.pageRepo.update(pageId, updatePageDto);
// Emit event for AI search reindexing
this.eventEmitter.emit('page.updated', {
pageId: page.id,
workspaceId: page.workspaceId,
spaceId: page.spaceId,
title: page.title,
textContent: page.textContent,
operation: 'update'
});
return page;
}
async deletePage(pageId: string): Promise<void> {
// Get page info before deletion
const page = await this.pageRepo.findById(pageId);
// Your existing page deletion logic
await this.pageRepo.delete(pageId);
// Emit event for AI search cleanup
if (page) {
this.eventEmitter.emit('page.deleted', {
pageId: page.id,
workspaceId: page.workspaceId,
spaceId: page.spaceId,
operation: 'delete'
});
}
}
}
```
### Adding EventEmitter to Page Module
Make sure your `PageModule` imports the `EventEmitterModule`:
```typescript
// In your page.module.ts
import { Module } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { PageService } from './services/page.service';
import { PageController } from './page.controller';
@Module({
imports: [
EventEmitterModule, // Add this if not already present
],
controllers: [PageController],
providers: [PageService],
exports: [PageService],
})
export class PageModule {}
```
### Bulk Operations
For bulk operations, you can emit multiple events or use a bulk reindex:
```typescript
async bulkUpdatePages(updates: BulkUpdateDto[]): Promise<Page[]> {
const updatedPages = await this.pageRepo.bulkUpdate(updates);
// Option 1: Emit individual events
for (const page of updatedPages) {
this.eventEmitter.emit('page.updated', {
pageId: page.id,
workspaceId: page.workspaceId,
spaceId: page.spaceId,
title: page.title,
textContent: page.textContent,
operation: 'update'
});
}
// Option 2: Use bulk reindex (more efficient for large batches)
// const pageIds = updatedPages.map(p => p.id);
// this.eventEmitter.emit('ai-search.bulk-reindex', {
// pageIds,
// workspaceId: updatedPages[0]?.workspaceId
// });
return updatedPages;
}
```
## Manual Integration
If you prefer manual control over indexing, you can directly use the AI search services:
```typescript
// In your page.service.ts
import { AiSearchService } from '../ai-search/services/ai-search.service';
@Injectable()
export class PageService {
constructor(
private readonly aiSearchService: AiSearchService,
// ... other dependencies
) {}
async createPageWithSearch(createPageDto: CreatePageDto): Promise<Page> {
const page = await this.pageRepo.create(createPageDto);
// Manually trigger indexing
try {
await this.aiSearchService.reindexPages({
pageIds: [page.id],
workspaceId: page.workspaceId
});
} catch (error) {
// Log error but don't fail the page creation
console.error('Failed to index page for AI search:', error);
}
return page;
}
}
```
## Frontend Integration
### Adding AI Search to Client
Create AI search service on the client side:
```typescript
// apps/client/src/features/ai-search/services/ai-search-service.ts
import api from "@/lib/api-client";
export interface AiSearchParams {
query: string;
spaceId?: string;
limit?: number;
similarity_threshold?: number;
}
export interface AiSearchResult {
id: string;
title: string;
icon: string;
similarity_score: number;
highlight: string;
space?: {
id: string;
name: string;
slug: string;
};
}
export async function semanticSearch(params: AiSearchParams): Promise<AiSearchResult[]> {
const response = await api.post<AiSearchResult[]>("/ai-search/semantic", params);
return response.data;
}
export async function hybridSearch(params: AiSearchParams): Promise<AiSearchResult[]> {
const response = await api.post<AiSearchResult[]>("/ai-search/hybrid", params);
return response.data;
}
```
### React Query Integration
```typescript
// apps/client/src/features/ai-search/queries/ai-search-query.ts
import { useQuery } from "@tanstack/react-query";
import { semanticSearch, hybridSearch, AiSearchParams } from "../services/ai-search-service";
export function useAiSemanticSearchQuery(params: AiSearchParams) {
return useQuery({
queryKey: ["ai-search", "semantic", params],
queryFn: () => semanticSearch(params),
enabled: !!params.query && params.query.length > 0,
});
}
export function useAiHybridSearchQuery(params: AiSearchParams) {
return useQuery({
queryKey: ["ai-search", "hybrid", params],
queryFn: () => hybridSearch(params),
enabled: !!params.query && params.query.length > 0,
});
}
```
### AI Search Component
```typescript
// apps/client/src/features/ai-search/components/ai-search-spotlight.tsx
import React, { useState } from "react";
import { Spotlight } from "@mantine/spotlight";
import { IconSearch, IconBrain } from "@tabler/icons-react";
import { useDebouncedValue } from "@mantine/hooks";
import { useAiSemanticSearchQuery } from "../queries/ai-search-query";
export function AiSearchSpotlight() {
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const { data: results, isLoading } = useAiSemanticSearchQuery({
query: debouncedQuery,
limit: 10,
similarity_threshold: 0.7,
});
return (
<Spotlight.Root query={query} onQueryChange={setQuery}>
<Spotlight.Search
placeholder="AI-powered semantic search..."
leftSection={<IconBrain size={20} />}
/>
<Spotlight.ActionsList>
{isLoading && <Spotlight.Empty>Searching...</Spotlight.Empty>}
{!isLoading && (!results || results.length === 0) && (
<Spotlight.Empty>No results found</Spotlight.Empty>
)}
{results?.map((result) => (
<Spotlight.Action key={result.id}>
<div>
<div>{result.title}</div>
<div style={{ fontSize: '0.8em', opacity: 0.7 }}>
Similarity: {(result.similarity_score * 100).toFixed(1)}%
</div>
{result.highlight && (
<div
style={{ fontSize: '0.8em', opacity: 0.6 }}
dangerouslySetInnerHTML={{ __html: result.highlight }}
/>
)}
</div>
</Spotlight.Action>
))}
</Spotlight.ActionsList>
</Spotlight.Root>
);
}
```
## Search Mode Toggle
Create a component that allows users to choose between traditional and AI search:
```typescript
// apps/client/src/features/search/components/search-mode-toggle.tsx
import { SegmentedControl } from "@mantine/core";
import { IconSearch, IconBrain } from "@tabler/icons-react";
interface SearchModeToggleProps {
value: 'traditional' | 'ai' | 'hybrid';
onChange: (value: 'traditional' | 'ai' | 'hybrid') => void;
}
export function SearchModeToggle({ value, onChange }: SearchModeToggleProps) {
return (
<SegmentedControl
value={value}
onChange={onChange}
data={[
{
label: 'Traditional',
value: 'traditional',
icon: IconSearch,
},
{
label: 'AI Semantic',
value: 'ai',
icon: IconBrain,
},
{
label: 'Hybrid',
value: 'hybrid',
icon: IconBrain,
},
]}
/>
);
}
```
## Performance Considerations
### Async Indexing
For better performance, consider making indexing asynchronous:
```typescript
// Use a queue for heavy indexing operations
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
@Injectable()
export class PageService {
constructor(
@InjectQueue('ai-search') private aiSearchQueue: Queue,
) {}
async createPage(createPageDto: CreatePageDto): Promise<Page> {
const page = await this.pageRepo.create(createPageDto);
// Queue indexing job instead of doing it synchronously
await this.aiSearchQueue.add('index-page', {
pageId: page.id,
workspaceId: page.workspaceId,
spaceId: page.spaceId,
title: page.title,
textContent: page.textContent,
});
return page;
}
}
```
### Conditional Indexing
Only index pages when AI search is configured:
```typescript
async createPage(createPageDto: CreatePageDto): Promise<Page> {
const page = await this.pageRepo.create(createPageDto);
// Check if AI search is enabled before emitting events
if (this.embeddingService.isConfigured()) {
this.eventEmitter.emit('page.created', {
pageId: page.id,
workspaceId: page.workspaceId,
spaceId: page.spaceId,
title: page.title,
textContent: page.textContent,
operation: 'create'
});
}
return page;
}
```
## Testing Integration
### Unit Tests
```typescript
// page.service.spec.ts
import { EventEmitter2 } from '@nestjs/event-emitter';
describe('PageService', () => {
let service: PageService;
let eventEmitter: EventEmitter2;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
PageService,
{
provide: EventEmitter2,
useValue: {
emit: jest.fn(),
},
},
],
}).compile();
service = module.get<PageService>(PageService);
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
});
it('should emit page.created event when creating page', async () => {
const createPageDto = { title: 'Test Page', content: 'Test content' };
await service.createPage(createPageDto);
expect(eventEmitter.emit).toHaveBeenCalledWith('page.created',
expect.objectContaining({
operation: 'create',
title: 'Test Page',
})
);
});
});
```
## Monitoring and Analytics
### Track Search Usage
```typescript
// Add search analytics
this.eventEmitter.emit('ai-search.query', {
query: searchParams.query,
userId: opts.userId,
workspaceId: opts.workspaceId,
searchType: 'semantic',
resultCount: results.length,
executionTime: Date.now() - startTime,
});
```
This integration approach ensures that your AI search stays in sync with your content while maintaining good performance and error handling.

View File

@ -0,0 +1,201 @@
# AI Search Module
A comprehensive AI-powered semantic search module for Docmost that integrates with Redis vector database using the official **node-redis** client to provide intelligent search capabilities following Redis vector search specifications.
## Features
- **Semantic Search**: Find content based on meaning rather than exact keywords using vector embeddings
- **Hybrid Search**: Combines both semantic and traditional full-text search with configurable weights
- **Redis Vector Database**: Uses Redis with RediSearch module for efficient vector operations via node-redis client
- **HNSW Indexing**: Hierarchical Navigable Small World algorithm for fast approximate nearest neighbor search
- **Auto-indexing**: Automatically indexes pages when they are created or updated
- **OpenAI-Compatible**: Supports OpenAI and OpenAI-compatible embedding providers
- **Batch Operations**: Efficient batch processing for large-scale indexing
- **Permission-aware**: Respects user permissions and workspace access
- **COSINE Distance**: Uses cosine distance metric for semantic similarity
## Architecture
```
ai-search/
├── ai-search.controller.ts # REST API endpoints
├── ai-search.module.ts # Module configuration
├── dto/
│ └── semantic-search.dto.ts # Request/response DTOs
├── services/
│ ├── ai-search.service.ts # Main search logic
│ ├── embedding.service.ts # Text embedding generation
│ ├── redis-vector.service.ts # Redis vector operations (node-redis)
│ └── vector.service.ts # Vector math utilities
├── listeners/
│ └── page-update.listener.ts # Auto-indexing on page changes
├── constants.ts # Configuration constants
├── README.md # This file
├── SETUP.md # Setup guide
└── INTEGRATION.md # Integration examples
```
## Configuration
Add these environment variables to your `.env` file:
```env
# Redis Vector Database (using node-redis client)
REDIS_VECTOR_HOST=localhost
REDIS_VECTOR_PORT=6379
REDIS_VECTOR_PASSWORD=your_redis_password
REDIS_VECTOR_DB=0
REDIS_VECTOR_INDEX=docmost_pages
# AI Embedding Configuration (OpenAI-compatible)
AI_EMBEDDING_MODEL=text-embedding-3-small
AI_EMBEDDING_DIMENSIONS=1536
AI_EMBEDDING_BASE_URL=https://api.openai.com/v1/embeddings # Optional: for custom providers
# OpenAI API Key (or compatible provider key)
OPENAI_API_KEY=your_openai_api_key
```
## Redis Vector Search Implementation
This implementation follows the official [Redis Vector Search specifications](https://redis.io/docs/latest/develop/interact/search-and-query/query/vector-search/) and uses the [node-redis client](https://redis.io/docs/latest/develop/clients/nodejs/vecsearch/) for proper integration.
### Key Features:
- **HNSW Algorithm**: Uses Hierarchical Navigable Small World for fast vector indexing
- **COSINE Distance**: Semantic similarity using cosine distance metric
- **KNN Queries**: K-nearest neighbors search with `*=>[KNN k @embedding $vector AS distance]`
- **Hash Storage**: Vectors stored as Redis hash documents with binary embedding data
- **node-redis Client**: Official Redis client with full vector search support
### Vector Index Schema:
```typescript
{
page_id: SchemaFieldTypes.TEXT, // Sortable page identifier
workspace_id: SchemaFieldTypes.TEXT, // Sortable workspace filter
space_id: SchemaFieldTypes.TEXT, // Space filter
title: SchemaFieldTypes.TEXT, // Page title
embedding: { // Vector field
type: SchemaFieldTypes.VECTOR,
ALGORITHM: VectorAlgorithms.HNSW, // HNSW indexing
TYPE: 'FLOAT32', // 32-bit floats
DIM: 1536, // Embedding dimensions
DISTANCE_METRIC: 'COSINE', // Cosine similarity
},
indexed_at: SchemaFieldTypes.NUMERIC // Indexing timestamp
}
```
## API Endpoints
### Semantic Search
```http
POST /ai-search/semantic
Content-Type: application/json
{
"query": "machine learning algorithms",
"spaceId": "optional-space-id",
"limit": 20,
"similarity_threshold": 0.7
}
```
### Hybrid Search
```http
POST /ai-search/hybrid
Content-Type: application/json
{
"query": "neural networks",
"spaceId": "optional-space-id",
"limit": 20
}
```
### Reindex Pages
```http
POST /ai-search/reindex
Content-Type: application/json
{
"spaceId": "optional-space-id",
"pageIds": ["page-id-1", "page-id-2"]
}
```
## Usage Examples
### Basic Semantic Search
```typescript
import { AiSearchService } from './ai-search.service';
// Search for pages semantically using vector similarity
const results = await aiSearchService.semanticSearch(
'artificial intelligence concepts',
{ limit: 10, similarity_threshold: 0.8 },
{ userId: 'user-id', workspaceId: 'workspace-id' }
);
```
### Hybrid Search with Weighted Scoring
```typescript
// Combine semantic (70%) and text search (30%)
const results = await aiSearchService.hybridSearch(
'machine learning tutorial',
{ spaceId: 'space-id', limit: 15 },
{ userId: 'user-id', workspaceId: 'workspace-id' }
);
```
## Dependencies
The module uses the official **node-redis** package for Redis integration:
```json
{
"redis": "^4.7.0"
}
```
Install with pnpm:
```bash
pnpm install
```
## Performance Optimizations
### Vector Search Performance
- **HNSW Algorithm**: Provides O(log n) search complexity
- **COSINE Distance**: Efficient for normalized embeddings
- **Batch Operations**: Multi-command execution for bulk indexing
- **Connection Pooling**: Persistent Redis connections
### Memory Efficiency
- **Float32 Vectors**: Reduced memory usage vs Float64
- **TTL Expiration**: Automatic cleanup of old vectors (30 days)
- **Prefix-based Storage**: Organized key structure
## Vector Storage Format
Vectors are stored as Redis hash documents:
```
Key: vector:{workspaceId}:{pageId}
Fields:
page_id: "page-uuid"
workspace_id: "workspace-uuid"
space_id: "space-uuid"
title: "Page Title"
embedding: Buffer<Float32Array> // Binary vector data
indexed_at: "1234567890"
```
## Error Handling
The module includes comprehensive error handling:
- **Connection Resilience**: Automatic reconnection on Redis failures
- **Embedding Retries**: Exponential backoff for API failures
- **Vector Validation**: Dimension and format checking
- **Graceful Degradation**: Fallback to text search on vector errors
This implementation provides production-ready vector search capabilities that scale with your content while maintaining excellent search quality and performance.

View File

@ -0,0 +1,224 @@
# AI Search Setup Guide
This guide will help you set up the AI Search module with Redis vector database for Docmost.
## Prerequisites
1. **Redis with RediSearch**: You need Redis with the RediSearch module for vector operations
2. **OpenAI API Key**: For embedding generation (or alternative provider)
3. **Node.js Dependencies**: The required packages are already added to package.json
## Step 1: Install Redis with RediSearch
### Option A: Using Docker (Recommended)
```bash
# Using Redis Stack (includes RediSearch and vector capabilities)
docker run -d --name redis-stack \
-p 6379:6379 \
-v redis-data:/data \
redis/redis-stack-server:latest
# Or using Redis Enterprise with RediSearch
docker run -d --name redis-vector \
-p 6379:6379 \
-v redis-data:/data \
redislabs/redisearch:latest
```
### Option B: Manual Installation
1. Install Redis from source with RediSearch module
2. Or use Redis Cloud with RediSearch enabled
## Step 2: Configure Environment Variables
Add these variables to your `.env` file:
```env
# ===== Redis Vector Database Configuration =====
REDIS_VECTOR_HOST=localhost
REDIS_VECTOR_PORT=6379
REDIS_VECTOR_PASSWORD=your_redis_password_here
REDIS_VECTOR_DB=0
REDIS_VECTOR_INDEX=docmost_pages
# ===== AI Embedding Configuration (OpenAI-compatible) =====
AI_EMBEDDING_MODEL=text-embedding-3-small
AI_EMBEDDING_DIMENSIONS=1536
AI_EMBEDDING_BASE_URL=https://api.openai.com/v1/embeddings # Optional: for custom providers
# ===== OpenAI API Key (or compatible provider key) =====
OPENAI_API_KEY=your_openai_api_key_here
```
## Step 3: Custom OpenAI-Compatible Providers
You can use any provider that follows the OpenAI embeddings API specification by setting the `AI_EMBEDDING_BASE_URL`:
### Examples:
**Azure OpenAI:**
```env
AI_EMBEDDING_BASE_URL=https://your-resource.openai.azure.com/openai/deployments/your-deployment/embeddings?api-version=2023-05-15
OPENAI_API_KEY=your_azure_openai_key
```
**Ollama (local):**
```env
AI_EMBEDDING_BASE_URL=http://localhost:11434/v1/embeddings
AI_EMBEDDING_MODEL=nomic-embed-text
AI_EMBEDDING_DIMENSIONS=768
```
**Other compatible providers:**
- Together AI
- Anyscale
- OpenRouter
- Any provider implementing OpenAI's embeddings API
## Step 4: Install Dependencies
The required dependencies are already in package.json. Run:
```bash
pnpm install
```
## Step 5: Initialize the Vector Index
The vector index will be created automatically when the service starts. You can also manually trigger reindexing:
```bash
# Using the API endpoint
curl -X POST http://localhost:3000/ai-search/reindex \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-d '{"workspaceId": "your-workspace-id"}'
```
## Step 6: Test the Setup
### Test Semantic Search
```bash
curl -X POST http://localhost:3000/ai-search/semantic \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-d '{
"query": "machine learning algorithms",
"limit": 10,
"similarity_threshold": 0.7
}'
```
### Test Hybrid Search
```bash
curl -X POST http://localhost:3000/ai-search/hybrid \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-d '{
"query": "neural networks",
"limit": 10
}'
```
## Step 7: Monitor the Setup
### Check Redis Connection
```bash
redis-cli ping
# Should return PONG
```
### Check RediSearch Module
```bash
redis-cli MODULE LIST
# Should show RediSearch in the list
```
### Check Index Status
```bash
redis-cli FT.INFO docmost_pages
# Should show index information
```
## Troubleshooting
### Common Issues
1. **Redis Connection Error**
- Check if Redis is running: `docker ps` or `redis-cli ping`
- Verify connection details in .env file
- Check firewall/network settings
2. **RediSearch Module Not Found**
- Ensure you're using Redis Stack or Redis with RediSearch
- Check module is loaded: `redis-cli MODULE LIST`
3. **OpenAI API Errors**
- Verify API key is correct and has sufficient credits
- Check API usage limits and quotas
- Ensure model name is correct
4. **Embedding Generation Fails**
- Check text length (max 8000 characters by default)
- Verify network connectivity to embedding provider
- Check API rate limits
5. **Search Returns No Results**
- Ensure pages are indexed: check logs for indexing errors
- Verify similarity threshold (try lowering it)
- Check user permissions for searched content
### Debug Logging
Enable debug logging by setting:
```env
LOG_LEVEL=debug
```
### Performance Tuning
1. **Batch Size**: Adjust based on your API rate limits
```env
AI_SEARCH_BATCH_SIZE=50 # Lower for rate-limited APIs
```
2. **Similarity Threshold**: Balance precision vs recall
```env
AI_SEARCH_SIMILARITY_THRESHOLD=0.6 # Lower = more results
```
3. **Redis Memory**: Monitor memory usage as index grows
```bash
redis-cli INFO memory
```
## Production Deployment
### Redis Configuration
- Use Redis Cluster for high availability
- Set up proper backup and persistence
- Monitor memory usage and performance
- Configure appropriate TTL for vectors
### Security
- Use strong Redis passwords
- Enable TLS for Redis connections
- Secure API keys in environment variables
- Implement proper rate limiting
### Monitoring
- Set up alerts for Redis health
- Monitor embedding API usage and costs
- Track search performance metrics
- Log search queries for analysis
## Next Steps
1. **Auto-indexing**: Pages are automatically indexed on create/update
2. **Client Integration**: Add AI search to your frontend
3. **Custom Scoring**: Implement custom ranking algorithms
4. **Analytics**: Track search usage and effectiveness
For more detailed information, see the main README.md file.

View File

@ -0,0 +1,38 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AiSearchController } from './ai-search.controller';
import { AiSearchService } from './services/ai-search.service';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
describe('AiSearchController', () => {
let controller: AiSearchController;
let service: AiSearchService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AiSearchController],
providers: [
{
provide: AiSearchService,
useValue: {
semanticSearch: jest.fn(),
hybridSearch: jest.fn(),
reindexPages: jest.fn(),
},
},
{
provide: SpaceAbilityFactory,
useValue: {
createForUser: jest.fn(),
},
},
],
}).compile();
controller = module.get<AiSearchController>(AiSearchController);
service = module.get<AiSearchService>(AiSearchService);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -0,0 +1,123 @@
import {
Controller,
Post,
Body,
UseGuards,
HttpCode,
HttpStatus,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { User } from '@docmost/db/types/entity.types';
import { Workspace } from '@docmost/db/types/entity.types';
import { AiSearchService } from './services/ai-search.service';
import { SemanticSearchDto, SemanticSearchShareDto } from './dto/semantic-search.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { SpaceCaslAction, SpaceCaslSubject } from '../casl/interfaces/space-ability.type';
import { Public } from '../../common/decorators/public.decorator';
@UseGuards(JwtAuthGuard)
@Controller('ai-search')
export class AiSearchController {
constructor(
private readonly aiSearchService: AiSearchService,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
@HttpCode(HttpStatus.OK)
@Post('semantic')
async semanticSearch(
@Body() searchDto: SemanticSearchDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
delete searchDto.shareId;
if (searchDto.spaceId) {
const ability = await this.spaceAbility.createForUser(
user,
searchDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
}
return this.aiSearchService.semanticSearch(searchDto.query, searchDto, {
userId: user.id,
workspaceId: workspace.id,
});
}
@HttpCode(HttpStatus.OK)
@Post('hybrid')
async hybridSearch(
@Body() searchDto: SemanticSearchDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
delete searchDto.shareId;
if (searchDto.spaceId) {
const ability = await this.spaceAbility.createForUser(
user,
searchDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
}
return this.aiSearchService.hybridSearch(searchDto.query, searchDto, {
userId: user.id,
workspaceId: workspace.id,
});
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('semantic-share')
async semanticSearchShare(
@Body() searchDto: SemanticSearchShareDto,
@AuthWorkspace() workspace: Workspace,
) {
delete searchDto.spaceId;
if (!searchDto.shareId) {
throw new BadRequestException('shareId is required');
}
return this.aiSearchService.semanticSearch(searchDto.query, searchDto, {
workspaceId: workspace.id,
});
}
@HttpCode(HttpStatus.OK)
@Post('reindex')
async reindexPages(
@Body() body: { spaceId?: string; pageIds?: string[] },
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
if (body.spaceId) {
const ability = await this.spaceAbility.createForUser(
user,
body.spaceId,
);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
}
return this.aiSearchService.reindexPages({
workspaceId: workspace.id,
spaceId: body.spaceId,
pageIds: body.pageIds,
});
}
}

View File

@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AiSearchController } from './ai-search.controller';
import { AiSearchService } from './services/ai-search.service';
import { VectorService } from './services/vector.service';
import { EmbeddingService } from './services/embedding.service';
import { RedisVectorService } from './services/redis-vector.service';
import { PageUpdateListener } from './listeners/page-update.listener';
@Module({
imports: [ConfigModule],
controllers: [AiSearchController],
providers: [
AiSearchService,
VectorService,
EmbeddingService,
RedisVectorService,
PageUpdateListener,
],
exports: [AiSearchService, VectorService, EmbeddingService, RedisVectorService],
})
export class AiSearchModule {}

View File

@ -0,0 +1,50 @@
export const AI_SEARCH_CONFIG = {
// Default similarity thresholds
DEFAULT_SIMILARITY_THRESHOLD: 0.7,
HIGH_SIMILARITY_THRESHOLD: 0.85,
LOW_SIMILARITY_THRESHOLD: 0.6,
// Search limits
MAX_SEARCH_LIMIT: 100,
DEFAULT_SEARCH_LIMIT: 20,
MIN_SEARCH_LIMIT: 1,
// Embedding configuration
DEFAULT_EMBEDDING_DIMENSIONS: 1536,
MAX_TEXT_LENGTH: 8000,
// Indexing configuration
DEFAULT_BATCH_SIZE: 100,
INDEX_TTL_DAYS: 30,
// Hybrid search weights
SEMANTIC_WEIGHT: 0.7,
TEXT_WEIGHT: 0.3,
// Redis configuration
REDIS_KEY_PREFIX: 'docmost:ai-search',
VECTOR_KEY_PREFIX: 'vector',
METADATA_KEY_PREFIX: 'metadata',
// Retry configuration
MAX_RETRIES: 3,
RETRY_DELAY_MS: 1000,
// OpenAI configuration
OPENAI_BATCH_SIZE: 100,
} as const;
export const EMBEDDING_MODELS = {
OPENAI: {
'text-embedding-3-small': 1536,
'text-embedding-3-large': 3072,
'text-embedding-ada-002': 1536,
},
} as const;
export const SEARCH_EVENTS = {
PAGE_CREATED: 'page.created',
PAGE_UPDATED: 'page.updated',
PAGE_DELETED: 'page.deleted',
BULK_REINDEX: 'ai-search.bulk-reindex',
} as const;

View File

@ -0,0 +1,103 @@
import {
IsNotEmpty,
IsString,
IsOptional,
IsNumber,
Min,
Max,
IsArray,
IsBoolean,
} from 'class-validator';
export class SemanticSearchDto {
@IsNotEmpty()
@IsString()
query: string;
@IsOptional()
@IsString()
spaceId?: string;
@IsOptional()
@IsString()
shareId?: string;
@IsOptional()
@IsString()
creatorId?: string;
@IsOptional()
@IsNumber()
@Min(1)
@Max(100)
limit?: number = 20;
@IsOptional()
@IsNumber()
@Min(0)
offset?: number = 0;
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
similarity_threshold?: number = 0.7;
@IsOptional()
@IsBoolean()
include_highlights?: boolean = true;
@IsOptional()
@IsArray()
@IsString({ each: true })
filters?: string[];
}
export class SemanticSearchShareDto extends SemanticSearchDto {
@IsNotEmpty()
@IsString()
shareId: string;
@IsOptional()
@IsString()
spaceId?: string;
}
export class SemanticSearchResponseDto {
id: string;
title: string;
icon: string;
parentPageId: string;
creatorId: string;
similarity_score: number;
semantic_rank: number;
highlight: string;
createdAt: Date;
updatedAt: Date;
space?: {
id: string;
name: string;
slug: string;
};
}
export class HybridSearchResponseDto extends SemanticSearchResponseDto {
text_rank?: number;
combined_score: number;
search_type: 'semantic' | 'text' | 'hybrid';
}
export class ReindexDto {
@IsOptional()
@IsString()
spaceId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
pageIds?: string[];
@IsNotEmpty()
@IsString()
workspaceId: string;
}

View File

@ -0,0 +1,88 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { AiSearchService } from '../services/ai-search.service';
import { EmbeddingService } from '../services/embedding.service';
import { RedisVectorService } from '../services/redis-vector.service';
import { Page } from '@docmost/db/types/entity.types';
import { UpdatedPageEvent } from '../../../collaboration/listeners/history.listener';
export interface PageUpdateEvent {
pageId: string;
workspaceId: string;
spaceId: string;
title?: string;
textContent?: string;
operation: 'create' | 'update' | 'delete';
}
@Injectable()
export class PageUpdateListener {
private readonly logger = new Logger(PageUpdateListener.name);
constructor(
private readonly aiSearchService: AiSearchService,
private readonly embeddingService: EmbeddingService,
private readonly redisVectorService: RedisVectorService,
) {}
@OnEvent('page.created')
async handlePageCreated(event: Page) {
await this.indexPage(event);
}
@OnEvent('collab.page.updated')
async handlePageUpdated(event: UpdatedPageEvent) {
await this.indexPage(event.page);
}
@OnEvent('page.deleted')
async handlePageDeleted(event: Page) {
try {
await this.redisVectorService.deletePage(event.id, event.workspaceId);
this.logger.debug(`Removed page ${event.id} from vector index`);
} catch (error) {
this.logger.error(
`Failed to remove page ${event.id} from vector index:`,
error,
);
}
}
private async indexPage(event: Page) {
try {
const content = `${event.title || ''} ${event.textContent || ''}`.trim();
if (!content) {
this.logger.debug(
`Skipping indexing for page ${event.id} - no content`,
);
return;
}
if (!this.embeddingService.isConfigured()) {
this.logger.debug(
'Embedding service not configured, skipping indexing',
);
return;
}
const embedding = await this.embeddingService.generateEmbedding(content);
console.log('embedding', embedding);
await this.redisVectorService.indexPage({
pageId: event.id,
embedding,
metadata: {
title: event.title,
workspaceId: event.workspaceId,
spaceId: event.spaceId,
},
});
this.logger.debug(`Indexed page ${event.id} for AI search`);
} catch (error) {
this.logger.error(`Failed to index page ${event.id}:`, error);
}
}
}

View File

@ -0,0 +1,438 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { sql } from 'kysely';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { VectorService } from './vector.service';
import { EmbeddingService } from './embedding.service';
import { RedisVectorService } from './redis-vector.service';
import {
SemanticSearchDto,
SemanticSearchResponseDto,
HybridSearchResponseDto,
ReindexDto,
} from '../dto/semantic-search.dto';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const tsquery = require('pg-tsquery')();
@Injectable()
export class AiSearchService {
private readonly logger = new Logger(AiSearchService.name);
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly pageRepo: PageRepo,
private readonly shareRepo: ShareRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly vectorService: VectorService,
private readonly embeddingService: EmbeddingService,
private readonly redisVectorService: RedisVectorService,
) {}
async semanticSearch(
query: string,
searchParams: SemanticSearchDto,
opts: {
userId?: string;
workspaceId: string;
},
): Promise<SemanticSearchResponseDto[]> {
if (query.length < 1) {
return [];
}
try {
// Generate embedding for the query
const queryEmbedding =
await this.embeddingService.generateEmbedding(query);
// Get page IDs that user has access to
const accessiblePageIds = await this.getAccessiblePageIds(
searchParams,
opts,
);
console.log('accessible', accessiblePageIds);
if (accessiblePageIds.length === 0) {
return [];
}
// Perform vector search
const vectorResults = await this.redisVectorService.searchSimilar(
queryEmbedding,
{
limit: searchParams.limit || 20,
offset: searchParams.offset || 0,
threshold: searchParams.similarity_threshold || 0.7,
filters: {
workspace_id: opts.workspaceId,
page_ids: accessiblePageIds,
},
},
);
console.log('vectorResults', vectorResults);
if (vectorResults.length === 0) {
return [];
}
// Get page details from database
const pageIds = vectorResults.map((result) => result.pageId);
const pages = await this.getPageDetails(pageIds, searchParams);
// Combine vector results with page details
const results = this.combineVectorResultsWithPages(
vectorResults,
pages,
query,
searchParams.include_highlights,
);
return results;
} catch (error) {
this.logger.error(`Semantic search failed: ${error?.['message']}`, error);
throw error;
}
}
async hybridSearch(
query: string,
searchParams: SemanticSearchDto,
opts: {
userId?: string;
workspaceId: string;
},
): Promise<HybridSearchResponseDto[]> {
if (query.length < 1) {
return [];
}
try {
// Run both semantic and text search in parallel
const [semanticResults, textResults] = await Promise.all([
this.semanticSearch(query, searchParams, opts),
this.performTextSearch(query, searchParams, opts),
]);
// Combine and rank results
const hybridResults = this.combineHybridResults(
semanticResults,
textResults,
query,
);
return hybridResults;
} catch (error) {
this.logger.error(`Hybrid search failed: ${error?.['message']}`, error);
throw error;
}
}
async reindexPages(
params: ReindexDto,
): Promise<{ indexed: number; errors?: string[] }> {
try {
let query = this.db
.selectFrom('pages')
.select(['id', 'title', 'textContent'])
.where('workspaceId', '=', params.workspaceId)
.where('deletedAt', 'is', null);
if (params.spaceId) {
query = query.where('spaceId', '=', params.spaceId);
}
if (params.pageIds && params.pageIds.length > 0) {
query = query.where('id', 'in', params.pageIds);
}
const pages = await query.execute();
const results = await Promise.allSettled(
pages.map(async (page) => {
const content =
`${page.title || ''} ${page.textContent || ''}`.trim();
if (!content) return null;
const embedding =
await this.embeddingService.generateEmbedding(content);
await this.redisVectorService.indexPage({
pageId: page.id,
embedding,
metadata: {
title: page.title,
workspaceId: params.workspaceId,
},
});
return page.id;
}),
);
const indexed = results.filter(
(r) => r.status === 'fulfilled' && r.value,
).length;
const errors = results
.filter((r) => r.status === 'rejected')
.map((r) => r.reason.message);
this.logger.log(
`Reindexed ${indexed} pages for workspace ${params.workspaceId}`,
);
return { indexed, errors: errors.length > 0 ? errors : undefined };
} catch (error) {
this.logger.error(`Reindexing failed: ${error?.['message']}`, error);
throw error;
}
}
private async getAccessiblePageIds(
searchParams: SemanticSearchDto,
opts: { userId?: string; workspaceId: string },
): Promise<string[]> {
if (searchParams.shareId) {
// Handle shared pages
const share = await this.shareRepo.findById(searchParams.shareId);
if (!share || share.workspaceId !== opts.workspaceId) {
return [];
}
const pageIdsToSearch = [];
if (share.includeSubPages) {
const pageList = await this.pageRepo.getPageAndDescendants(
share.pageId,
{ includeContent: false },
);
pageIdsToSearch.push(...pageList.map((page) => page.id));
} else {
pageIdsToSearch.push(share.pageId);
}
return pageIdsToSearch;
}
if (searchParams.spaceId) {
// Get pages from specific space
const pages = await this.db
.selectFrom('pages')
.select('id')
.where('spaceId', '=', searchParams.spaceId)
.where('workspaceId', '=', opts.workspaceId)
.where('deletedAt', 'is', null)
.execute();
return pages.map((p) => p.id);
}
if (opts.userId) {
// Get pages from user's accessible spaces
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(
opts.userId,
);
if (userSpaceIds.length === 0) {
return [];
}
const pages = await this.db
.selectFrom('pages')
.select('id')
.where('spaceId', 'in', userSpaceIds)
.where('workspaceId', '=', opts.workspaceId)
.where('deletedAt', 'is', null)
.execute();
return pages.map((p) => p.id);
}
return [];
}
private async getPageDetails(
pageIds: string[],
searchParams: SemanticSearchDto,
) {
let query = this.db
.selectFrom('pages')
.select([
'id',
'slugId',
'title',
'icon',
'parentPageId',
'creatorId',
'createdAt',
'updatedAt',
'textContent',
]);
if (!searchParams.shareId) {
query = query.select((eb) => this.pageRepo.withSpace(eb));
}
const pages = await query
.where('id', 'in', pageIds)
.where('deletedAt', 'is', null)
.execute();
return pages;
}
private combineVectorResultsWithPages(
vectorResults: any[],
pages: any[],
query: string,
includeHighlights: boolean = true,
): SemanticSearchResponseDto[] {
const pageMap = new Map(pages.map((p) => [p.id, p]));
return vectorResults
.map((result, index) => {
const page = pageMap.get(result.pageId);
if (!page) return null;
let highlight = '';
if (includeHighlights && page.textContent) {
highlight = this.generateHighlight(page.textContent, query);
}
return {
id: page.id,
title: page.title,
icon: page.icon,
parentPageId: page.parentPageId,
creatorId: page.creatorId,
similarity_score: result.score,
semantic_rank: index + 1,
highlight,
createdAt: page.createdAt,
updatedAt: page.updatedAt,
space: page.space
? {
id: page.space.id,
name: page.space.name,
slug: page.space.slug,
}
: undefined,
};
})
.filter(Boolean);
}
private async performTextSearch(
query: string,
searchParams: SemanticSearchDto,
opts: { userId?: string; workspaceId: string },
) {
const searchQuery = tsquery(query.trim() + '*');
const accessiblePageIds = await this.getAccessiblePageIds(
searchParams,
opts,
);
if (accessiblePageIds.length === 0) {
return [];
}
const results = await this.db
.selectFrom('pages')
.select([
'id',
'slugId',
'title',
'icon',
'parentPageId',
'creatorId',
'createdAt',
'updatedAt',
sql<number>`ts_rank(tsv, to_tsquery(${searchQuery}))`.as('text_rank'),
sql<string>`ts_headline('english', text_content, to_tsquery(${searchQuery}),'MinWords=9, MaxWords=10, MaxFragments=3')`.as(
'highlight',
),
])
.where('tsv', '@@', sql<string>`to_tsquery(${searchQuery})`)
.where('id', 'in', accessiblePageIds)
.orderBy('text_rank', 'desc')
.limit(searchParams.limit || 20)
.execute();
return results.map((result) => ({
...result,
text_rank: result.text_rank,
search_type: 'text' as const,
}));
}
private combineHybridResults(
semanticResults: SemanticSearchResponseDto[],
textResults: any[],
query: string,
): HybridSearchResponseDto[] {
const combinedMap = new Map<string, HybridSearchResponseDto>();
// Add semantic results
semanticResults.forEach((result, index) => {
combinedMap.set(result.id, {
...result,
text_rank: undefined,
combined_score: result.similarity_score * 0.7, // Weight semantic results
search_type: 'semantic',
});
});
// Add text results or combine with existing
textResults.forEach((result, index) => {
const existing = combinedMap.get(result.id);
if (existing) {
// Combine scores
existing.combined_score =
existing.similarity_score * 0.7 + result.text_rank * 0.3;
existing.text_rank = result.text_rank;
existing.search_type = 'hybrid';
} else {
combinedMap.set(result.id, {
id: result.id,
title: result.title,
icon: result.icon,
parentPageId: result.parentPageId,
creatorId: result.creatorId,
similarity_score: 0,
semantic_rank: 0,
text_rank: result.text_rank,
combined_score: result.text_rank * 0.3,
highlight: result.highlight,
createdAt: result.createdAt,
updatedAt: result.updatedAt,
search_type: 'text',
});
}
});
// Sort by combined score
return Array.from(combinedMap.values())
.sort((a, b) => b.combined_score - a.combined_score)
.slice(0, 20);
}
private generateHighlight(content: string, query: string): string {
if (!content) return '';
const words = query.toLowerCase().split(/\s+/);
const sentences = content.split(/[.!?]+/);
for (const sentence of sentences) {
const lowerSentence = sentence.toLowerCase();
if (words.some((word) => lowerSentence.includes(word))) {
return sentence.trim().substring(0, 200) + '...';
}
}
return content.substring(0, 200) + '...';
}
}

View File

@ -0,0 +1,185 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import OpenAI from 'openai';
export interface EmbeddingConfig {
model: string;
apiKey?: string;
baseUrl?: string;
dimensions: number;
}
export interface EmbeddingResult {
embedding: number[];
tokens: number;
model: string;
}
@Injectable()
export class EmbeddingService {
private readonly logger = new Logger(EmbeddingService.name);
private readonly config: EmbeddingConfig;
private readonly openai: OpenAI;
constructor(private readonly configService: ConfigService) {
this.config = {
model: this.configService.get<string>(
'AI_EMBEDDING_MODEL',
'text-embedding-3-small',
),
apiKey: this.configService.get<string>('OPENAI_API_KEY'),
baseUrl: 'https://api.openai.com/v1/',
dimensions: Number(
this.configService.get<string>('AI_EMBEDDING_DIMENSIONS', '1536'),
),
};
if (!this.config.apiKey) {
this.logger.warn(
'OpenAI API key not configured. AI search will not work.',
);
}
// Initialize OpenAI client with optional custom base URL
this.openai = new OpenAI({
apiKey: this.config.apiKey || 'dummy-key',
baseURL: this.config.baseUrl,
});
}
/**
* Generate embedding for a single text
*/
async generateEmbedding(text: string): Promise<number[]> {
if (!text || text.trim().length === 0) {
throw new Error('Text cannot be empty');
}
const cleanText = this.preprocessText(text);
console.log('generate clean text', cleanText);
try {
const result = await this.generateEmbeddingWithOpenAI(cleanText);
console.log('embedding results', result);
return result.embedding;
} catch (error) {
this.logger.error(`Embedding generation failed:`, error);
}
}
/**
* Generate embeddings for multiple texts in batch
*/
async generateEmbeddings(texts: string[]): Promise<number[][]> {
if (!texts || texts.length === 0) {
return [];
}
const cleanTexts = texts.map((text) => this.preprocessText(text));
const batchSize = this.getBatchSize();
const results: number[][] = [];
for (let i = 0; i < cleanTexts.length; i += batchSize) {
const batch = cleanTexts.slice(i, i + batchSize);
try {
const batchResults = await this.generateBatchEmbeddings(batch);
results.push(...batchResults);
} catch (error) {
this.logger.error(
`Batch embedding generation failed for batch ${i}:`,
error,
);
throw error;
}
}
return results;
}
/**
* Generate embedding using OpenAI API
*/
private async generateEmbeddingWithOpenAI(
text: string,
): Promise<EmbeddingResult> {
const response = await this.openai.embeddings.create({
model: this.config.model,
input: text,
dimensions: this.config.dimensions,
});
if (!response.data || response.data.length === 0) {
throw new Error('Invalid response from OpenAI API');
}
return {
embedding: response.data[0].embedding,
tokens: response.usage?.total_tokens || 0,
model: this.config.model,
};
}
/**
* Generate embeddings for multiple texts
*/
private async generateBatchEmbeddings(texts: string[]): Promise<number[][]> {
const response = await this.openai.embeddings.create({
model: this.config.model,
input: texts,
dimensions: this.config.dimensions,
});
if (!response.data || !Array.isArray(response.data)) {
throw new Error('Invalid response from OpenAI API');
}
return response.data.map((item) => item.embedding);
}
/**
* Preprocess text before embedding generation
*/
private preprocessText(text: string): string {
if (!text) return '';
// Remove excessive whitespace
let processed = text.replace(/\s+/g, ' ').trim();
// Truncate if too long (most models have token limits)
const maxLength = 8000; // Conservative limit
if (processed.length > maxLength) {
processed = processed.substring(0, maxLength);
}
return processed;
}
/**
* Get batch size for OpenAI API
*/
private getBatchSize(): number {
return 100; // OpenAI supports up to 2048 inputs
}
/**
* Sleep utility for retries
*/
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Check if embedding service is configured
*/
isConfigured(): boolean {
return !!this.config.apiKey;
}
/**
* Get embedding configuration
*/
getConfig(): EmbeddingConfig {
return { ...this.config };
}
}

View File

@ -0,0 +1,393 @@
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import {
VectorSearchOptions,
VectorSearchResult,
VectorService,
} from './vector.service';
import {
createClient,
RedisClientType,
SCHEMA_FIELD_TYPE,
SCHEMA_VECTOR_FIELD_ALGORITHM,
} from 'redis';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
export interface IndexPageData {
pageId: string;
embedding: number[];
metadata: {
title?: string;
workspaceId: string;
spaceId?: string;
[key: string]: any;
};
}
export interface RedisVectorConfig {
host: string;
port: number;
password?: string;
db?: number;
indexName: string;
vectorDimension: number;
}
@Injectable()
export class RedisVectorService implements OnModuleDestroy {
private readonly logger = new Logger(RedisVectorService.name);
private readonly redis: RedisClientType;
private readonly config: RedisVectorConfig;
private isIndexCreated = false;
constructor(
private readonly environmentService: EnvironmentService,
private readonly vectorService: VectorService,
) {
//@ts-ignore
this.config = {
indexName: 'docmost_pages_index',
vectorDimension: 1536, //AI_EMBEDDING_DIMENSIONS
};
this.redis = createClient({
url: this.environmentService.getRedisUrl(),
});
this.redis.on('error', (err) => {
this.logger.error('Redis Client Error:', err);
});
this.initializeConnection();
}
async searchSimilar(
queryEmbedding: number[],
options: VectorSearchOptions,
): Promise<VectorSearchResult[]> {
try {
await this.ensureIndexExists();
const { limit = 20, offset = 0, threshold = 0.7, filters } = options;
// Build query following Redis specs
let query = `*=>[KNN ${limit + offset} @embedding $vector AS score]`;
// Apply filters if provided
if (filters && Object.keys(filters).length > 0) {
const filterClauses = Object.entries(filters).map(([key, value]) => {
if (Array.isArray(value)) {
return `@${key}:{${value.join('|')}}`;
}
return `@${key}:${value}`;
});
query = `(${filterClauses.join(' ')})=>[KNN ${limit + offset} @embedding $vector AS score]`;
}
// Execute search using proper node-redis syntax
const searchOptions = {
PARAMS: {
vector: Buffer.from(new Float32Array(queryEmbedding).buffer),
},
SORTBY: {
BY: '@score' as `@${string}`,
DIRECTION: 'ASC' as 'ASC',
},
LIMIT: {
from: offset,
size: limit,
},
RETURN: ['page_id', 'workspace_id', 'space_id', 'title', 'score'],
DIALECT: 2,
};
console.log(searchOptions);
//is not assignable to parameter of type FtSearchOptions
// Types of property SORTBY are incompatible.
// Type { BY: string; DIRECTION: string; } is not assignable to type
// RedisArgument | { BY: `@${string}` | `$.${string}`; DIRECTION?: 'DESC' | 'ASC'; }
const searchResult = await this.redis.ft.search(
this.config.indexName,
query,
searchOptions,
);
const results = this.parseSearchResults(searchResult, threshold);
this.logger.debug(`Vector search found ${results.length} results`);
return results;
} catch (error) {
this.logger.error('Vector search failed:', error);
throw new Error(`Vector search failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
async indexPage(data: IndexPageData): Promise<void> {
try {
await this.ensureIndexExists();
const key = this.vectorService.createVectorKey(
data.pageId,
data.metadata.workspaceId,
);
// Store vector and metadata using proper node-redis hash operations
await this.redis.hSet(key, {
page_id: data.pageId,
workspace_id: data.metadata.workspaceId,
space_id: data.metadata.spaceId || '',
title: data.metadata.title || '',
embedding: Buffer.from(new Float32Array(data.embedding).buffer),
indexed_at: Date.now().toString(),
});
// Set TTL for the key
await this.redis.expire(key, 86400 * 30); // 30 days TTL
this.logger.debug(
`Indexed page ${data.pageId} in workspace ${data.metadata.workspaceId}`,
);
} catch (error) {
this.logger.error(
`Failed to index page ${data.pageId}: ${error?.['message']}`,
error,
);
throw error;
}
}
async deletePage(pageId: string, workspaceId: string): Promise<void> {
try {
const key = this.vectorService.createVectorKey(pageId, workspaceId);
await this.redis.del(key);
this.logger.debug(`Deleted page ${pageId} from vector index`);
} catch (error) {
this.logger.error(
`Failed to delete page ${pageId}: ${error?.['message']}`,
error,
);
throw error;
}
}
async batchIndexPages(
pages: IndexPageData[],
): Promise<{ indexed: number; errors: string[] }> {
const errors: string[] = [];
let indexed = 0;
try {
await this.ensureIndexExists();
// Process in batches to avoid memory issues
const batchSize = 100;
for (let i = 0; i < pages.length; i += batchSize) {
const batch = pages.slice(i, i + batchSize);
// Use node-redis multi for batch operations
const multi = this.redis.multi();
for (const page of batch) {
try {
const key = this.vectorService.createVectorKey(
page.pageId,
page.metadata.workspaceId,
);
multi.hSet(key, {
page_id: page.pageId,
workspace_id: page.metadata.workspaceId,
space_id: page.metadata.spaceId || '',
title: page.metadata.title || '',
embedding: Buffer.from(new Float32Array(page.embedding).buffer),
indexed_at: Date.now().toString(),
});
multi.expire(key, 86400 * 30);
} catch (error) {
errors.push(`Page ${page.pageId}: ${error?.['message']}`);
}
}
const results = await multi.exec();
// Count successful operations
const batchIndexed =
//@ts-ignore
results?.filter((result) => !result.error).length || 0;
indexed += Math.floor(batchIndexed / 2); // Each page has 2 operations (hSet + expire)
}
this.logger.log(
`Batch indexed ${indexed} pages with ${errors.length} errors`,
);
return { indexed, errors };
} catch (error) {
this.logger.error(`Batch indexing failed: ${error?.['message']}`, error);
throw error;
}
}
private async initializeConnection(): Promise<void> {
try {
await this.redis.connect();
console.log('create');
await this.createIndex();
this.isIndexCreated = true;
this.logger.log('Redis vector database connected and index initialized');
} catch (error) {
this.logger.error(
`Failed to initialize vector index: ${error?.['message']}`,
error,
);
console.error(error);
}
}
private async ensureIndexExists(): Promise<void> {
console.log('creating index 1111');
if (!this.isIndexCreated) {
console.log('creating index');
await this.createIndex();
this.isIndexCreated = true;
}
}
private async createIndex(): Promise<void> {
try {
// Check if index already exists using proper node-redis syntax
await this.redis.ft.info(this.config.indexName);
this.logger.debug(`Vector index ${this.config.indexName} already exists`);
return;
} catch (error) {
// Index doesn't exist, create it
}
try {
// Create index using proper node-redis schema definition
await this.redis.ft.create(
this.config.indexName,
{
page_id: {
type: SCHEMA_FIELD_TYPE.TEXT,
SORTABLE: true,
},
workspace_id: {
type: SCHEMA_FIELD_TYPE.TEXT,
SORTABLE: true,
},
space_id: {
type: SCHEMA_FIELD_TYPE.TEXT,
},
title: {
type: SCHEMA_FIELD_TYPE.TEXT,
},
embedding: {
type: SCHEMA_FIELD_TYPE.VECTOR,
ALGORITHM: SCHEMA_VECTOR_FIELD_ALGORITHM.HNSW,
TYPE: 'FLOAT32',
DIM: this.config.vectorDimension,
DISTANCE_METRIC: 'COSINE',
},
indexed_at: {
type: SCHEMA_FIELD_TYPE.NUMERIC,
SORTABLE: true,
},
},
{
ON: 'HASH',
PREFIX: 'vector:',
},
);
this.logger.log(`Created vector index ${this.config.indexName}`);
} catch (error) {
if (error?.['message']?.includes('Index already exists')) {
this.logger.debug('Vector index already exists');
} else {
throw error;
}
}
}
private parseSearchResults(
results: any,
threshold: number,
): VectorSearchResult[] {
if (!results?.documents || results.documents.length === 0) {
return [];
}
const parsed: VectorSearchResult[] = [];
for (const doc of results.documents) {
const distance = parseFloat(doc.value?.distance || '1');
const similarity = 1 - distance; // Convert distance to similarity
if (similarity >= threshold) {
parsed.push({
pageId: doc.value?.page_id || doc.id.split(':')[1],
score: similarity,
metadata: {
workspaceId: doc.value?.workspace_id,
spaceId: doc.value?.space_id,
title: doc.value?.title,
distance,
},
});
}
}
return parsed;
}
async getIndexStats(): Promise<{
totalDocs: number;
indexSize: string;
vectorCount: number;
}> {
try {
const info = await this.redis.ft.info(this.config.indexName);
return {
//@ts-ignore
totalDocs: info.numDocs || 0,
//@ts-ignore
indexSize: info.indexSize || '0',
//@ts-ignore
vectorCount: info.numDocs || 0,
};
} catch (error) {
this.logger.error(`Failed to get index stats: ${error?.['message']}`);
return { totalDocs: 0, indexSize: '0', vectorCount: 0 };
}
}
async deleteIndex(): Promise<void> {
try {
await this.redis.ft.dropIndex(this.config.indexName);
this.isIndexCreated = false;
this.logger.log(`Deleted vector index ${this.config.indexName}`);
} catch (error) {
this.logger.error(`Failed to delete index: ${error?.['message']}`);
throw error;
}
}
async disconnect(): Promise<void> {
try {
await this.redis.quit();
this.logger.log('Redis vector database disconnected');
} catch (error) {
this.logger.error(
`Failed to disconnect from Redis: ${error?.['message']}`,
);
}
}
async onModuleDestroy() {
await this.disconnect();
}
}

View File

@ -0,0 +1,216 @@
import { Injectable, Logger } from '@nestjs/common';
export interface VectorSearchResult {
pageId: string;
score: number;
metadata?: Record<string, any>;
}
export interface VectorSearchOptions {
limit?: number;
offset?: number;
threshold?: number;
filters?: Record<string, any>;
}
@Injectable()
export class VectorService {
private readonly logger = new Logger(VectorService.name);
/**
* Calculate cosine similarity between two vectors
*/
cosineSimilarity(vectorA: number[], vectorB: number[]): number {
if (vectorA.length !== vectorB.length) {
throw new Error('Vectors must have the same length');
}
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < vectorA.length; i++) {
dotProduct += vectorA[i] * vectorB[i];
normA += vectorA[i] * vectorA[i];
normB += vectorB[i] * vectorB[i];
}
const magnitude = Math.sqrt(normA) * Math.sqrt(normB);
if (magnitude === 0) {
return 0;
}
return dotProduct / magnitude;
}
/**
* Calculate Euclidean distance between two vectors
*/
euclideanDistance(vectorA: number[], vectorB: number[]): number {
if (vectorA.length !== vectorB.length) {
throw new Error('Vectors must have the same length');
}
let sum = 0;
for (let i = 0; i < vectorA.length; i++) {
const diff = vectorA[i] - vectorB[i];
sum += diff * diff;
}
return Math.sqrt(sum);
}
/**
* Calculate dot product similarity
*/
dotProductSimilarity(vectorA: number[], vectorB: number[]): number {
if (vectorA.length !== vectorB.length) {
throw new Error('Vectors must have the same length');
}
let dotProduct = 0;
for (let i = 0; i < vectorA.length; i++) {
dotProduct += vectorA[i] * vectorB[i];
}
return dotProduct;
}
/**
* Normalize a vector to unit length
*/
normalizeVector(vector: number[]): number[] {
const magnitude = Math.sqrt(
vector.reduce((sum, val) => sum + val * val, 0),
);
if (magnitude === 0) {
return vector;
}
return vector.map((val) => val / magnitude);
}
/**
* Convert vector to string format for Redis storage
*/
vectorToString(vector: number[]): string {
return vector.join(',');
}
/**
* Parse vector from string format
*/
stringToVector(vectorString: string): number[] {
return vectorString.split(',').map((val) => parseFloat(val));
}
/**
* Validate vector format and dimensions
*/
validateVector(vector: number[], expectedDimensions?: number): boolean {
if (!Array.isArray(vector)) {
return false;
}
if (vector.length === 0) {
return false;
}
if (expectedDimensions && vector.length !== expectedDimensions) {
return false;
}
return vector.every((val) => typeof val === 'number' && !isNaN(val));
}
/**
* Calculate similarity score with configurable method
*/
calculateSimilarity(
vectorA: number[],
vectorB: number[],
method: 'cosine' | 'euclidean' | 'dot' = 'cosine',
): number {
switch (method) {
case 'cosine':
return this.cosineSimilarity(vectorA, vectorB);
case 'euclidean': // Convert distance to similarity (0-1 scale)
{
const distance = this.euclideanDistance(vectorA, vectorB);
return 1 / (1 + distance);
}
case 'dot':
return this.dotProductSimilarity(vectorA, vectorB);
default:
throw new Error(`Unsupported similarity method: ${method}`);
}
}
/**
* Filter results by similarity threshold
*/
filterByThreshold(
results: VectorSearchResult[],
threshold: number,
): VectorSearchResult[] {
return results.filter((result) => result.score >= threshold);
}
/**
* Sort results by similarity score (descending)
*/
sortByScore(results: VectorSearchResult[]): VectorSearchResult[] {
return results.sort((a, b) => b.score - a.score);
}
/**
* Apply pagination to results
*/
paginateResults(
results: VectorSearchResult[],
offset: number = 0,
limit: number = 20,
): VectorSearchResult[] {
return results.slice(offset, offset + limit);
}
/**
* Create vector index key for Redis
*/
createVectorKey(pageId: string, workspaceId: string): string {
return `vector:${workspaceId}:${pageId}`;
}
/**
* Create metadata key for Redis
*/
createMetadataKey(pageId: string, workspaceId: string): string {
return `metadata:${workspaceId}:${pageId}`;
}
/**
* Batch process vectors with chunking
*/
async batchProcess<T, R>(
items: T[],
processor: (batch: T[]) => Promise<R[]>,
batchSize: number = 100,
): Promise<R[]> {
const results: R[] = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
try {
const batchResults = await processor(batch);
results.push(...batchResults);
} catch (error) {
this.logger.error(
`Batch processing failed for items ${i}-${i + batch.length}:`,
error,
);
throw error;
}
}
return results;
}
}

View File

@ -1,11 +1,9 @@
import {
BadRequestException,
Body,
Controller,
HttpCode,
HttpStatus,
Post,
Req,
Res,
UseGuards,
} from '@nestjs/common';
@ -23,7 +21,6 @@ import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { PasswordResetDto } from './dto/password-reset.dto';
import { VerifyUserTokenDto } from './dto/verify-user-token.dto';
import { FastifyReply } from 'fastify';
import { addDays } from 'date-fns';
import { validateSsoEnforcement } from './auth.util';
@Controller('auth')
@ -125,7 +122,7 @@ export class AuthController {
res.setCookie('authToken', token, {
httpOnly: true,
path: '/',
expires: addDays(new Date(), 30),
expires: this.environmentService.getCookieExpiresIn(),
secure: this.environmentService.isHttps(),
});
}

View File

@ -6,3 +6,16 @@ export function validateSsoEnforcement(workspace: Workspace) {
throw new BadRequestException('This workspace has enforced SSO login.');
}
}
export function validateAllowedEmail(userEmail: string, workspace: Workspace) {
const emailParts = userEmail.split('@');
const emailDomain = emailParts[1].toLowerCase();
if (
workspace.emailDomains?.length > 0 &&
!workspace.emailDomains.includes(emailDomain)
) {
throw new BadRequestException(
`The email domain "${emailDomain}" is not approved for this workspace.`,
);
}
}

View File

@ -1,6 +1,12 @@
import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
import {
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { CreateUserDto } from './create-user.dto';
import {Transform, TransformFnParams} from "class-transformer";
import { Transform, TransformFnParams } from 'class-transformer';
export class CreateAdminUserDto extends CreateUserDto {
@IsNotEmpty()
@ -9,10 +15,17 @@ export class CreateAdminUserDto extends CreateUserDto {
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
@IsNotEmpty()
@MinLength(3)
@IsOptional()
@MinLength(1)
@MaxLength(50)
@IsString()
@Transform(({ value }: TransformFnParams) => value?.trim())
workspaceName: string;
@IsOptional()
@MinLength(4)
@MaxLength(50)
@IsString()
@Transform(({ value }: TransformFnParams) => value?.trim())
hostname?: string;
}

View File

@ -92,7 +92,8 @@ export class SignupService {
// create workspace with full setup
const workspaceData: CreateWorkspaceDto = {
name: createAdminUserDto.workspaceName,
name: createAdminUserDto.workspaceName || 'My workspace',
hostname: createAdminUserDto.hostname,
};
workspace = await this.workspaceService.create(

View File

@ -11,6 +11,7 @@ import { PageModule } from './page/page.module';
import { AttachmentModule } from './attachment/attachment.module';
import { CommentModule } from './comment/comment.module';
import { SearchModule } from './search/search.module';
import { AiSearchModule } from './ai-search/ai-search.module';
import { SpaceModule } from './space/space.module';
import { GroupModule } from './group/group.module';
import { CaslModule } from './casl/casl.module';
@ -26,6 +27,7 @@ import { ShareModule } from './share/share.module';
AttachmentModule,
CommentModule,
SearchModule,
AiSearchModule,
SpaceModule,
GroupModule,
CaslModule,

View File

@ -1,5 +1,13 @@
import { OmitType, PartialType } from '@nestjs/mapped-types';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import {
IsBoolean,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { CreateUserDto } from '../../auth/dto/create-user.dto';
export class UpdateUserDto extends PartialType(
@ -13,7 +21,18 @@ export class UpdateUserDto extends PartialType(
@IsBoolean()
fullPageWidth: boolean;
@IsOptional()
@IsString()
@IsIn(['read', 'edit'])
pageEditMode: string;
@IsOptional()
@IsString()
locale: string;
@IsOptional()
@MinLength(8)
@MaxLength(70)
@IsString()
confirmPassword: string;
}

View File

@ -50,6 +50,6 @@ export class UserController {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.userService.update(updateUserDto, user.id, workspace.id);
return this.userService.update(updateUserDto, user.id, workspace);
}
}

View File

@ -3,8 +3,12 @@ import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { UpdateUserDto } from './dto/update-user.dto';
import { comparePasswordHash } from 'src/common/helpers/utils';
import { Workspace } from '@docmost/db/types/entity.types';
import { validateSsoEnforcement } from '../auth/auth.util';
@Injectable()
export class UserService {
@ -17,9 +21,14 @@ export class UserService {
async update(
updateUserDto: UpdateUserDto,
userId: string,
workspaceId: string,
workspace: Workspace,
) {
const user = await this.userRepo.findById(userId, workspaceId);
const includePassword =
updateUserDto.email != null && updateUserDto.confirmPassword != null;
const user = await this.userRepo.findById(userId, workspace.id, {
includePassword,
});
if (!user) {
throw new NotFoundException('User not found');
@ -34,14 +43,40 @@ export class UserService {
);
}
if (typeof updateUserDto.pageEditMode !== 'undefined') {
return this.userRepo.updatePreference(
userId,
'pageEditMode',
updateUserDto.pageEditMode.toLowerCase(),
);
}
if (updateUserDto.name) {
user.name = updateUserDto.name;
}
if (updateUserDto.email && user.email != updateUserDto.email) {
if (await this.userRepo.findByEmail(updateUserDto.email, workspaceId)) {
validateSsoEnforcement(workspace);
if (!updateUserDto.confirmPassword) {
throw new BadRequestException(
'You must provide a password to change your email',
);
}
const isPasswordMatch = await comparePasswordHash(
updateUserDto.confirmPassword,
user.password,
);
if (!isPasswordMatch) {
throw new BadRequestException('You must provide the correct password to change your email');
}
if (await this.userRepo.findByEmail(updateUserDto.email, workspace.id)) {
throw new BadRequestException('A user with this email already exists');
}
user.email = updateUserDto.email;
}
@ -53,7 +88,9 @@ export class UserService {
user.locale = updateUserDto.locale;
}
await this.userRepo.updateUser(updateUserDto, userId, workspaceId);
delete updateUserDto.confirmPassword;
await this.userRepo.updateUser(updateUserDto, userId, workspace.id);
return user;
}
}

View File

@ -29,9 +29,7 @@ import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.fact
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../../casl/interfaces/workspace-ability.type';
import { addDays } from 'date-fns';
import { FastifyReply } from 'fastify';
} from '../../casl/interfaces/workspace-ability.type';import { FastifyReply } from 'fastify';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { CheckHostnameDto } from '../dto/check-hostname.dto';
import { RemoveWorkspaceUserDto } from '../dto/remove-workspace-user.dto';
@ -180,10 +178,13 @@ export class WorkspaceController {
@Public()
@HttpCode(HttpStatus.OK)
@Post('invites/info')
async getInvitationById(@Body() dto: InvitationIdDto, @Req() req: any) {
async getInvitationById(
@Body() dto: InvitationIdDto,
@AuthWorkspace() workspace: Workspace,
) {
return this.workspaceInvitationService.getInvitationById(
dto.invitationId,
req.raw.workspaceId,
workspace,
);
}
@ -253,18 +254,18 @@ export class WorkspaceController {
@Post('invites/accept')
async acceptInvite(
@Body() acceptInviteDto: AcceptInviteDto,
@Req() req: any,
@AuthWorkspace() workspace: Workspace,
@Res({ passthrough: true }) res: FastifyReply,
) {
const authToken = await this.workspaceInvitationService.acceptInvitation(
acceptInviteDto,
req.raw.workspaceId,
workspace,
);
res.setCookie('authToken', authToken, {
httpOnly: true,
path: '/',
expires: addDays(new Date(), 30),
expires: this.environmentService.getCookieExpiresIn(),
secure: this.environmentService.isHttps(),
});
}

View File

@ -28,6 +28,10 @@ import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import {
validateAllowedEmail,
validateSsoEnforcement,
} from '../../auth/auth.util';
@Injectable()
export class WorkspaceInvitationService {
@ -63,19 +67,19 @@ export class WorkspaceInvitationService {
return result;
}
async getInvitationById(invitationId: string, workspaceId: string) {
async getInvitationById(invitationId: string, workspace: Workspace) {
const invitation = await this.db
.selectFrom('workspaceInvitations')
.select(['id', 'email', 'createdAt'])
.where('id', '=', invitationId)
.where('workspaceId', '=', workspaceId)
.where('workspaceId', '=', workspace.id)
.executeTakeFirst();
if (!invitation) {
throw new NotFoundException('Invitation not found');
}
return invitation;
return { ...invitation, enforceSso: workspace.enforceSso };
}
async getInvitationTokenById(invitationId: string, workspaceId: string) {
@ -141,6 +145,10 @@ export class WorkspaceInvitationService {
groupIds: validGroups?.map((group: Partial<Group>) => group.id),
}));
if (invitesToInsert.length < 1) {
return;
}
invites = await trx
.insertInto('workspaceInvitations')
.values(invitesToInsert)
@ -169,12 +177,12 @@ export class WorkspaceInvitationService {
}
}
async acceptInvitation(dto: AcceptInviteDto, workspaceId: string) {
async acceptInvitation(dto: AcceptInviteDto, workspace: Workspace) {
const invitation = await this.db
.selectFrom('workspaceInvitations')
.selectAll()
.where('id', '=', dto.invitationId)
.where('workspaceId', '=', workspaceId)
.where('workspaceId', '=', workspace.id)
.executeTakeFirst();
if (!invitation) {
@ -185,6 +193,9 @@ export class WorkspaceInvitationService {
throw new BadRequestException('Invalid invitation token');
}
validateSsoEnforcement(workspace);
validateAllowedEmail(invitation.email, workspace);
let newUser: User;
try {
@ -197,7 +208,7 @@ export class WorkspaceInvitationService {
password: dto.password,
role: invitation.role,
invitedById: invitation.invitedById,
workspaceId: workspaceId,
workspaceId: workspace.id,
},
trx,
);
@ -205,7 +216,7 @@ export class WorkspaceInvitationService {
// add user to default group
await this.groupUserRepo.addUserToDefaultGroup(
newUser.id,
workspaceId,
workspace.id,
trx,
);
@ -215,7 +226,7 @@ export class WorkspaceInvitationService {
.selectFrom('groups')
.select(['id', 'name'])
.where('groups.id', 'in', invitation.groupIds)
.where('groups.workspaceId', '=', workspaceId)
.where('groups.workspaceId', '=', workspace.id)
.execute();
if (validGroups && validGroups.length > 0) {
@ -256,7 +267,7 @@ export class WorkspaceInvitationService {
// notify the inviter
const invitedByUser = await this.userRepo.findById(
invitation.invitedById,
workspaceId,
workspace.id,
);
if (invitedByUser) {
@ -273,7 +284,9 @@ export class WorkspaceInvitationService {
}
if (this.environmentService.isCloud()) {
await this.billingQueue.add(QueueJob.STRIPE_SEATS_SYNC, { workspaceId });
await this.billingQueue.add(QueueJob.STRIPE_SEATS_SYNC, {
workspaceId: workspace.id,
});
}
return this.tokenService.generateAccessToken(newUser);

View File

@ -32,6 +32,7 @@ import { AttachmentType } from 'src/core/attachment/attachment.constants';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq';
import { generateRandomSuffixNumbers } from '../../../common/helpers';
@Injectable()
export class WorkspaceService {
@ -377,24 +378,20 @@ export class WorkspaceService {
name: string,
trx?: KyselyTransaction,
): Promise<string> {
const generateRandomSuffix = (length: number) =>
Math.random()
.toFixed(length)
.substring(2, 2 + length);
let subdomain = name
.toLowerCase()
.replace(/[^a-z0-9]/g, '')
.substring(0, 20);
.replace(/[^a-z0-9-]/g, '')
.substring(0, 20)
.replace(/^-+|-+$/g, ''); //remove any hyphen at the start or end
// Ensure we leave room for a random suffix.
const maxSuffixLength = 6;
if (subdomain.length < 4) {
subdomain = `${subdomain}-${generateRandomSuffix(maxSuffixLength)}`;
subdomain = `${subdomain}-${generateRandomSuffixNumbers(maxSuffixLength)}`;
}
if (DISALLOWED_HOSTNAMES.includes(subdomain)) {
subdomain = `workspace-${generateRandomSuffix(maxSuffixLength)}`;
subdomain = `workspace-${generateRandomSuffixNumbers(maxSuffixLength)}`;
}
let uniqueHostname = subdomain;
@ -408,7 +405,7 @@ export class WorkspaceService {
break;
}
// Append a random suffix and retry.
const randomSuffix = generateRandomSuffix(maxSuffixLength);
const randomSuffix = generateRandomSuffixNumbers(maxSuffixLength);
uniqueHostname = `${subdomain}-${randomSuffix}`.substring(0, 25);
}

View File

@ -0,0 +1,23 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('billing')
.addColumn('billing_scheme', 'varchar', (col) => col)
.addColumn('tiered_up_to', 'varchar', (col) => col)
.addColumn('tiered_flat_amount', 'int8', (col) => col)
.addColumn('tiered_unit_amount', 'int8', (col) => col)
.addColumn('plan_name', 'varchar', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('billing')
.dropColumn('billing_scheme')
.dropColumn('tiered_up_to')
.dropColumn('tiered_flat_amount')
.dropColumn('tiered_unit_amount')
.dropColumn('plan_name')
.execute();
}

View File

@ -84,6 +84,7 @@ export interface Backlinks {
export interface Billing {
amount: Int8 | null;
billingScheme: string | null;
cancelAt: Timestamp | null;
cancelAtPeriodEnd: boolean | null;
canceledAt: Timestamp | null;
@ -96,6 +97,7 @@ export interface Billing {
metadata: Json | null;
periodEndAt: Timestamp | null;
periodStartAt: Timestamp;
planName: string | null;
quantity: Int8 | null;
status: string;
stripeCustomerId: string | null;
@ -103,6 +105,9 @@ export interface Billing {
stripePriceId: string | null;
stripeProductId: string | null;
stripeSubscriptionId: string;
tieredFlatAmount: Int8 | null;
tieredUnitAmount: Int8 | null;
tieredUpTo: string | null;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}

View File

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import ms, { StringValue } from 'ms';
@Injectable()
export class EnvironmentService {
@ -56,7 +57,18 @@ export class EnvironmentService {
}
getJwtTokenExpiresIn(): string {
return this.configService.get<string>('JWT_TOKEN_EXPIRES_IN', '30d');
return this.configService.get<string>('JWT_TOKEN_EXPIRES_IN', '90d');
}
getCookieExpiresIn(): Date {
const expiresInStr = this.getJwtTokenExpiresIn();
let msUntilExpiry: number;
try {
msUntilExpiry = ms(expiresInStr as StringValue);
} catch (err) {
msUntilExpiry = ms('90d');
}
return new Date(Date.now() + msUntilExpiry);
}
getStorageDriver(): string {
@ -193,4 +205,12 @@ export class EnvironmentService {
.toLowerCase();
return disable === 'true';
}
getPostHogHost(): string {
return this.configService.get<string>('POSTHOG_HOST');
}
getPostHogKey(): string {
return this.configService.get<string>('POSTHOG_KEY');
}
}

View File

@ -37,6 +37,8 @@ export class StaticModule implements OnModuleInit {
CLOUD: this.environmentService.isCloud(),
FILE_UPLOAD_SIZE_LIMIT:
this.environmentService.getFileUploadSizeLimit(),
FILE_IMPORT_SIZE_LIMIT:
this.environmentService.getFileImportSizeLimit(),
DRAWIO_URL: this.environmentService.getDrawioUrl(),
SUBDOMAIN_HOST: this.environmentService.isCloud()
? this.environmentService.getSubdomainHost()
@ -45,6 +47,8 @@ export class StaticModule implements OnModuleInit {
BILLING_TRIAL_DAYS: this.environmentService.isCloud()
? this.environmentService.getBillingTrialDays()
: undefined,
POSTHOG_HOST: this.environmentService.getPostHogHost(),
POSTHOG_KEY: this.environmentService.getPostHogKey(),
};
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;

View File

@ -1,7 +1,7 @@
{
"name": "docmost",
"homepage": "https://docmost.com",
"version": "0.20.4",
"version": "0.21.0",
"private": true,
"scripts": {
"build": "nx run-many -t build",
@ -26,40 +26,40 @@
"@joplin/turndown": "^4.0.74",
"@joplin/turndown-plugin-gfm": "^1.0.56",
"@sindresorhus/slugify": "1.1.0",
"@tiptap/core": "^2.14.0",
"@tiptap/extension-code-block": "^2.14.0",
"@tiptap/extension-code-block-lowlight": "^2.14.0",
"@tiptap/extension-collaboration": "^2.14.0",
"@tiptap/extension-collaboration-cursor": "^2.14.0",
"@tiptap/extension-color": "^2.14.0",
"@tiptap/extension-document": "^2.14.0",
"@tiptap/extension-heading": "^2.14.0",
"@tiptap/extension-highlight": "^2.14.0",
"@tiptap/extension-history": "^2.14.0",
"@tiptap/extension-image": "^2.14.0",
"@tiptap/extension-link": "^2.14.0",
"@tiptap/extension-list-item": "^2.14.0",
"@tiptap/extension-list-keymap": "^2.14.0",
"@tiptap/extension-placeholder": "^2.14.0",
"@tiptap/extension-subscript": "^2.14.0",
"@tiptap/extension-superscript": "^2.14.0",
"@tiptap/extension-table": "^2.14.0",
"@tiptap/extension-table-cell": "^2.14.0",
"@tiptap/extension-table-header": "^2.14.0",
"@tiptap/extension-table-row": "^2.14.0",
"@tiptap/extension-task-item": "^2.14.0",
"@tiptap/extension-task-list": "^2.14.0",
"@tiptap/extension-text": "^2.14.0",
"@tiptap/extension-text-align": "^2.14.0",
"@tiptap/extension-text-style": "^2.14.0",
"@tiptap/extension-typography": "^2.14.0",
"@tiptap/extension-underline": "^2.14.0",
"@tiptap/extension-youtube": "^2.14.0",
"@tiptap/html": "^2.14.0",
"@tiptap/pm": "^2.14.0",
"@tiptap/react": "^2.14.0",
"@tiptap/starter-kit": "^2.14.0",
"@tiptap/suggestion": "^2.14.0",
"@tiptap/core": "^2.10.3",
"@tiptap/extension-code-block": "^2.10.3",
"@tiptap/extension-code-block-lowlight": "^2.10.3",
"@tiptap/extension-collaboration": "^2.10.3",
"@tiptap/extension-collaboration-cursor": "^2.10.3",
"@tiptap/extension-color": "^2.10.3",
"@tiptap/extension-document": "^2.10.3",
"@tiptap/extension-heading": "^2.10.3",
"@tiptap/extension-highlight": "^2.10.3",
"@tiptap/extension-history": "^2.10.3",
"@tiptap/extension-image": "^2.10.3",
"@tiptap/extension-link": "^2.10.3",
"@tiptap/extension-list-item": "^2.10.3",
"@tiptap/extension-list-keymap": "^2.10.3",
"@tiptap/extension-placeholder": "^2.10.3",
"@tiptap/extension-subscript": "^2.10.3",
"@tiptap/extension-superscript": "^2.10.3",
"@tiptap/extension-table": "^2.10.3",
"@tiptap/extension-table-cell": "^2.10.3",
"@tiptap/extension-table-header": "^2.10.3",
"@tiptap/extension-table-row": "^2.10.3",
"@tiptap/extension-task-item": "^2.10.3",
"@tiptap/extension-task-list": "^2.10.3",
"@tiptap/extension-text": "^2.10.3",
"@tiptap/extension-text-align": "^2.10.3",
"@tiptap/extension-text-style": "^2.10.3",
"@tiptap/extension-typography": "^2.10.3",
"@tiptap/extension-underline": "^2.10.3",
"@tiptap/extension-youtube": "^2.10.3",
"@tiptap/html": "^2.10.3",
"@tiptap/pm": "^2.10.3",
"@tiptap/react": "^2.10.3",
"@tiptap/starter-kit": "^2.10.3",
"@tiptap/suggestion": "^2.10.3",
"bytes": "^3.1.2",
"cross-env": "^7.0.3",
"date-fns": "^4.1.0",
@ -69,6 +69,7 @@
"jszip": "^3.10.1",
"linkifyjs": "^4.2.0",
"marked": "13.0.3",
"ms": "3.0.0-canary.1",
"uuid": "^11.1.0",
"y-indexeddb": "^9.0.12",
"yjs": "^13.6.27"

306
pnpm-lock.yaml generated
View File

@ -30,7 +30,7 @@ importers:
version: 2.15.2(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)
'@hocuspocus/transformer':
specifier: ^2.15.2
version: 2.15.2(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(y-prosemirror@1.2.3(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(yjs@13.6.27)
version: 2.15.2(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(y-prosemirror@1.2.3(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(yjs@13.6.27)
'@joplin/turndown':
specifier: ^4.0.74
version: 4.0.74
@ -41,106 +41,106 @@ importers:
specifier: 1.1.0
version: 1.1.0
'@tiptap/core':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/pm@2.14.0)
'@tiptap/extension-code-block':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
'@tiptap/extension-code-block-lowlight':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/extension-code-block@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(highlight.js@11.11.1)(lowlight@3.3.0)
'@tiptap/extension-collaboration':
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(y-prosemirror@1.2.3(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(y-prosemirror@1.2.3(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))
'@tiptap/extension-collaboration-cursor':
specifier: ^2.14.0
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(y-prosemirror@1.2.3(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(y-prosemirror@1.2.3(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))
'@tiptap/extension-color':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/extension-text-style@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0)))
'@tiptap/extension-document':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-heading':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-highlight':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-history':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
'@tiptap/extension-image':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-link':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
'@tiptap/extension-list-item':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-list-keymap':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-placeholder':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
'@tiptap/extension-subscript':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-superscript':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-table':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
'@tiptap/extension-table-cell':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-table-header':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-table-row':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-task-item':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
'@tiptap/extension-task-list':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-text':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-text-align':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-text-style':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-typography':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-underline':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/extension-youtube':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
'@tiptap/html':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
'@tiptap/pm':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0
'@tiptap/react':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tiptap/starter-kit':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0
'@tiptap/suggestion':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
bytes:
specifier: ^3.1.2
@ -169,6 +169,9 @@ importers:
marked:
specifier: 13.0.3
version: 13.0.3
ms:
specifier: 3.0.0-canary.1
version: 3.0.0-canary.1
uuid:
specifier: ^11.1.0
version: 11.1.0
@ -243,8 +246,11 @@ importers:
specifier: ^5.80.6
version: 5.80.6(react@18.3.1)
'@tiptap/extension-character-count':
specifier: ^2.14.0
specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
alfaaz:
specifier: ^1.1.0
version: 1.1.0
axios:
specifier: ^1.9.0
version: 1.9.0
@ -290,6 +296,9 @@ importers:
mitt:
specifier: ^3.0.1
version: 3.0.1
posthog-js:
specifier: ^1.255.1
version: 1.255.1
react:
specifier: ^18.3.1
version: 18.3.1
@ -522,6 +531,9 @@ importers:
nodemailer:
specifier: ^7.0.3
version: 7.0.3
openai:
specifier: ^5.8.2
version: 5.8.2(ws@8.18.2)(zod@3.25.56)
openid-client:
specifier: ^5.7.1
version: 5.7.1
@ -543,6 +555,9 @@ importers:
react:
specifier: ^18.3.1
version: 18.3.1
redis:
specifier: ^5.5.6
version: 5.5.6
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
@ -3340,6 +3355,34 @@ packages:
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@redis/bloom@5.5.6':
resolution: {integrity: sha512-bNR3mxkwtfuCxNOzfV8B3R5zA1LiN57EH6zK4jVBIgzMzliNuReZXBFGnXvsi80/SYohajn78YdpYI+XNpqL+A==}
engines: {node: '>= 18'}
peerDependencies:
'@redis/client': ^5.5.6
'@redis/client@5.5.6':
resolution: {integrity: sha512-M3Svdwt6oSfyfQdqEr0L2HOJH2vK7GgCFx1NfAQvpWAT4+ljoT1L5S5cKT3dA9NJrxrOPDkdoTPWJnIrGCOcmw==}
engines: {node: '>= 18'}
'@redis/json@5.5.6':
resolution: {integrity: sha512-AIsoe3SsGQagqAmSQHaqxEinm5oCWr7zxPWL90kKaEdLJ+zw8KBznf2i9oK0WUFP5pFssSQUXqnscQKe2amfDQ==}
engines: {node: '>= 18'}
peerDependencies:
'@redis/client': ^5.5.6
'@redis/search@5.5.6':
resolution: {integrity: sha512-JSqasYqO0mVcHL7oxvbySRBBZYRYhFl3W7f0Da7BW8M/r0Z9wCiVrdjnN4/mKBpWZkoJT/iuisLUdPGhpKxBew==}
engines: {node: '>= 18'}
peerDependencies:
'@redis/client': ^5.5.6
'@redis/time-series@5.5.6':
resolution: {integrity: sha512-jkpcgq3NOI3TX7xEAJ3JgesJTxAx7k0m6lNxNsYdEM8KOl+xj7GaB/0CbLkoricZDmFSEAz7ClA1iK9XkGHf+Q==}
engines: {node: '>= 18'}
peerDependencies:
'@redis/client': ^5.5.6
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
@ -4677,6 +4720,9 @@ packages:
ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
alfaaz@1.1.0:
resolution: {integrity: sha512-J/P07R41APslK7NmD5303bwStN8jpRA4DdvtLeAr1Jhfj6XWGrASUWI0G6jbWjJAZyw3Lu1Pb4J8rsM/cb+xDQ==}
ansi-align@3.0.1:
resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==}
@ -5204,6 +5250,9 @@ packages:
core-js-compat@3.35.0:
resolution: {integrity: sha512-5blwFAddknKeNgsjBzilkdQ0+YK8L1PfqPYq40NOYMYFSS38qj+hpTcLLWwpIwA2A5bje/x5jmVn2tzUMg9IVw==}
core-js@3.43.0:
resolution: {integrity: sha512-N6wEbTTZSYOY2rYAn85CuvWWkCK6QweMn7/4Nr3w+gDBeBhk/x4EJeY6FPo4QzDoJZxVTv8U7CMvgWk6pOHHqA==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@ -5979,6 +6028,9 @@ packages:
picomatch:
optional: true
fflate@0.4.8:
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
@ -7358,6 +7410,10 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
ms@3.0.0-canary.1:
resolution: {integrity: sha512-kh8ARjh8rMN7Du2igDRO9QJnqCb2xYTJxyQYK7vJJS4TvLLmsbyhiKpSW+t+y26gyOyMd0riphX0GeWKU3ky5g==}
engines: {node: '>=12.13'}
msgpackr-extract@3.0.2:
resolution: {integrity: sha512-SdzXp4kD/Qf8agZ9+iTu6eql0m3kWm1A2y1hkpTeVNENutaB0BwHlSvAIaMxwntmRUAUjon2V4L8Z/njd0Ct8A==}
hasBin: true
@ -7582,6 +7638,18 @@ packages:
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
engines: {node: '>=12'}
openai@5.8.2:
resolution: {integrity: sha512-8C+nzoHYgyYOXhHGN6r0fcb4SznuEn1R7YZMvlqDbnCuE0FM2mm3T1HiYW6WIcMS/F1Of2up/cSPjLPaWt0X9Q==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.23.8
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
openid-client@5.7.1:
resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==}
@ -7942,9 +8010,23 @@ packages:
postgres-range@1.1.4:
resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==}
posthog-js@1.255.1:
resolution: {integrity: sha512-KMh0o9MhORhEZVjXpktXB5rJ8PfDk+poqBoTSoLzWgNjhJf6D8jcyB9jUMA6vVPfn4YeepVX5NuclDRqOwr5Mw==}
peerDependencies:
'@rrweb/types': 2.0.0-alpha.17
rrweb-snapshot: 2.0.0-alpha.17
peerDependenciesMeta:
'@rrweb/types':
optional: true
rrweb-snapshot:
optional: true
postmark@4.0.5:
resolution: {integrity: sha512-nerZdd3TwOH4CgGboZnlUM/q7oZk0EqpZgJL+Y3Nup8kHeaukxouQ6JcFF3EJEijc4QbuNv1TefGhboAKtf/SQ==}
preact@10.26.9:
resolution: {integrity: sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA==}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@ -8024,9 +8106,6 @@ packages:
prosemirror-menu@1.2.4:
resolution: {integrity: sha512-S/bXlc0ODQup6aiBbWVsX/eM+xJgCTAfMq/nLqaO5ID/am4wS0tTCIkzwytmao7ypEtjj39i7YbJjAgO20mIqA==}
prosemirror-model@1.23.0:
resolution: {integrity: sha512-Q/fgsgl/dlOAW9ILu4OOhYWQbc7TQd4BwKH/RwmUjyVf8682Be4zj3rOYdLnYEcGzyg8LL9Q5IWYKD8tdToreQ==}
prosemirror-model@1.25.1:
resolution: {integrity: sha512-AUvbm7qqmpZa5d9fPKMvH1Q5bqYQvAZWOGRvxsB6iFLyycvC9MwNemNVjHVrWgjaoxAfY8XVg7DbvQ/qxvI9Eg==}
@ -8049,15 +8128,9 @@ packages:
prosemirror-state: ^1.4.2
prosemirror-view: ^1.33.8
prosemirror-transform@1.10.2:
resolution: {integrity: sha512-2iUq0wv2iRoJO/zj5mv8uDUriOHWzXRnOTVgCzSXnktS/2iQRa3UUQwVlkBlYZFtygw6Nh1+X4mGqoYBINn5KQ==}
prosemirror-transform@1.10.4:
resolution: {integrity: sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==}
prosemirror-view@1.37.0:
resolution: {integrity: sha512-z2nkKI1sJzyi7T47Ji/ewBPuIma1RNvQCCYVdV+MqWBV7o4Sa1n94UJCJJ1aQRF/xRkFfyqLGlGFWitIcCOtbg==}
prosemirror-view@1.40.0:
resolution: {integrity: sha512-2G3svX0Cr1sJjkD/DYWSe3cfV5VPVTBOxI9XQEGWJDFEpsZb/gh4MV29ctv+OJx2RFX4BLt09i+6zaGM/ldkCw==}
@ -8299,6 +8372,10 @@ packages:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'}
redis@5.5.6:
resolution: {integrity: sha512-hbpqBfcuhWHOS9YLNcXcJ4akNr7HFX61Dq3JuFZ9S7uU7C7kvnzuH2PDIXOP62A3eevvACoG8UacuXP3N07xdg==}
engines: {node: '>= 18'}
redlock@4.2.0:
resolution: {integrity: sha512-j+oQlG+dOwcetUt2WJWttu4CZVeRzUrcVcISFmEmfyuwCVSJ93rDT7YSgg7H7rnxwoRyk/jU46kycVka5tW7jA==}
engines: {node: '>=8.0.0'}
@ -9293,6 +9370,9 @@ packages:
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
web-vitals@4.2.4:
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
web-worker@1.5.0:
resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==}
@ -11796,12 +11876,12 @@ snapshots:
- bufferutil
- utf-8-validate
'@hocuspocus/transformer@2.15.2(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(y-prosemirror@1.2.3(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(yjs@13.6.27)':
'@hocuspocus/transformer@2.15.2(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(y-prosemirror@1.2.3(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(yjs@13.6.27)':
dependencies:
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
'@tiptap/pm': 2.14.0
'@tiptap/starter-kit': 2.14.0
y-prosemirror: 1.2.3(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)
y-prosemirror: 1.2.3(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)
yjs: 13.6.27
'@humanfs/core@0.19.1': {}
@ -12998,6 +13078,26 @@ snapshots:
dependencies:
react: 18.3.1
'@redis/bloom@5.5.6(@redis/client@5.5.6)':
dependencies:
'@redis/client': 5.5.6
'@redis/client@5.5.6':
dependencies:
cluster-key-slot: 1.1.2
'@redis/json@5.5.6(@redis/client@5.5.6)':
dependencies:
'@redis/client': 5.5.6
'@redis/search@5.5.6(@redis/client@5.5.6)':
dependencies:
'@redis/client': 5.5.6
'@redis/time-series@5.5.6(@redis/client@5.5.6)':
dependencies:
'@redis/client': 5.5.6
'@remirror/core-constants@3.0.0': {}
'@rollup/rollup-android-arm-eabi@4.40.0':
@ -13557,16 +13657,16 @@ snapshots:
dependencies:
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
'@tiptap/extension-collaboration-cursor@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(y-prosemirror@1.2.3(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))':
'@tiptap/extension-collaboration-cursor@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(y-prosemirror@1.2.3(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))':
dependencies:
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
y-prosemirror: 1.2.3(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)
y-prosemirror: 1.2.3(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)
'@tiptap/extension-collaboration@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(y-prosemirror@1.2.3(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))':
'@tiptap/extension-collaboration@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(y-prosemirror@1.2.3(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))':
dependencies:
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
'@tiptap/pm': 2.14.0
y-prosemirror: 1.2.3(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)
y-prosemirror: 1.2.3(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)
'@tiptap/extension-color@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/extension-text-style@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0)))':
dependencies:
@ -13730,14 +13830,14 @@ snapshots:
prosemirror-keymap: 1.2.2
prosemirror-markdown: 1.13.1
prosemirror-menu: 1.2.4
prosemirror-model: 1.23.0
prosemirror-model: 1.25.1
prosemirror-schema-basic: 1.2.3
prosemirror-schema-list: 1.4.1
prosemirror-state: 1.4.3
prosemirror-tables: 1.7.1
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)
prosemirror-transform: 1.10.2
prosemirror-view: 1.37.0
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)
prosemirror-transform: 1.10.4
prosemirror-view: 1.40.0
'@tiptap/react@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
@ -14558,6 +14658,8 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
alfaaz@1.1.0: {}
ansi-align@3.0.1:
dependencies:
string-width: 4.2.3
@ -15188,6 +15290,8 @@ snapshots:
dependencies:
browserslist: 4.24.2
core-js@3.43.0: {}
core-util-is@1.0.3: {}
cors@2.8.5:
@ -16175,6 +16279,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.2
fflate@0.4.8: {}
fflate@0.8.2: {}
figures@3.2.0:
@ -17853,6 +17959,8 @@ snapshots:
ms@2.1.3: {}
ms@3.0.0-canary.1: {}
msgpackr-extract@3.0.2:
dependencies:
node-gyp-build-optional-packages: 5.0.7
@ -18102,6 +18210,11 @@ snapshots:
is-docker: 2.2.1
is-wsl: 2.2.0
openai@5.8.2(ws@8.18.2)(zod@3.25.56):
optionalDependencies:
ws: 8.18.2
zod: 3.25.56
openid-client@5.7.1:
dependencies:
jose: 4.15.9
@ -18474,12 +18587,21 @@ snapshots:
postgres-range@1.1.4: {}
posthog-js@1.255.1:
dependencies:
core-js: 3.43.0
fflate: 0.4.8
preact: 10.26.9
web-vitals: 4.2.4
postmark@4.0.5:
dependencies:
axios: 1.9.0
transitivePeerDependencies:
- debug
preact@10.26.9: {}
prelude-ls@1.2.1: {}
prettier@3.4.1: {}
@ -18519,7 +18641,7 @@ snapshots:
prosemirror-changeset@2.3.1:
dependencies:
prosemirror-transform: 1.10.2
prosemirror-transform: 1.10.4
prosemirror-collab@1.3.1:
dependencies:
@ -18527,34 +18649,34 @@ snapshots:
prosemirror-commands@1.6.2:
dependencies:
prosemirror-model: 1.23.0
prosemirror-model: 1.25.1
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-transform: 1.10.4
prosemirror-dropcursor@1.8.1:
dependencies:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-view: 1.37.0
prosemirror-transform: 1.10.4
prosemirror-view: 1.40.0
prosemirror-gapcursor@1.3.2:
dependencies:
prosemirror-keymap: 1.2.2
prosemirror-model: 1.23.0
prosemirror-model: 1.25.1
prosemirror-state: 1.4.3
prosemirror-view: 1.37.0
prosemirror-view: 1.40.0
prosemirror-history@1.4.1:
dependencies:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-view: 1.37.0
prosemirror-transform: 1.10.4
prosemirror-view: 1.40.0
rope-sequence: 1.3.4
prosemirror-inputrules@1.4.0:
dependencies:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-transform: 1.10.4
prosemirror-keymap@1.2.2:
dependencies:
@ -18565,7 +18687,7 @@ snapshots:
dependencies:
'@types/markdown-it': 14.1.2
markdown-it: 14.1.0
prosemirror-model: 1.23.0
prosemirror-model: 1.25.1
prosemirror-menu@1.2.4:
dependencies:
@ -18574,29 +18696,25 @@ snapshots:
prosemirror-history: 1.4.1
prosemirror-state: 1.4.3
prosemirror-model@1.23.0:
dependencies:
orderedmap: 2.1.1
prosemirror-model@1.25.1:
dependencies:
orderedmap: 2.1.1
prosemirror-schema-basic@1.2.3:
dependencies:
prosemirror-model: 1.23.0
prosemirror-model: 1.25.1
prosemirror-schema-list@1.4.1:
dependencies:
prosemirror-model: 1.23.0
prosemirror-model: 1.25.1
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-transform: 1.10.4
prosemirror-state@1.4.3:
dependencies:
prosemirror-model: 1.23.0
prosemirror-transform: 1.10.2
prosemirror-view: 1.37.0
prosemirror-model: 1.25.1
prosemirror-transform: 1.10.4
prosemirror-view: 1.40.0
prosemirror-tables@1.7.1:
dependencies:
@ -18606,28 +18724,18 @@ snapshots:
prosemirror-transform: 1.10.4
prosemirror-view: 1.40.0
prosemirror-trailing-node@3.0.0(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0):
prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0):
dependencies:
'@remirror/core-constants': 3.0.0
escape-string-regexp: 4.0.0
prosemirror-model: 1.23.0
prosemirror-model: 1.25.1
prosemirror-state: 1.4.3
prosemirror-view: 1.37.0
prosemirror-transform@1.10.2:
dependencies:
prosemirror-model: 1.23.0
prosemirror-view: 1.40.0
prosemirror-transform@1.10.4:
dependencies:
prosemirror-model: 1.25.1
prosemirror-view@1.37.0:
dependencies:
prosemirror-model: 1.23.0
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.2
prosemirror-view@1.40.0:
dependencies:
prosemirror-model: 1.25.1
@ -18889,6 +18997,14 @@ snapshots:
dependencies:
redis-errors: 1.2.0
redis@5.5.6:
dependencies:
'@redis/bloom': 5.5.6(@redis/client@5.5.6)
'@redis/client': 5.5.6
'@redis/json': 5.5.6(@redis/client@5.5.6)
'@redis/search': 5.5.6(@redis/client@5.5.6)
'@redis/time-series': 5.5.6(@redis/client@5.5.6)
redlock@4.2.0:
dependencies:
bluebird: 3.7.2
@ -19917,6 +20033,8 @@ snapshots:
dependencies:
defaults: 1.0.4
web-vitals@4.2.4: {}
web-worker@1.5.0: {}
webidl-conversions@3.0.1: {}
@ -20099,12 +20217,12 @@ snapshots:
lib0: 0.2.88
yjs: 13.6.27
y-prosemirror@1.2.3(prosemirror-model@1.23.0)(prosemirror-state@1.4.3)(prosemirror-view@1.37.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27):
y-prosemirror@1.2.3(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27):
dependencies:
lib0: 0.2.108
prosemirror-model: 1.23.0
prosemirror-model: 1.25.1
prosemirror-state: 1.4.3
prosemirror-view: 1.37.0
prosemirror-view: 1.40.0
y-protocols: 1.0.6(yjs@13.6.27)
yjs: 13.6.27