mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 20:12:36 +10:00
Compare commits
1 Commits
feature/ad
...
redis-vect
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cf44914ad |
@ -389,15 +389,5 @@
|
||||
"Failed to share page": "Failed to share page",
|
||||
"Copy page": "Copy page",
|
||||
"Copy page to a different space.": "Copy page to a different space.",
|
||||
"Page copied successfully": "Page copied successfully",
|
||||
"Find": "Find",
|
||||
"Not found": "Not found",
|
||||
"Previous Match (Shift+Enter)": "Previous Match (Shift+Enter)",
|
||||
"Next match (Enter)": "Next match (Enter)",
|
||||
"Match case (Alt+C)": "Match case (Alt+C)",
|
||||
"Replace": "Replace",
|
||||
"Close (Escape)": "Close (Escape)",
|
||||
"Replace (Enter)": "Replace (Enter)",
|
||||
"Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)",
|
||||
"Replace all": "Replace all"
|
||||
"Page copied successfully": "Page copied successfully"
|
||||
}
|
||||
|
||||
@ -12,12 +12,6 @@
|
||||
padding: 8px;
|
||||
background: var(--mantine-color-gray-light);
|
||||
cursor: pointer;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
-ms-word-break: break-word;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.commentEditor {
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
type SearchAndReplaceAtomType = {
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
export const searchAndReplaceStateAtom = atom<SearchAndReplaceAtomType>({
|
||||
isOpen: false,
|
||||
});
|
||||
@ -1,312 +0,0 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Dialog,
|
||||
Flex,
|
||||
Input,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconArrowNarrowDown,
|
||||
IconArrowNarrowUp,
|
||||
IconLetterCase,
|
||||
IconReplace,
|
||||
IconSearch,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getHotkeyHandler, useToggle } from "@mantine/hooks";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import classes from "./search-replace.module.css";
|
||||
|
||||
interface PageFindDialogDialogProps {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [replaceText, setReplaceText] = useState("");
|
||||
const [pageFindState, setPageFindState] = useAtom(searchAndReplaceStateAtom);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const [replaceButton, replaceButtonToggle] = useToggle([
|
||||
{ isReplaceShow: false, color: "gray" },
|
||||
{ isReplaceShow: true, color: "blue" },
|
||||
]);
|
||||
|
||||
const [caseSensitive, caseSensitiveToggle] = useToggle([
|
||||
{ isCaseSensitive: false, color: "gray" },
|
||||
{ isCaseSensitive: true, color: "blue" },
|
||||
]);
|
||||
|
||||
const searchInputEvent = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchText(event.target.value);
|
||||
};
|
||||
|
||||
const replaceInputEvent = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setReplaceText(event.target.value);
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setSearchText("");
|
||||
setReplaceText("");
|
||||
setPageFindState({ isOpen: false });
|
||||
// Reset replace button state when closing
|
||||
if (replaceButton.isReplaceShow) {
|
||||
replaceButtonToggle();
|
||||
}
|
||||
// Clear search term in editor
|
||||
if (editor) {
|
||||
editor.commands.setSearchTerm("");
|
||||
}
|
||||
};
|
||||
|
||||
const goToSelection = () => {
|
||||
if (!editor) return;
|
||||
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
const position: Range = results[resultIndex];
|
||||
|
||||
if (!position) return;
|
||||
|
||||
// @ts-ignore
|
||||
editor.commands.setTextSelection(position);
|
||||
|
||||
const element = document.querySelector(".search-result-current");
|
||||
if (element)
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
|
||||
editor.commands.setTextSelection(0);
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
editor.commands.nextSearchResult();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const previous = () => {
|
||||
editor.commands.previousSearchResult();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const replace = () => {
|
||||
editor.commands.setReplaceTerm(replaceText);
|
||||
editor.commands.replace();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const replaceAll = () => {
|
||||
editor.commands.setReplaceTerm(replaceText);
|
||||
editor.commands.replaceAll();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
editor.commands.setSearchTerm(searchText);
|
||||
editor.commands.resetIndex();
|
||||
editor.commands.selectCurrentItem();
|
||||
}, [searchText]);
|
||||
|
||||
const handleOpenEvent = (e) => {
|
||||
setPageFindState({ isOpen: true });
|
||||
const selectedText = editor.state.doc.textBetween(
|
||||
editor.state.selection.from,
|
||||
editor.state.selection.to,
|
||||
);
|
||||
if (selectedText !== "") {
|
||||
setSearchText(selectedText);
|
||||
}
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
};
|
||||
|
||||
const handleCloseEvent = (e) => {
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
!pageFindState.isOpen && closeDialog();
|
||||
|
||||
document.addEventListener("openFindDialogFromEditor", handleOpenEvent);
|
||||
document.addEventListener("closeFindDialogFromEditor", handleCloseEvent);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("openFindDialogFromEditor", handleOpenEvent);
|
||||
document.removeEventListener(
|
||||
"closeFindDialogFromEditor",
|
||||
handleCloseEvent,
|
||||
);
|
||||
};
|
||||
}, [pageFindState.isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
editor.commands.setCaseSensitive(caseSensitive.isCaseSensitive);
|
||||
editor.commands.resetIndex();
|
||||
goToSelection();
|
||||
}, [caseSensitive]);
|
||||
|
||||
const resultsCount = useMemo(
|
||||
() =>
|
||||
searchText.trim() === ""
|
||||
? ""
|
||||
: editor?.storage?.searchAndReplace?.results.length > 0
|
||||
? editor?.storage?.searchAndReplace?.resultIndex +
|
||||
1 +
|
||||
"/" +
|
||||
editor?.storage?.searchAndReplace?.results.length
|
||||
: t("Not found"),
|
||||
[
|
||||
searchText,
|
||||
editor?.storage?.searchAndReplace?.resultIndex,
|
||||
editor?.storage?.searchAndReplace?.results.length,
|
||||
],
|
||||
);
|
||||
|
||||
const location = useLocation();
|
||||
useEffect(() => {
|
||||
closeDialog();
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className={classes.findDialog}
|
||||
opened={pageFindState.isOpen}
|
||||
|
||||
size="lg"
|
||||
radius="md"
|
||||
w={"auto"}
|
||||
position={{ top: 90, right: 50 }}
|
||||
withBorder
|
||||
transitionProps={{ transition: "slide-down" }}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Flex align="center" gap="xs">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={t("Find")}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
rightSection={
|
||||
<Text size="xs" ta="right">
|
||||
{resultsCount}
|
||||
</Text>
|
||||
}
|
||||
rightSectionWidth="70"
|
||||
rightSectionPointerEvents="all"
|
||||
size="xs"
|
||||
w={220}
|
||||
onChange={searchInputEvent}
|
||||
value={searchText}
|
||||
autoFocus
|
||||
onKeyDown={getHotkeyHandler([
|
||||
["Enter", next],
|
||||
["shift+Enter", previous],
|
||||
["alt+C", caseSensitiveToggle],
|
||||
//@ts-ignore
|
||||
...(editable ? [["alt+R", replaceButtonToggle]] : []),
|
||||
])}
|
||||
/>
|
||||
|
||||
<ActionIcon.Group>
|
||||
<Tooltip label={t("Previous match (Shift+Enter)")}>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={previous}>
|
||||
<IconArrowNarrowUp
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Next match (Enter)")}>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={next}>
|
||||
<IconArrowNarrowDown
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Match case (Alt+C)")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={caseSensitive.color}
|
||||
onClick={() => caseSensitiveToggle()}
|
||||
>
|
||||
<IconLetterCase
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{editable && (
|
||||
<Tooltip label={t("Replace")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={replaceButton.color}
|
||||
onClick={() => replaceButtonToggle()}
|
||||
>
|
||||
<IconReplace
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={t("Close (Escape)")}>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={closeDialog}>
|
||||
<IconX style={{ width: "70%", height: "70%" }} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
</Flex>
|
||||
{replaceButton.isReplaceShow && editable && (
|
||||
<Flex align="center" gap="xs">
|
||||
<Input
|
||||
placeholder={t("Replace")}
|
||||
leftSection={<IconReplace size={16} />}
|
||||
rightSection={<div></div>}
|
||||
rightSectionPointerEvents="all"
|
||||
size="xs"
|
||||
w={180}
|
||||
autoFocus
|
||||
onChange={replaceInputEvent}
|
||||
value={replaceText}
|
||||
onKeyDown={getHotkeyHandler([
|
||||
["Enter", replace],
|
||||
["ctrl+alt+Enter", replaceAll],
|
||||
])}
|
||||
/>
|
||||
<ActionIcon.Group>
|
||||
<Tooltip label={t("Replace (Enter)")}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={replace}
|
||||
>
|
||||
{t("Replace")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Replace all (Ctrl+Alt+Enter)")}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={replaceAll}
|
||||
>
|
||||
{t("Replace all")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
</Flex>
|
||||
)}
|
||||
</Stack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchAndReplaceDialog;
|
||||
@ -1,10 +0,0 @@
|
||||
.findDialog{
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.findDialog div[data-position="right"].mantine-Input-section {
|
||||
justify-content: right;
|
||||
padding-right: 8px;
|
||||
}
|
||||
@ -36,7 +36,6 @@ import {
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
Embed,
|
||||
SearchAndReplace,
|
||||
Mention,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
@ -218,22 +217,6 @@ export const mainExtensions = [
|
||||
CharacterCount.configure({
|
||||
wordCounter: (text) => countWords(text),
|
||||
}),
|
||||
SearchAndReplace.extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Mod-f': () => {
|
||||
const event = new CustomEvent("openFindDialogFromEditor", {});
|
||||
document.dispatchEvent(event);
|
||||
return true;
|
||||
},
|
||||
'Escape': () => {
|
||||
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
||||
document.dispatchEvent(event);
|
||||
return true;
|
||||
},
|
||||
}
|
||||
},
|
||||
}).configure(),
|
||||
] as any;
|
||||
|
||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||
|
||||
@ -44,7 +44,6 @@ import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
|
||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
||||
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
||||
import { useIdle } from "@/hooks/use-idle.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
@ -351,8 +350,6 @@ export default function PageEditor({
|
||||
<div style={{ position: "relative" }}>
|
||||
<div ref={menuContainerRef}>
|
||||
<EditorContent editor={editor} />
|
||||
<SearchAndReplaceDialog editor={editor} editable={editable} />
|
||||
|
||||
{editor && editor.isEditable && (
|
||||
<div>
|
||||
<EditorBubbleMenu editor={editor} />
|
||||
|
||||
@ -71,12 +71,4 @@
|
||||
[data-type="details"][open] > [data-type="detailsButton"] .ProseMirror-icon{
|
||||
transform: rotateZ(90deg);
|
||||
}
|
||||
|
||||
[data-type="details"]:has(.search-result) > [data-type="detailsContainer"] > [data-type="detailsContent"]{
|
||||
display: block;
|
||||
}
|
||||
|
||||
[data-type="details"]:has(.search-result) > [data-type="detailsButton"] .ProseMirror-icon{
|
||||
transform: rotateZ(90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
.search-result{
|
||||
background: #ffff65;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.search-result-current{
|
||||
background: #ffc266 !important;
|
||||
color: #212529;
|
||||
}
|
||||
@ -9,5 +9,5 @@
|
||||
@import "./media.css";
|
||||
@import "./code.css";
|
||||
@import "./print.css";
|
||||
@import "./find.css";
|
||||
@import "./mention.css";
|
||||
|
||||
|
||||
@ -10,11 +10,8 @@ import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import {
|
||||
updatePageData,
|
||||
useUpdateTitlePageMutation,
|
||||
} from "@/features/page/queries/page-query";
|
||||
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
|
||||
import { updatePageData, useUpdateTitlePageMutation } from "@/features/page/queries/page-query";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
import { useAtom } from "jotai";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
import { History } from "@tiptap/extension-history";
|
||||
@ -43,8 +40,7 @@ export function TitleEditor({
|
||||
editable,
|
||||
}: TitleEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { mutateAsync: updateTitlePageMutationAsync } =
|
||||
useUpdateTitlePageMutation();
|
||||
const { mutateAsync: updateTitlePageMutationAsync } = useUpdateTitlePageMutation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||
const emit = useQueryEmit();
|
||||
@ -112,12 +108,7 @@ export function TitleEditor({
|
||||
spaceId: page.spaceId,
|
||||
entity: ["pages"],
|
||||
id: page.id,
|
||||
payload: {
|
||||
title: page.title,
|
||||
slugId: page.slugId,
|
||||
parentPageId: page.parentPageId,
|
||||
icon: page.icon,
|
||||
},
|
||||
payload: { title: page.title, slugId: page.slugId, parentPageId: page.parentPageId, icon: page.icon },
|
||||
};
|
||||
|
||||
if (page.title !== titleEditor.getText()) return;
|
||||
@ -161,19 +152,13 @@ export function TitleEditor({
|
||||
}
|
||||
}, [userPageEditMode, titleEditor, editable]);
|
||||
|
||||
const openSearchDialog = () => {
|
||||
const event = new CustomEvent("openFindDialogFromEditor", {});
|
||||
document.dispatchEvent(event);
|
||||
};
|
||||
|
||||
function handleTitleKeyDown(event: any) {
|
||||
if (!titleEditor || !pageEditor || event.shiftKey) return;
|
||||
|
||||
// Prevent focus shift when IME composition is active
|
||||
|
||||
// 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;
|
||||
|
||||
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
|
||||
|
||||
const { key } = event;
|
||||
const { $head } = titleEditor.state.selection;
|
||||
|
||||
@ -187,16 +172,5 @@ export function TitleEditor({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<EditorContent
|
||||
editor={titleEditor}
|
||||
onKeyDown={(event) => {
|
||||
// First handle the search hotkey
|
||||
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
||||
|
||||
// Then handle other key events
|
||||
handleTitleKeyDown(event);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return <EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />;
|
||||
}
|
||||
|
||||
@ -9,7 +9,6 @@ import {
|
||||
IconList,
|
||||
IconMessage,
|
||||
IconPrinter,
|
||||
IconSearch,
|
||||
IconTrash,
|
||||
IconWifiOff,
|
||||
} from "@tabler/icons-react";
|
||||
@ -17,12 +16,7 @@ import React from "react";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
import {
|
||||
getHotkeyHandler,
|
||||
useClipboard,
|
||||
useDisclosure,
|
||||
useHotkeys,
|
||||
} from "@mantine/hooks";
|
||||
import { useClipboard, useDisclosure } from "@mantine/hooks";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
@ -38,7 +32,6 @@ import {
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.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";
|
||||
@ -53,26 +46,6 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
const toggleAside = useToggleAside();
|
||||
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
|
||||
|
||||
useHotkeys(
|
||||
[
|
||||
[
|
||||
"mod+F",
|
||||
() => {
|
||||
const event = new CustomEvent("openFindDialogFromEditor", {});
|
||||
document.dispatchEvent(event);
|
||||
},
|
||||
],
|
||||
[
|
||||
"Escape",
|
||||
() => {
|
||||
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
||||
document.dispatchEvent(event);
|
||||
},
|
||||
],
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{yjsConnectionStatus === "disconnected" && (
|
||||
|
||||
@ -26,9 +26,6 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
||||
{option["type"] === "group" && <IconGroupCircle />}
|
||||
<div>
|
||||
<Text size="sm" lineClamp={1}>{option.label}</Text>
|
||||
{option["type"] === "user" && option["email"] && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>{option["email"]}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
@ -50,7 +47,6 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
|
||||
const userItems = suggestion?.users.map((user: IUser) => ({
|
||||
value: `user-${user.id}`,
|
||||
label: user.name,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
type: "user",
|
||||
}));
|
||||
|
||||
@ -36,7 +36,6 @@ if (isCloud() && isPostHogEnabled) {
|
||||
api_host: getPostHogHost(),
|
||||
defaults: "2025-05-24",
|
||||
disable_session_recording: true,
|
||||
capture_pageleave: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -156,6 +156,7 @@ export class PersistenceExtension implements Extension {
|
||||
page: {
|
||||
...page,
|
||||
content: tiptapJson,
|
||||
textContent: textContent,
|
||||
lastUpdatedById: context.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
444
apps/server/src/core/ai-search/INTEGRATION.md
Normal file
444
apps/server/src/core/ai-search/INTEGRATION.md
Normal 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.
|
||||
201
apps/server/src/core/ai-search/README.md
Normal file
201
apps/server/src/core/ai-search/README.md
Normal 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.
|
||||
224
apps/server/src/core/ai-search/SETUP.md
Normal file
224
apps/server/src/core/ai-search/SETUP.md
Normal 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.
|
||||
38
apps/server/src/core/ai-search/ai-search.controller.spec.ts
Normal file
38
apps/server/src/core/ai-search/ai-search.controller.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
123
apps/server/src/core/ai-search/ai-search.controller.ts
Normal file
123
apps/server/src/core/ai-search/ai-search.controller.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
22
apps/server/src/core/ai-search/ai-search.module.ts
Normal file
22
apps/server/src/core/ai-search/ai-search.module.ts
Normal 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 {}
|
||||
50
apps/server/src/core/ai-search/constants.ts
Normal file
50
apps/server/src/core/ai-search/constants.ts
Normal 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;
|
||||
103
apps/server/src/core/ai-search/dto/semantic-search.dto.ts
Normal file
103
apps/server/src/core/ai-search/dto/semantic-search.dto.ts
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
438
apps/server/src/core/ai-search/services/ai-search.service.ts
Normal file
438
apps/server/src/core/ai-search/services/ai-search.service.ts
Normal 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) + '...';
|
||||
}
|
||||
}
|
||||
185
apps/server/src/core/ai-search/services/embedding.service.ts
Normal file
185
apps/server/src/core/ai-search/services/embedding.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
393
apps/server/src/core/ai-search/services/redis-vector.service.ts
Normal file
393
apps/server/src/core/ai-search/services/redis-vector.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
216
apps/server/src/core/ai-search/services/vector.service.ts
Normal file
216
apps/server/src/core/ai-search/services/vector.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -140,7 +140,7 @@ export class SearchService {
|
||||
if (suggestion.includeUsers) {
|
||||
users = await this.db
|
||||
.selectFrom('users')
|
||||
.select(['id', 'name', 'email', 'avatarUrl'])
|
||||
.select(['id', 'name', 'avatarUrl'])
|
||||
.where((eb) => eb(sql`LOWER(users.name)`, 'like', `%${query}%`))
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
|
||||
Submodule apps/server/src/ee updated: 4c252d1ec3...19197d2610
@ -17,5 +17,4 @@ export * from "./lib/excalidraw";
|
||||
export * from "./lib/embed";
|
||||
export * from "./lib/mention";
|
||||
export * from "./lib/markdown";
|
||||
export * from "./lib/search-and-replace";
|
||||
export * from "./lib/embed-provider";
|
||||
|
||||
@ -35,42 +35,6 @@ export const CustomCodeBlock = CodeBlockLowlight.extend<CustomCodeBlockOptions>(
|
||||
return true;
|
||||
}
|
||||
},
|
||||
"Mod-a": () => {
|
||||
if (this.editor.isActive("codeBlock")) {
|
||||
const { state } = this.editor;
|
||||
const { $from } = state.selection;
|
||||
|
||||
let codeBlockNode = null;
|
||||
let codeBlockPos = null;
|
||||
let depth = 0;
|
||||
|
||||
for (depth = $from.depth; depth > 0; depth--) {
|
||||
const node = $from.node(depth);
|
||||
if (node.type.name === "codeBlock") {
|
||||
codeBlockNode = node;
|
||||
codeBlockPos = $from.start(depth) - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (codeBlockNode && codeBlockPos !== null) {
|
||||
const codeBlockStart = codeBlockPos;
|
||||
const codeBlockEnd = codeBlockPos + codeBlockNode.nodeSize;
|
||||
|
||||
const contentStart = codeBlockStart + 1;
|
||||
const contentEnd = codeBlockEnd - 1;
|
||||
|
||||
this.editor.commands.setTextSelection({
|
||||
from: contentStart,
|
||||
to: contentEnd,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
import { SearchAndReplace } from './search-and-replace'
|
||||
export * from './search-and-replace'
|
||||
export default SearchAndReplace
|
||||
@ -1,455 +0,0 @@
|
||||
/***
|
||||
MIT License
|
||||
Copyright (c) 2023 - 2024 Jeet Mandaliya (Github Username: sereneinserenade)
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
***/
|
||||
|
||||
import { Extension, Range, type Dispatch } from "@tiptap/core";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import {
|
||||
Plugin,
|
||||
PluginKey,
|
||||
type EditorState,
|
||||
type Transaction,
|
||||
} from "@tiptap/pm/state";
|
||||
import { Node as PMNode, Mark } from "@tiptap/pm/model";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
search: {
|
||||
/**
|
||||
* @description Set search term in extension.
|
||||
*/
|
||||
setSearchTerm: (searchTerm: string) => ReturnType;
|
||||
/**
|
||||
* @description Set replace term in extension.
|
||||
*/
|
||||
setReplaceTerm: (replaceTerm: string) => ReturnType;
|
||||
/**
|
||||
* @description Set case sensitivity in extension.
|
||||
*/
|
||||
setCaseSensitive: (caseSensitive: boolean) => ReturnType;
|
||||
/**
|
||||
* @description Reset current search result to first instance.
|
||||
*/
|
||||
resetIndex: () => ReturnType;
|
||||
/**
|
||||
* @description Find next instance of search result.
|
||||
*/
|
||||
nextSearchResult: () => ReturnType;
|
||||
/**
|
||||
* @description Find previous instance of search result.
|
||||
*/
|
||||
previousSearchResult: () => ReturnType;
|
||||
/**
|
||||
* @description Replace first instance of search result with given replace term.
|
||||
*/
|
||||
replace: () => ReturnType;
|
||||
/**
|
||||
* @description Replace all instances of search result with given replace term.
|
||||
*/
|
||||
replaceAll: () => ReturnType;
|
||||
/**
|
||||
* @description Find selected instance of search result.
|
||||
*/
|
||||
selectCurrentItem: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface TextNodesWithPosition {
|
||||
text: string;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
const getRegex = (
|
||||
s: string,
|
||||
disableRegex: boolean,
|
||||
caseSensitive: boolean,
|
||||
): RegExp => {
|
||||
return RegExp(
|
||||
disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : s,
|
||||
caseSensitive ? "gu" : "gui",
|
||||
);
|
||||
};
|
||||
|
||||
interface ProcessedSearches {
|
||||
decorationsToReturn: DecorationSet;
|
||||
results: Range[];
|
||||
}
|
||||
|
||||
function processSearches(
|
||||
doc: PMNode,
|
||||
searchTerm: RegExp,
|
||||
searchResultClass: string,
|
||||
resultIndex: number,
|
||||
): ProcessedSearches {
|
||||
const decorations: Decoration[] = [];
|
||||
const results: Range[] = [];
|
||||
|
||||
let textNodesWithPosition: TextNodesWithPosition[] = [];
|
||||
let index = 0;
|
||||
|
||||
if (!searchTerm) {
|
||||
return {
|
||||
decorationsToReturn: DecorationSet.empty,
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
|
||||
doc?.descendants((node, pos) => {
|
||||
if (node.isText) {
|
||||
if (textNodesWithPosition[index]) {
|
||||
textNodesWithPosition[index] = {
|
||||
text: textNodesWithPosition[index].text + node.text,
|
||||
pos: textNodesWithPosition[index].pos,
|
||||
};
|
||||
} else {
|
||||
textNodesWithPosition[index] = {
|
||||
text: `${node.text}`,
|
||||
pos,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
});
|
||||
|
||||
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
|
||||
|
||||
for (const element of textNodesWithPosition) {
|
||||
const { text, pos } = element;
|
||||
const matches = Array.from(text.matchAll(searchTerm)).filter(
|
||||
([matchText]) => matchText.trim(),
|
||||
);
|
||||
|
||||
for (const m of matches) {
|
||||
if (m[0] === "") break;
|
||||
|
||||
if (m.index !== undefined) {
|
||||
results.push({
|
||||
from: pos + m.index,
|
||||
to: pos + m.index + m[0].length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < results.length; i += 1) {
|
||||
const r = results[i];
|
||||
const className =
|
||||
i === resultIndex
|
||||
? `${searchResultClass} ${searchResultClass}-current`
|
||||
: searchResultClass;
|
||||
const decoration: Decoration = Decoration.inline(r.from, r.to, {
|
||||
class: className,
|
||||
});
|
||||
|
||||
decorations.push(decoration);
|
||||
}
|
||||
|
||||
return {
|
||||
decorationsToReturn: DecorationSet.create(doc, decorations),
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
const replace = (
|
||||
replaceTerm: string,
|
||||
results: Range[],
|
||||
resultIndex: number,
|
||||
{ state, dispatch }: { state: EditorState; dispatch: Dispatch },
|
||||
) => {
|
||||
const firstResult = results[resultIndex];
|
||||
|
||||
if (!firstResult) return;
|
||||
|
||||
const { from, to } = results[resultIndex];
|
||||
|
||||
if (dispatch) {
|
||||
const tr = state.tr;
|
||||
|
||||
// Get all marks that span the text being replaced
|
||||
const marksSet = new Set<Mark>();
|
||||
state.doc.nodesBetween(from, to, (node) => {
|
||||
if (node.isText && node.marks) {
|
||||
node.marks.forEach(mark => marksSet.add(mark));
|
||||
}
|
||||
});
|
||||
|
||||
const marks = Array.from(marksSet);
|
||||
|
||||
// Delete the old text and insert new text with preserved marks
|
||||
tr.delete(from, to);
|
||||
tr.insert(from, state.schema.text(replaceTerm, marks));
|
||||
|
||||
dispatch(tr);
|
||||
}
|
||||
};
|
||||
|
||||
const replaceAll = (
|
||||
replaceTerm: string,
|
||||
results: Range[],
|
||||
{ tr, dispatch }: { tr: Transaction; dispatch: Dispatch },
|
||||
) => {
|
||||
const resultsCopy = results.slice();
|
||||
|
||||
if (!resultsCopy.length) return;
|
||||
|
||||
// Process replacements in reverse order to avoid position shifting issues
|
||||
for (let i = resultsCopy.length - 1; i >= 0; i -= 1) {
|
||||
const { from, to } = resultsCopy[i];
|
||||
|
||||
// Get all marks that span the text being replaced
|
||||
const marksSet = new Set<Mark>();
|
||||
tr.doc.nodesBetween(from, to, (node) => {
|
||||
if (node.isText && node.marks) {
|
||||
node.marks.forEach(mark => marksSet.add(mark));
|
||||
}
|
||||
});
|
||||
|
||||
const marks = Array.from(marksSet);
|
||||
|
||||
// Delete and insert with preserved marks
|
||||
tr.delete(from, to);
|
||||
tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks));
|
||||
}
|
||||
|
||||
dispatch(tr);
|
||||
};
|
||||
|
||||
export const searchAndReplacePluginKey = new PluginKey(
|
||||
"searchAndReplacePlugin",
|
||||
);
|
||||
|
||||
export interface SearchAndReplaceOptions {
|
||||
searchResultClass: string;
|
||||
disableRegex: boolean;
|
||||
}
|
||||
|
||||
export interface SearchAndReplaceStorage {
|
||||
searchTerm: string;
|
||||
replaceTerm: string;
|
||||
results: Range[];
|
||||
lastSearchTerm: string;
|
||||
caseSensitive: boolean;
|
||||
lastCaseSensitive: boolean;
|
||||
resultIndex: number;
|
||||
lastResultIndex: number;
|
||||
}
|
||||
|
||||
export const SearchAndReplace = Extension.create<
|
||||
SearchAndReplaceOptions,
|
||||
SearchAndReplaceStorage
|
||||
>({
|
||||
name: "searchAndReplace",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
searchResultClass: "search-result",
|
||||
disableRegex: true,
|
||||
};
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
searchTerm: "",
|
||||
replaceTerm: "",
|
||||
results: [],
|
||||
lastSearchTerm: "",
|
||||
caseSensitive: false,
|
||||
lastCaseSensitive: false,
|
||||
resultIndex: 0,
|
||||
lastResultIndex: 0,
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setSearchTerm:
|
||||
(searchTerm: string) =>
|
||||
({ editor }) => {
|
||||
editor.storage.searchAndReplace.searchTerm = searchTerm;
|
||||
|
||||
return false;
|
||||
},
|
||||
setReplaceTerm:
|
||||
(replaceTerm: string) =>
|
||||
({ editor }) => {
|
||||
editor.storage.searchAndReplace.replaceTerm = replaceTerm;
|
||||
|
||||
return false;
|
||||
},
|
||||
setCaseSensitive:
|
||||
(caseSensitive: boolean) =>
|
||||
({ editor }) => {
|
||||
editor.storage.searchAndReplace.caseSensitive = caseSensitive;
|
||||
|
||||
return false;
|
||||
},
|
||||
resetIndex:
|
||||
() =>
|
||||
({ editor }) => {
|
||||
editor.storage.searchAndReplace.resultIndex = 0;
|
||||
|
||||
return false;
|
||||
},
|
||||
nextSearchResult:
|
||||
() =>
|
||||
({ editor }) => {
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
|
||||
const nextIndex = resultIndex + 1;
|
||||
|
||||
if (results[nextIndex]) {
|
||||
editor.storage.searchAndReplace.resultIndex = nextIndex;
|
||||
} else {
|
||||
editor.storage.searchAndReplace.resultIndex = 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
previousSearchResult:
|
||||
() =>
|
||||
({ editor }) => {
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
|
||||
const prevIndex = resultIndex - 1;
|
||||
|
||||
if (results[prevIndex]) {
|
||||
editor.storage.searchAndReplace.resultIndex = prevIndex;
|
||||
} else {
|
||||
editor.storage.searchAndReplace.resultIndex = results.length - 1;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
replace:
|
||||
() =>
|
||||
({ editor, state, dispatch }) => {
|
||||
const { replaceTerm, results, resultIndex } =
|
||||
editor.storage.searchAndReplace;
|
||||
|
||||
replace(replaceTerm, results, resultIndex, { state, dispatch });
|
||||
|
||||
// After replace, adjust index if needed
|
||||
// The results will be recalculated by the plugin, but we need to ensure
|
||||
// the index doesn't exceed the new bounds
|
||||
setTimeout(() => {
|
||||
const newResultsLength = editor.storage.searchAndReplace.results.length;
|
||||
if (newResultsLength > 0 && editor.storage.searchAndReplace.resultIndex >= newResultsLength) {
|
||||
// Keep the same position if possible, otherwise go to the last result
|
||||
editor.storage.searchAndReplace.resultIndex = Math.min(resultIndex, newResultsLength - 1);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return false;
|
||||
},
|
||||
replaceAll:
|
||||
() =>
|
||||
({ editor, tr, dispatch }) => {
|
||||
const { replaceTerm, results } = editor.storage.searchAndReplace;
|
||||
|
||||
replaceAll(replaceTerm, results, { tr, dispatch });
|
||||
|
||||
return false;
|
||||
},
|
||||
selectCurrentItem:
|
||||
() =>
|
||||
({ editor }) => {
|
||||
const { results } = editor.storage.searchAndReplace;
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
if (
|
||||
results[i].from == editor.state.selection.from &&
|
||||
results[i].to == editor.state.selection.to
|
||||
) {
|
||||
editor.storage.searchAndReplace.resultIndex = i;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const editor = this.editor;
|
||||
const { searchResultClass, disableRegex } = this.options;
|
||||
|
||||
const setLastSearchTerm = (t: string) =>
|
||||
(editor.storage.searchAndReplace.lastSearchTerm = t);
|
||||
const setLastCaseSensitive = (t: boolean) =>
|
||||
(editor.storage.searchAndReplace.lastCaseSensitive = t);
|
||||
const setLastResultIndex = (t: number) =>
|
||||
(editor.storage.searchAndReplace.lastResultIndex = t);
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: searchAndReplacePluginKey,
|
||||
state: {
|
||||
init: () => DecorationSet.empty,
|
||||
apply({ doc, docChanged }, oldState) {
|
||||
const {
|
||||
searchTerm,
|
||||
lastSearchTerm,
|
||||
caseSensitive,
|
||||
lastCaseSensitive,
|
||||
resultIndex,
|
||||
lastResultIndex,
|
||||
} = editor.storage.searchAndReplace;
|
||||
|
||||
if (
|
||||
!docChanged &&
|
||||
lastSearchTerm === searchTerm &&
|
||||
lastCaseSensitive === caseSensitive &&
|
||||
lastResultIndex === resultIndex
|
||||
)
|
||||
return oldState;
|
||||
|
||||
setLastSearchTerm(searchTerm);
|
||||
setLastCaseSensitive(caseSensitive);
|
||||
setLastResultIndex(resultIndex);
|
||||
|
||||
if (!searchTerm) {
|
||||
editor.storage.searchAndReplace.results = [];
|
||||
return DecorationSet.empty;
|
||||
}
|
||||
|
||||
const { decorationsToReturn, results } = processSearches(
|
||||
doc,
|
||||
getRegex(searchTerm, disableRegex, caseSensitive),
|
||||
searchResultClass,
|
||||
resultIndex,
|
||||
);
|
||||
|
||||
editor.storage.searchAndReplace.results = results;
|
||||
|
||||
return decorationsToReturn;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export default SearchAndReplace;
|
||||
83
pnpm-lock.yaml
generated
83
pnpm-lock.yaml
generated
@ -531,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
|
||||
@ -552,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
|
||||
@ -3349,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==}
|
||||
|
||||
@ -7604,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==}
|
||||
|
||||
@ -8326,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'}
|
||||
@ -13028,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':
|
||||
@ -18140,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
|
||||
@ -18922,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
|
||||
|
||||
Reference in New Issue
Block a user