mirror of
https://github.com/docmost/docmost.git
synced 2025-11-10 22:02:05 +10:00
Compare commits
30 Commits
generic-if
...
redis-vect
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cf44914ad | |||
| 232cea8cc9 | |||
| b9643d3584 | |||
| 9f144d35fb | |||
| e44c170873 | |||
| 1be39d4353 | |||
| 36d028ef4d | |||
| f5a36c60e8 | |||
| d5b84ae0b8 | |||
| e775e4dd8c | |||
| 65b01038d7 | |||
| e07cb57b01 | |||
| 2b53e0a455 | |||
| b9b3406b28 | |||
| 728cac0a34 | |||
| d35e16010b | |||
| 15791d4e59 | |||
| 3318e13225 | |||
| 080900610d | |||
| d1dc6977ab | |||
| 5f62448894 | |||
| 44445fbf46 | |||
| 1c674efddd | |||
| ccf7e34e99 | |||
| f39d48d6ee | |||
| f584ea84b0 | |||
| bc0c4d6258 | |||
| d8da307a61 | |||
| 50b3f9ddd9 | |||
| 0029f84d50 |
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.20.4",
|
||||
"version": "0.21.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@ -15,45 +15,47 @@
|
||||
"@docmost/editor-ext": "workspace:*",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@excalidraw/excalidraw": "^0.17.6",
|
||||
"@excalidraw/excalidraw": "0.18.0-864353b",
|
||||
"@mantine/core": "^7.17.0",
|
||||
"@mantine/form": "^7.17.0",
|
||||
"@mantine/hooks": "^7.17.0",
|
||||
"@mantine/modals": "^7.17.0",
|
||||
"@mantine/notifications": "^7.17.0",
|
||||
"@mantine/spotlight": "^7.17.0",
|
||||
"@tabler/icons-react": "^3.22.0",
|
||||
"@tanstack/react-query": "^5.61.4",
|
||||
"@tiptap/extension-character-count": "^2.11.5",
|
||||
"axios": "^1.8.4",
|
||||
"@tabler/icons-react": "^3.34.0",
|
||||
"@tanstack/react-query": "^5.80.6",
|
||||
"@tiptap/extension-character-count": "^2.10.3",
|
||||
"alfaaz": "^1.1.0",
|
||||
"axios": "^1.9.0",
|
||||
"clsx": "^2.1.1",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"highlightjs-sap-abap": "^0.3.0",
|
||||
"i18next": "^23.14.0",
|
||||
"i18next-http-backend": "^2.6.1",
|
||||
"jotai": "^2.12.1",
|
||||
"jotai": "^2.12.5",
|
||||
"jotai-optics": "^0.4.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"katex": "0.16.21",
|
||||
"lowlight": "^3.2.0",
|
||||
"mermaid": "^11.4.1",
|
||||
"katex": "0.16.22",
|
||||
"lowlight": "^3.3.0",
|
||||
"mermaid": "^11.6.0",
|
||||
"mitt": "^3.0.1",
|
||||
"posthog-js": "^1.255.1",
|
||||
"react": "^18.3.1",
|
||||
"react-arborist": "3.4.0",
|
||||
"react-clear-modal": "^2.0.11",
|
||||
"react-clear-modal": "^2.0.15",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-drawio": "^1.0.1",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-i18next": "^15.0.1",
|
||||
"react-router-dom": "^7.0.1",
|
||||
"semver": "^7.7.1",
|
||||
"semver": "^7.7.2",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||
"zod": "^3.23.8"
|
||||
"zod": "^3.25.56"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.16.0",
|
||||
@ -77,6 +79,6 @@
|
||||
"prettier": "^3.4.1",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.17.0",
|
||||
"vite": "^6.3.2"
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@ -383,5 +383,8 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Öffentlich geteilte Seiten aus Bereichen, in denen Sie Mitglied sind, erscheinen hier",
|
||||
"Share deleted successfully": "Freigabe erfolgreich gelöscht",
|
||||
"Share not found": "Freigabe nicht gefunden",
|
||||
"Failed to share page": "Fehler beim Teilen der Seite"
|
||||
"Failed to share page": "Fehler beim Teilen der Seite",
|
||||
"Copy page": "Seite kopieren",
|
||||
"Copy page to a different space.": "Seite in einen anderen Bereich kopieren.",
|
||||
"Page copied successfully": "Seite erfolgreich kopiert"
|
||||
}
|
||||
|
||||
@ -354,6 +354,9 @@
|
||||
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
|
||||
"New update": "New update",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} is available",
|
||||
"Default page edit mode": "Default page edit mode",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Choose your preferred page edit mode. Avoid accidental edits.",
|
||||
"Reading": "Reading"
|
||||
"Delete member": "Delete member",
|
||||
"Member deleted successfully": "Member deleted successfully",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Are you sure you want to delete this workspace member? This action is irreversible.",
|
||||
@ -384,7 +387,7 @@
|
||||
"Share deleted successfully": "Share deleted successfully",
|
||||
"Share not found": "Share not found",
|
||||
"Failed to share page": "Failed to share page",
|
||||
"Copy page": "Copy page",
|
||||
"Copy page": "Copy page",
|
||||
"Copy page to a different space.": "Copy page to a different space.",
|
||||
"Page copied successfully": "Page copied successfully"
|
||||
}
|
||||
|
||||
@ -383,5 +383,8 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Las páginas compartidas públicamente de los espacios a los que pertenece aparecerán aquí",
|
||||
"Share deleted successfully": "Compartición eliminada con éxito",
|
||||
"Share not found": "Compartición no encontrada",
|
||||
"Failed to share page": "Error al compartir la página"
|
||||
"Failed to share page": "Error al compartir la página",
|
||||
"Copy page": "Copy page",
|
||||
"Copy page to a different space.": "Copy page to a different space.",
|
||||
"Page copied successfully": "Page copied successfully"
|
||||
}
|
||||
|
||||
@ -383,5 +383,8 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Les pages partagées publiquement des espaces dont vous êtes membre apparaîtront ici",
|
||||
"Share deleted successfully": "Partage supprimé avec succès",
|
||||
"Share not found": "Partage non trouvé",
|
||||
"Failed to share page": "Échec du partage de la page"
|
||||
"Failed to share page": "Échec du partage de la page",
|
||||
"Copy page": "Copier la page",
|
||||
"Copy page to a different space.": "Copier la page dans un autre espace.",
|
||||
"Page copied successfully": "Page copiée avec succès"
|
||||
}
|
||||
|
||||
@ -383,5 +383,8 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Le pagine condivise pubblicamente dagli spazi di cui sei membro appariranno qui",
|
||||
"Share deleted successfully": "Condivisione eliminata con successo",
|
||||
"Share not found": "Condivisione non trovata",
|
||||
"Failed to share page": "Condivisione della pagina fallita"
|
||||
"Failed to share page": "Condivisione della pagina fallita",
|
||||
"Copy page": "Copia pagina",
|
||||
"Copy page to a different space.": "Copia pagina in un altro spazio.",
|
||||
"Page copied successfully": "Pagina copiata con successo"
|
||||
}
|
||||
|
||||
@ -347,7 +347,7 @@
|
||||
"Members added successfully": "メンバーを追加しました",
|
||||
"Member removed successfully": "メンバーが削除されました",
|
||||
"Member role updated successfully": "メンバーのロールを更新しました",
|
||||
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
|
||||
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
|
||||
"Created at: {{time}}": "が作成しました:{{time}}",
|
||||
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
|
||||
"Word count: {{wordCount}}": "ワード数: {{wordCount}}",
|
||||
@ -383,5 +383,8 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます",
|
||||
"Share deleted successfully": "共有が正常に削除されました",
|
||||
"Share not found": "共有が見つかりません",
|
||||
"Failed to share page": "ページの共有に失敗しました"
|
||||
"Failed to share page": "ページの共有に失敗しました",
|
||||
"Copy page": "ページをコピー",
|
||||
"Copy page to a different space.": "ページを別のスペースにコピーします。",
|
||||
"Page copied successfully": "ページのコピーに成功しました"
|
||||
}
|
||||
|
||||
@ -58,7 +58,7 @@
|
||||
"Enter a strong password": "강력한 비밀번호를 입력하세요",
|
||||
"Enter valid email addresses separated by comma or space max_50": "유효한 이메일 주소를 쉼표나 공백으로 구분하여 입력하세요 [최대: 50]",
|
||||
"enter valid emails addresses": "유효한 이메일 주소를 입력하세요",
|
||||
"Enter your current password": "현재 비밀번호를 입력하세요",
|
||||
"Enter your current password": "기존 비밀번호를 입력하세요",
|
||||
"enter your full name": "전체 이름을 입력하세요",
|
||||
"Enter your new password": "새 비밀번호를 입력하세요",
|
||||
"Enter your new preferred email": "새로운 이메일을 입력하세요",
|
||||
@ -170,7 +170,7 @@
|
||||
"Successfully restored": "복원 완료",
|
||||
"System settings": "시스템 설정",
|
||||
"Theme": "배경",
|
||||
"To change your email, you have to enter your password and new email.": "이메일을 변경하려면 현재 비밀번호와 새 이메일을 입력해야 합니다.",
|
||||
"To change your email, you have to enter your password and new email.": "이메일을 변경하려면 기존 비밀번호와 새 이메일을 입력해야 합니다.",
|
||||
"Toggle full page width": "전체 페이지 너비 전환",
|
||||
"Unable to import pages. Please try again.": "페이지를 가져올 수 없습니다. 다시 시도해주세요.",
|
||||
"untitled": "제목 없음",
|
||||
@ -383,5 +383,8 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "회원인 공간의 공개 공유된 페이지가 여기에 표시됩니다",
|
||||
"Share deleted successfully": "공유가 성공적으로 삭제되었습니다",
|
||||
"Share not found": "공유를 찾을 수 없습니다",
|
||||
"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"
|
||||
}
|
||||
|
||||
@ -383,5 +383,8 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Openbaar gedeelde pagina's van ruimtes waarvan u lid bent, verschijnen hier",
|
||||
"Share deleted successfully": "Delen succesvol verwijderd",
|
||||
"Share not found": "Delen niet gevonden",
|
||||
"Failed to share page": "Pagina delen mislukt"
|
||||
"Failed to share page": "Pagina delen mislukt",
|
||||
"Copy page": "Copy page",
|
||||
"Copy page to a different space.": "Copy page to a different space.",
|
||||
"Page copied successfully": "Page copied successfully"
|
||||
}
|
||||
|
||||
@ -383,5 +383,8 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Páginas compartilhadas publicamente de espaços que você é membro aparecerão aqui",
|
||||
"Share deleted successfully": "Compartilhamento excluído com sucesso",
|
||||
"Share not found": "Compartilhamento não encontrado",
|
||||
"Failed to share page": "Falha ao compartilhar página"
|
||||
"Failed to share page": "Falha ao compartilhar página",
|
||||
"Copy page": "Copy page",
|
||||
"Copy page to a different space.": "Copy page to a different space.",
|
||||
"Page copied successfully": "Page copied successfully"
|
||||
}
|
||||
|
||||
@ -383,5 +383,8 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Общие страницы из пространств, участником которых вы являетесь, появятся здесь",
|
||||
"Share deleted successfully": "Общий доступ успешно удален",
|
||||
"Share not found": "Общий доступ не найден",
|
||||
"Failed to share page": "Не удалось поделиться страницей"
|
||||
"Failed to share page": "Не удалось поделиться страницей",
|
||||
"Copy page": "Копировать страницу",
|
||||
"Copy page to a different space.": "Копировать страницу в другое пространство.",
|
||||
"Page copied successfully": "Страница успешно скопирована"
|
||||
}
|
||||
|
||||
390
apps/client/public/locales/uk-UA/translation.json
Normal file
390
apps/client/public/locales/uk-UA/translation.json
Normal file
@ -0,0 +1,390 @@
|
||||
{
|
||||
"Account": "Обліковий запис",
|
||||
"Active": "Активний",
|
||||
"Add": "Додати",
|
||||
"Add group members": "Додати учасників групи",
|
||||
"Add groups": "Додати групи",
|
||||
"Add members": "Додати учасників",
|
||||
"Add to groups": "Додати до груп",
|
||||
"Add space members": "Додати учасників простору",
|
||||
"Admin": "Адміністратор",
|
||||
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Ви впевнені, що хочете видалити цю групу? Учасники втратять доступ до матеріалів, до яких ця група має доступ.",
|
||||
"Are you sure you want to delete this page?": "Ви впевнені, що хочете видалити цю сторінку?",
|
||||
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Ви впевнені, що хочете видалити цього користувача з групи? Користувач втратить доступ до матеріалів, до яких ця група має доступ.",
|
||||
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Ви впевнені, що хочете видалити цього користувача з простору? Користувач втратить весь доступ до цього простору.",
|
||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Ви впевнені, що хочете відновити цю версію? Усі не збережені зміни будуть втрачені.",
|
||||
"Can become members of groups and spaces in workspace": "Можуть ставати учасниками груп та просторів у робочій області",
|
||||
"Can create and edit pages in space.": "Може створювати та редагувати сторінки в просторі.",
|
||||
"Can edit": "Може редагувати",
|
||||
"Can manage workspace": "Може керувати робочою областю",
|
||||
"Can manage workspace but cannot delete it": "Може керувати робочою областю, але не може її видалити",
|
||||
"Can view": "Може переглядати",
|
||||
"Can view pages in space but not edit.": "Може переглядати сторінки в просторі, але не може їх редагувати.",
|
||||
"Cancel": "Скасувати",
|
||||
"Change email": "Змінити електронну пошту",
|
||||
"Change password": "Змінити пароль",
|
||||
"Change photo": "Змінити фото",
|
||||
"Choose a role": "Оберіть роль",
|
||||
"Choose your preferred color scheme.": "Оберіть бажану кольорову схему.",
|
||||
"Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.",
|
||||
"Choose your preferred page width.": "Оберіть бажану ширину сторінки.",
|
||||
"Confirm": "Підтвердити",
|
||||
"Copy link": "Копіювати посилання",
|
||||
"Create": "Створити",
|
||||
"Create group": "Створити групу",
|
||||
"Create page": "Створити сторінку",
|
||||
"Create space": "Створити простір",
|
||||
"Create workspace": "Створити робочу область",
|
||||
"Current password": "Поточний пароль",
|
||||
"Dark": "Темна",
|
||||
"Date": "Дата",
|
||||
"Delete": "Видалити",
|
||||
"Delete group": "Видалити групу",
|
||||
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Ви впевнені, що хочете видалити цю сторінку? Це видалить її дочірні сторінки, а також історію сторінки. Ця дія необоротна.",
|
||||
"Description": "Опис",
|
||||
"Details": "Деталі",
|
||||
"e.g ACME": "наприклад, ACME",
|
||||
"e.g ACME Inc": "наприклад, ACME Inc",
|
||||
"e.g Developers": "наприклад, Розробники",
|
||||
"e.g Group for developers": "наприклад, Група для розробників",
|
||||
"e.g product": "наприклад, продукт",
|
||||
"e.g Product Team": "наприклад, Продуктова команда",
|
||||
"e.g Sales": "наприклад, Продажі",
|
||||
"e.g Space for product team": "наприклад, Простір для продуктової команди",
|
||||
"e.g Space for sales team to collaborate": "наприклад, Простір для спільної роботи команди продажів",
|
||||
"Edit": "Редагувати",
|
||||
"Edit group": "Редагувати групу",
|
||||
"Email": "Електронна пошта",
|
||||
"Enter a strong password": "Введіть надійний пароль",
|
||||
"Enter valid email addresses separated by comma or space max_50": "Введіть дійсні адреси електронної пошти, розділені комою або пробілом [макс: 50]",
|
||||
"enter valid emails addresses": "введіть дійсні адреси електронної пошти",
|
||||
"Enter your current password": "Введіть ваш поточний пароль",
|
||||
"enter your full name": "введіть ваше повне ім'я",
|
||||
"Enter your new password": "Введіть ваш новий пароль",
|
||||
"Enter your new preferred email": "Введіть вашу нову бажану електронну пошту",
|
||||
"Enter your password": "Введіть ваш пароль",
|
||||
"Error fetching page data.": "Помилка при завантаженні даних сторінки.",
|
||||
"Error loading page history.": "Помилка при завантаженні історії сторінки.",
|
||||
"Export": "Експорт",
|
||||
"Failed to create page": "Не вдалося створити сторінку",
|
||||
"Failed to delete page": "Не вдалося видалити сторінку",
|
||||
"Failed to fetch recent pages": "Не вдалося отримати нещодавні сторінки",
|
||||
"Failed to import pages": "Не вдалося імпортувати сторінки",
|
||||
"Failed to load page. An error occurred.": "Не вдалося завантажити сторінку. Сталася помилка.",
|
||||
"Failed to update data": "Не вдалося оновити дані",
|
||||
"Full access": "Повний доступ",
|
||||
"Full page width": "Ширина на всю сторінку",
|
||||
"Full width": "На всю ширину",
|
||||
"General": "Загальні",
|
||||
"Group": "Група",
|
||||
"Group description": "Опис групи",
|
||||
"Group name": "Назва групи",
|
||||
"Groups": "Групи",
|
||||
"Has full access to space settings and pages.": "Має повний доступ до налаштувань простору та сторінок.",
|
||||
"Home": "Головна",
|
||||
"Import pages": "Імпорт сторінок",
|
||||
"Import pages & space settings": "Імпорт сторінок і налаштування простору",
|
||||
"Importing pages": "Імпортування сторінок",
|
||||
"invalid invitation link": "посилання на запрошення недійсне",
|
||||
"Invitation signup": "Реєстрація за запрошенням",
|
||||
"Invite by email": "Запросити електронною поштою",
|
||||
"Invite members": "Запросити учасників",
|
||||
"Invite new members": "Запросити нових учасників",
|
||||
"Invited members who are yet to accept their invitation will appear here.": "Запрошені учасники, які ще не прийняли запрошення, з'являться тут.",
|
||||
"Invited members will be granted access to spaces the groups can access": "Запрошені учасники отримають доступ до просторів, доступ до яких має група",
|
||||
"Join the workspace": "Приєднатися до робочої області",
|
||||
"Language": "Мова",
|
||||
"Light": "Світла",
|
||||
"Link copied": "Посилання скопійовано",
|
||||
"Login": "Увійти",
|
||||
"Logout": "Вийти",
|
||||
"Manage Group": "Керування групою",
|
||||
"Manage members": "Керування учасниками",
|
||||
"member": "учасник",
|
||||
"Member": "Учасник",
|
||||
"members": "учасники",
|
||||
"Members": "Учасники",
|
||||
"My preferences": "Мої налаштування",
|
||||
"My Profile": "Мій профіль",
|
||||
"My profile": "Мій профіль",
|
||||
"Name": "Ім'я",
|
||||
"New email": "Нова електронна адреса",
|
||||
"New page": "Нова сторінка",
|
||||
"New password": "Новий пароль",
|
||||
"No group found": "Групу не знайдено",
|
||||
"No page history saved yet.": "Історія сторінок ще не збережена.",
|
||||
"No pages yet": "Сторінок поки немає",
|
||||
"No results found...": "Результати не знайдено...",
|
||||
"No user found": "Користувача не знайдено",
|
||||
"Overview": "Огляд",
|
||||
"Owner": "Власник",
|
||||
"page": "сторінка",
|
||||
"Page deleted successfully": "Сторінку успішно видалено",
|
||||
"Page history": "Історія сторінки",
|
||||
"Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.",
|
||||
"Pages": "Сторінки",
|
||||
"pages": "сторінки",
|
||||
"Password": "Пароль",
|
||||
"Password changed successfully": "Пароль успішно змінено",
|
||||
"Pending": "В очікуванні",
|
||||
"Please confirm your action": "Будь ласка, підтвердіть вашу дію",
|
||||
"Preferences": "Налаштування",
|
||||
"Print PDF": "Друк PDF",
|
||||
"Profile": "Профіль",
|
||||
"Recently updated": "Нещодавно оновлено",
|
||||
"Remove": "Видалити",
|
||||
"Remove group member": "Видалити учасника групи",
|
||||
"Remove space member": "Видалити учасника простору",
|
||||
"Restore": "Відновити",
|
||||
"Role": "Роль",
|
||||
"Save": "Зберегти",
|
||||
"Search": "Пошук",
|
||||
"Search for groups": "Пошук груп",
|
||||
"Search for users": "Пошук користувачів",
|
||||
"Search for users and groups": "Пошук користувачів та груп",
|
||||
"Search...": "Пошук...",
|
||||
"Select language": "Оберіть мову",
|
||||
"Select role": "Оберіть роль",
|
||||
"Select role to assign to all invited members": "Оберіть роль для всіх запрошених учасників",
|
||||
"Select theme": "Оберіть тему",
|
||||
"Send invitation": "Надіслати запрошення",
|
||||
"Invitation sent": "Запрошення надіслано",
|
||||
"Settings": "Налаштування",
|
||||
"Setup workspace": "Налаштувати робочу область",
|
||||
"Sign In": "Вхід",
|
||||
"Sign Up": "Реєстрація",
|
||||
"Slug": "Slug",
|
||||
"Space": "Простір",
|
||||
"Space description": "Опис простору",
|
||||
"Space menu": "Меню простору",
|
||||
"Space name": "Назва простору",
|
||||
"Space settings": "Налаштування простору",
|
||||
"Space slug": "Slug простору",
|
||||
"Spaces": "Простори",
|
||||
"Spaces you belong to": "Простори, до яких ви належите",
|
||||
"No space found": "Простори не знайдено",
|
||||
"Search for spaces": "Пошук просторів",
|
||||
"Start typing to search...": "Почніть вводити для пошуку...",
|
||||
"Status": "Статус",
|
||||
"Successfully imported": "Успішно імпортовано",
|
||||
"Successfully restored": "Успішно відновлено",
|
||||
"System settings": "Системні налаштування",
|
||||
"Theme": "Тема",
|
||||
"To change your email, you have to enter your password and new email.": "Щоб змінити електронну пошту, вам потрібно ввести пароль і нову адресу.",
|
||||
"Toggle full page width": "Перемкнути ширину на всю сторінку",
|
||||
"Unable to import pages. Please try again.": "Не вдалося імпортувати сторінки. Будь ласка, спробуйте ще раз.",
|
||||
"untitled": "без назви",
|
||||
"Untitled": "Без назви",
|
||||
"Updated successfully": "Оновлено успішно",
|
||||
"User": "Користувач",
|
||||
"Workspace": "Робоча область",
|
||||
"Workspace Name": "Ім'я робочої області",
|
||||
"Workspace settings": "Налаштування робочої області",
|
||||
"You can change your password here.": "Ви можете змінити свій пароль тут.",
|
||||
"Your Email": "Ваша електронна пошта",
|
||||
"Your import is complete.": "Ваш імпорт завершено.",
|
||||
"Your name": "Ваше ім'я",
|
||||
"Your Name": "Ваше ім'я",
|
||||
"Your password": "Ваш пароль",
|
||||
"Your password must be a minimum of 8 characters.": "Ваш пароль повинен містити мінімум 8 символів.",
|
||||
"Sidebar toggle": "Перемкнути бічну панель",
|
||||
"Comments": "Коментарі",
|
||||
"404 page not found": "404 сторінку не знайдено",
|
||||
"Sorry, we can't find the page you are looking for.": "На жаль, ми не можемо знайти сторінку, яку ви шукаєте.",
|
||||
"Take me back to homepage": "Повернутися на головну сторінку",
|
||||
"Forgot password": "Забули пароль",
|
||||
"Forgot your password?": "Забули пароль?",
|
||||
"A password reset link has been sent to your email. Please check your inbox.": "Посилання для скидання пароля було надіслано на вашу електронну адресу. Будь ласка, перевірте вхідні повідомлення.",
|
||||
"Send reset link": "Надіслати посилання для скидання",
|
||||
"Password reset": "Скидання пароля",
|
||||
"Your new password": "Ваш новий пароль",
|
||||
"Set password": "Встановити пароль",
|
||||
"Write a comment": "Написати коментар",
|
||||
"Reply...": "Відповісти...",
|
||||
"Error loading comments.": "Помилка при завантаженні коментарів.",
|
||||
"No comments yet.": "Коментарів поки немає.",
|
||||
"Edit comment": "Редагувати коментар",
|
||||
"Delete comment": "Видалити коментар",
|
||||
"Are you sure you want to delete this comment?": "Ви впевнені, що хочете видалити цей коментар?",
|
||||
"Comment created successfully": "Коментар успішно створено",
|
||||
"Error creating comment": "Помилка при створенні коментаря",
|
||||
"Comment updated successfully": "Коментар успішно оновлено",
|
||||
"Failed to update comment": "Не вдалося оновити коментар",
|
||||
"Comment deleted successfully": "Коментар успішно видалено",
|
||||
"Failed to delete comment": "Не вдалося видалити коментар",
|
||||
"Comment resolved successfully": "Коментар успішно вирішено",
|
||||
"Failed to resolve comment": "Не вдалося вирішити коментар",
|
||||
"Revoke invitation": "Відкликати запрошення",
|
||||
"Revoke": "Відкликати",
|
||||
"Don't": "Ні",
|
||||
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Ви впевнені, що хочете відкликати це запрошення? Користувач не зможе приєднатися до робочої області.",
|
||||
"Resend invitation": "Надіслати запрошення повторно",
|
||||
"Anyone with this link can join this workspace.": "Будь-хто, хто має це посилання, може приєднатися до цієї робочої області.",
|
||||
"Invite link": "Посилання для запрошення",
|
||||
"Copy": "Копіювати",
|
||||
"Copied": "Скопійовано",
|
||||
"Select a user": "Оберіть користувача",
|
||||
"Select a group": "Оберіть групу",
|
||||
"Export all pages and attachments in this space.": "Експортувати всі сторінки та вкладення в цьому просторі.",
|
||||
"Delete space": "Видалити простір",
|
||||
"Are you sure you want to delete this space?": "Ви впевнені, що хочете видалити цей простір?",
|
||||
"Delete this space with all its pages and data.": "Видалити цей простір з усіма його сторінками та даними.",
|
||||
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "Усі сторінки, коментарі, вкладення та дозволи в цьому просторі будуть видалені безповоротно.",
|
||||
"Confirm space name": "Підтвердіть назву простору",
|
||||
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "Введіть назву простору <b>{{spaceName}}</b>, щоб підтвердити вашу дію.",
|
||||
"Format": "Формат",
|
||||
"Include subpages": "Включити вкладені сторінки",
|
||||
"Include attachments": "Включити вкладення",
|
||||
"Select export format": "Виберіть формат експорту",
|
||||
"Export failed:": "Експортування не вдалося:",
|
||||
"export error": "помилка експорту",
|
||||
"Export page": "Експорт сторінки",
|
||||
"Export space": "Експорт простору",
|
||||
"Export {{type}}": "Експорт {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
|
||||
"Align left": "По лівому краю",
|
||||
"Align right": "По правому краю",
|
||||
"Align center": "По центру",
|
||||
"Justify": "По ширині",
|
||||
"Merge cells": "Об'єднати комірки",
|
||||
"Split cell": "Розділити комірку",
|
||||
"Delete column": "Видалити стовпець",
|
||||
"Delete row": "Видалити рядок",
|
||||
"Add left column": "Додати стовпець ліворуч",
|
||||
"Add right column": "Додати стовпець праворуч",
|
||||
"Add row above": "Додати рядок вище",
|
||||
"Add row below": "Додати рядок нижче",
|
||||
"Delete table": "Видалити таблицю",
|
||||
"Info": "Інформація",
|
||||
"Success": "Успішно",
|
||||
"Warning": "Попередження",
|
||||
"Danger": "Важливо",
|
||||
"Mermaid diagram error:": "Помилка діаграми Mermaid:",
|
||||
"Invalid Mermaid diagram": "Неприпустима діаграма Mermaid",
|
||||
"Double-click to edit Draw.io diagram": "Клацніть двічі для редагування діаграми Draw.io",
|
||||
"Exit": "Вийти",
|
||||
"Save & Exit": "Зберегти та вийти",
|
||||
"Double-click to edit Excalidraw diagram": "Клацніть двічі для редагування діаграми Excalidraw",
|
||||
"Paste link": "Вставити посилання",
|
||||
"Edit link": "Редагувати посилання",
|
||||
"Remove link": "Видалити посилання",
|
||||
"Add link": "Додати посилання",
|
||||
"Please enter a valid url": "Будь ласка, введіть коректний url",
|
||||
"Empty equation": "Порожнє рівняння",
|
||||
"Invalid equation": "Неприпустиме рівняння",
|
||||
"Color": "Колір",
|
||||
"Text color": "Колір тексту",
|
||||
"Default": "За замовчуванням",
|
||||
"Blue": "Синій",
|
||||
"Green": "Зелений",
|
||||
"Purple": "Фіолетовий",
|
||||
"Red": "Червоний",
|
||||
"Yellow": "Жовтий",
|
||||
"Orange": "Помаранчевий",
|
||||
"Pink": "Рожевий",
|
||||
"Gray": "Сірий",
|
||||
"Embed link": "Вбудоване посилання",
|
||||
"Invalid {{provider}} embed link": "Невірне посилання для вбудовування {{provider}}",
|
||||
"Embed {{provider}}": "Вбудувати {{provider}}",
|
||||
"Enter {{provider}} link to embed": "Введіть посилання для вбудовування {{provider}}",
|
||||
"Bold": "Жирний",
|
||||
"Italic": "Курсив",
|
||||
"Underline": "Підкреслений",
|
||||
"Strike": "Закреслений",
|
||||
"Code": "Код",
|
||||
"Comment": "Коментар",
|
||||
"Text": "Текст",
|
||||
"Heading 1": "Заголовок 1",
|
||||
"Heading 2": "Заголовок 2",
|
||||
"Heading 3": "Заголовок 3",
|
||||
"To-do List": "Список справ",
|
||||
"Bullet List": "Маркований список",
|
||||
"Numbered List": "Нумерований список",
|
||||
"Blockquote": "Блок цитування",
|
||||
"Just start typing with plain text.": "Просто почніть друкувати звичайний текст.",
|
||||
"Track tasks with a to-do list.": "Відстежуйте завдання за допомогою списку справ.",
|
||||
"Big section heading.": "Великий заголовок розділу.",
|
||||
"Medium section heading.": "Середній заголовок розділу.",
|
||||
"Small section heading.": "Малий заголовок розділу.",
|
||||
"Create a simple bullet list.": "Створити простий маркований список.",
|
||||
"Create a list with numbering.": "Створити нумерований список.",
|
||||
"Create block quote.": "Створити блок цитування.",
|
||||
"Insert code snippet.": "Вставити фрагмент коду.",
|
||||
"Insert horizontal rule divider": "Вставити горизонтальний роздільник",
|
||||
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
|
||||
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
|
||||
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
|
||||
"Table": "Таблиця",
|
||||
"Insert a table.": "Вставити таблицю.",
|
||||
"Insert collapsible block.": "Вставити блок, що згортається.",
|
||||
"Video": "Відео",
|
||||
"Divider": "Роздільник",
|
||||
"Quote": "Цитата",
|
||||
"Image": "Зображення",
|
||||
"File attachment": "Прикріплений файл",
|
||||
"Toggle block": "Блок, що згортається",
|
||||
"Callout": "Виноска",
|
||||
"Insert callout notice.": "Вставити виноску з повідомленням.",
|
||||
"Math inline": "Формула",
|
||||
"Insert inline math equation.": "Вставити математичне рівняння в рядок.",
|
||||
"Math block": "Блок формул",
|
||||
"Insert math equation": "Вставити математичне рівняння",
|
||||
"Mermaid diagram": "Діаграма Mermaid",
|
||||
"Insert mermaid diagram": "Вставити діаграму Mermaid",
|
||||
"Insert and design Drawio diagrams": "Вставити та розробити діаграми Draw.io",
|
||||
"Insert current date": "Вставити поточну дату",
|
||||
"Draw and sketch excalidraw diagrams": "Вставити та малювати діаграми Excalidraw",
|
||||
"Multiple": "Декілька",
|
||||
"Heading {{level}}": "Заголовок {{level}}",
|
||||
"Toggle title": "Перемкнути заголовок",
|
||||
"Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд",
|
||||
"Names do not match": "Назви не співпадають",
|
||||
"Today, {{time}}": "Сьогодні, {{time}}",
|
||||
"Yesterday, {{time}}": "Вчора, {{time}}",
|
||||
"Space created successfully": "Простір успішно створено",
|
||||
"Space updated successfully": "Простір успішно оновлено",
|
||||
"Space deleted successfully": "Простір успішно видалено",
|
||||
"Members added successfully": "Учасників успішно додано",
|
||||
"Member removed successfully": "Учасника успішно видалено",
|
||||
"Member role updated successfully": "Роль учасника успішно оновлено",
|
||||
"Created by: <b>{{creatorName}}</b>": "Автор: <b>{{creatorName}}</b>",
|
||||
"Created at: {{time}}": "Дата створення: {{time}}",
|
||||
"Edited by {{name}} {{time}}": "Змінено {{name}} {{time}}",
|
||||
"Word count: {{wordCount}}": "Кількість слів: {{wordCount}}",
|
||||
"Character count: {{characterCount}}": "Кількість символів: {{characterCount}}",
|
||||
"New update": "Нове оновлення",
|
||||
"{{latestVersion}} is available": "Доступна нова версія {{latestVersion}}",
|
||||
"Delete member": "Видалити учасника",
|
||||
"Member deleted successfully": "Учасника успішно видалено",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Ви впевнені, що хочете видалити цього учасника робочої області? Ця дія незворотна.",
|
||||
"Move": "Перемістити",
|
||||
"Move page": "Перемістити сторінку",
|
||||
"Move page to a different space.": "Перемістити сторінку в інший простір.",
|
||||
"Real-time editor connection lost. Retrying...": "З'єднання з редактором у реальному часі втрачено. Повторна спроба...",
|
||||
"Table of contents": "Зміст",
|
||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Додайте заголовки (H1, H2, H3), щоб створити зміст.",
|
||||
"Share": "Поділитися",
|
||||
"Public sharing": "Публічний доступ",
|
||||
"Shared by": "Поділився",
|
||||
"Shared at": "Поділився в",
|
||||
"Inherits public sharing from": "Успадковує публічний доступ від",
|
||||
"Share to web": "Поділитися в інтернеті",
|
||||
"Shared to web": "Розміщено в інтернеті",
|
||||
"Anyone with the link can view this page": "Будь-хто, хто має посилання, може переглянути цю сторінку",
|
||||
"Make this page publicly accessible": "Зробити цю сторінку загальнодоступною",
|
||||
"Include sub-pages": "Включити підсторінки",
|
||||
"Make sub-pages public too": "Зробити підсторінки також загальнодоступними",
|
||||
"Allow search engines to index page": "Дозволити пошуковим системам індексувати сторінку",
|
||||
"Open page": "Відкрити сторінку",
|
||||
"Page": "Сторінка",
|
||||
"Delete public share link": "Видалити посилання на публічний доступ",
|
||||
"Delete share": "Видалити спільний доступ",
|
||||
"Are you sure you want to delete this shared link?": "Ви впевнені, що хочете видалити це посилання спільного доступу?",
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Публічні сторінки з просторів, учасником яких ви є, з'являться тут",
|
||||
"Share deleted successfully": "Спільний доступ успішно видалено",
|
||||
"Share not found": "Спільний доступ не знайдено",
|
||||
"Failed to share page": "Не вдалося поділитися сторінкою",
|
||||
"Copy page": "Копіювати сторінки",
|
||||
"Copy page to a different space.": "Скопіювати сторінку в інший простір.",
|
||||
"Page copied successfully": "Сторінку успішно скопійовано"
|
||||
}
|
||||
@ -383,5 +383,8 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "您所在空间的公开共享页面会显示在此处",
|
||||
"Share deleted successfully": "分享已成功删除",
|
||||
"Share not found": "未找到分享",
|
||||
"Failed to share page": "页面分享失败"
|
||||
"Failed to share page": "页面分享失败",
|
||||
"Copy page": "复制页面",
|
||||
"Copy page to a different space.": "将页面复制到不同的空间。",
|
||||
"Page copied successfully": "页面复制成功"
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
||||
import { PosthogUser } from "@/ee/components/posthog-user.tsx";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
@ -8,6 +10,7 @@ export default function Layout() {
|
||||
<GlobalAppShell>
|
||||
<Outlet />
|
||||
</GlobalAppShell>
|
||||
{isCloud() && <PosthogUser />}
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -30,12 +30,12 @@ export default function BillingDetails() {
|
||||
>
|
||||
Plan
|
||||
</Text>
|
||||
<Text fw={700} fz="lg">
|
||||
{
|
||||
plans.find(
|
||||
(plan) => plan.productId === billing.stripeProductId,
|
||||
)?.name
|
||||
}
|
||||
<Text fw={700} fz="lg" tt="capitalize">
|
||||
{plans.find(
|
||||
(plan) => plan.productId === billing.stripeProductId,
|
||||
)?.name ||
|
||||
billing.planName ||
|
||||
"Standard"}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
@ -112,18 +112,58 @@ export default function BillingDetails() {
|
||||
fz="xs"
|
||||
className={classes.label}
|
||||
>
|
||||
Total
|
||||
</Text>
|
||||
<Text fw={700} fz="lg">
|
||||
{(billing.amount / 100) * billing.quantity}{" "}
|
||||
{billing.currency.toUpperCase()}
|
||||
</Text>
|
||||
<Text c="dimmed" fz="sm">
|
||||
${billing.amount / 100} /user/{billing.interval}
|
||||
Cost
|
||||
</Text>
|
||||
{billing.billingScheme === "tiered" && (
|
||||
<>
|
||||
<Text fw={700} fz="lg">
|
||||
${billing.amount / 100} {billing.currency.toUpperCase()}
|
||||
</Text>
|
||||
<Text c="dimmed" fz="sm">
|
||||
per {billing.interval}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{billing.billingScheme !== "tiered" && (
|
||||
<>
|
||||
<Text fw={700} fz="lg">
|
||||
{(billing.amount / 100) * billing.quantity}{" "}
|
||||
{billing.currency.toUpperCase()}
|
||||
</Text>
|
||||
<Text c="dimmed" fz="sm">
|
||||
${billing.amount / 100} /user/{billing.interval}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{billing.billingScheme === "tiered" && billing.tieredUpTo && (
|
||||
<Paper p="md" radius="md">
|
||||
<Group justify="apart">
|
||||
<div>
|
||||
<Text
|
||||
c="dimmed"
|
||||
tt="uppercase"
|
||||
fw={700}
|
||||
fz="xs"
|
||||
className={classes.label}
|
||||
>
|
||||
Current Tier
|
||||
</Text>
|
||||
<Text fw={700} fz="lg">
|
||||
For {billing.tieredUpTo} users
|
||||
</Text>
|
||||
{/*billing.tieredFlatAmount && (
|
||||
<Text c="dimmed" fz="sm">
|
||||
</Text>
|
||||
)*/}
|
||||
</div>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -2,24 +2,28 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
List,
|
||||
SegmentedControl,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Text,
|
||||
Group,
|
||||
Select,
|
||||
Container,
|
||||
Stack,
|
||||
Badge,
|
||||
Flex,
|
||||
Switch,
|
||||
} from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
||||
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
||||
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
||||
|
||||
export default function BillingPlans() {
|
||||
const { data: plans } = useBillingPlans();
|
||||
const [interval, setInterval] = useState("yearly");
|
||||
|
||||
if (!plans) {
|
||||
return null;
|
||||
}
|
||||
const [isAnnual, setIsAnnual] = useState(true);
|
||||
const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleCheckout = async (priceId: string) => {
|
||||
try {
|
||||
@ -32,84 +36,153 @@ export default function BillingPlans() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!plans || plans.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstPlan = plans[0];
|
||||
|
||||
// Set initial tier value if not set
|
||||
if (!selectedTierValue && firstPlan.pricingTiers.length > 0) {
|
||||
setSelectedTierValue(firstPlan.pricingTiers[0].upTo.toString());
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!selectedTierValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectData = firstPlan.pricingTiers
|
||||
.filter((tier) => !tier.custom)
|
||||
.map((tier, index) => {
|
||||
const prevMaxUsers =
|
||||
index > 0 ? firstPlan.pricingTiers[index - 1].upTo : 0;
|
||||
return {
|
||||
value: tier.upTo.toString(),
|
||||
label: `${prevMaxUsers + 1}-${tier.upTo} users`,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Group justify="center" p="xl">
|
||||
{plans.map((plan) => {
|
||||
const price =
|
||||
interval === "monthly" ? plan.price.monthly : plan.price.yearly;
|
||||
const priceId = interval === "monthly" ? plan.monthlyId : plan.yearlyId;
|
||||
const yearlyMonthPrice = parseInt(plan.price.yearly) / 12;
|
||||
<Container size="xl" py="xl">
|
||||
{/* Controls Section */}
|
||||
<Stack gap="xl" mb="md">
|
||||
{/* Team Size and Billing Controls */}
|
||||
<Group justify="center" align="center" gap="sm">
|
||||
<Select
|
||||
label="Team size"
|
||||
description="Select the number of users"
|
||||
value={selectedTierValue}
|
||||
onChange={setSelectedTierValue}
|
||||
data={selectData}
|
||||
w={250}
|
||||
size="md"
|
||||
allowDeselect={false}
|
||||
/>
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan.name}
|
||||
withBorder
|
||||
radius="md"
|
||||
shadow="sm"
|
||||
p="xl"
|
||||
w={300}
|
||||
>
|
||||
<SegmentedControl
|
||||
value={interval}
|
||||
onChange={setInterval}
|
||||
fullWidth
|
||||
data={[
|
||||
{ label: "Monthly", value: "monthly" },
|
||||
{ label: "Yearly (25% OFF)", value: "yearly" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Title order={3} ta="center" mt="sm" mb="xs">
|
||||
{plan.name}
|
||||
</Title>
|
||||
<Text ta="center" size="lg" fw={700}>
|
||||
{interval === "monthly" && (
|
||||
<>
|
||||
${price}{" "}
|
||||
<Text span size="sm" fw={500} c="dimmed">
|
||||
/user/month
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{interval === "yearly" && (
|
||||
<>
|
||||
${yearlyMonthPrice}{" "}
|
||||
<Text span size="sm" fw={500} c="dimmed">
|
||||
/user/month
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<br/>
|
||||
<Text span ta="center" size="md" fw={500} c="dimmed">
|
||||
billed {interval}
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
<Card.Section mt="lg">
|
||||
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
||||
Subscribe
|
||||
</Button>
|
||||
</Card.Section>
|
||||
|
||||
<Card.Section mt="md">
|
||||
<List
|
||||
spacing="xs"
|
||||
<Group justify="center" align="start">
|
||||
<Flex justify="center" gap="md" align="center">
|
||||
<Text size="md">Monthly</Text>
|
||||
<Switch
|
||||
defaultChecked={isAnnual}
|
||||
onChange={(event) => setIsAnnual(event.target.checked)}
|
||||
size="sm"
|
||||
center
|
||||
icon={
|
||||
<ThemeIcon variant="light" size={24} radius="xl">
|
||||
<IconCheck size={16} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
{plan.features.map((feature, index) => (
|
||||
<List.Item key={index}>{feature}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
/>
|
||||
<Text size="md">
|
||||
Annually
|
||||
<Badge component="span" variant="light" color="blue">
|
||||
15% OFF
|
||||
</Badge>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{/* Plans Grid */}
|
||||
<Group justify="center" gap="lg" align="stretch">
|
||||
{plans.map((plan, index) => {
|
||||
const tieredPlan = plan;
|
||||
const planSelectedTier =
|
||||
tieredPlan.pricingTiers.find(
|
||||
(tier) => tier.upTo.toString() === selectedTierValue,
|
||||
) || tieredPlan.pricingTiers[0];
|
||||
|
||||
const price = isAnnual
|
||||
? planSelectedTier.yearly
|
||||
: planSelectedTier.monthly;
|
||||
const priceId = isAnnual ? plan.yearlyId : plan.monthlyId;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan.name}
|
||||
withBorder
|
||||
radius="lg"
|
||||
shadow="sm"
|
||||
p="xl"
|
||||
w={350}
|
||||
miw={300}
|
||||
style={{
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
{/* Plan Header */}
|
||||
<Stack gap="xs">
|
||||
<Title order={3} size="h4">
|
||||
{plan.name}
|
||||
</Title>
|
||||
{plan.description && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{plan.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Pricing */}
|
||||
<Stack gap="xs">
|
||||
<Group align="baseline" gap="xs">
|
||||
<Title order={1} size="h1">
|
||||
${isAnnual ? (price / 12).toFixed(0) : price}
|
||||
</Title>
|
||||
<Text size="lg" c="dimmed">
|
||||
per {isAnnual ? "month" : "month"}
|
||||
</Text>
|
||||
</Group>
|
||||
{isAnnual && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Billed annually
|
||||
</Text>
|
||||
)}
|
||||
<Text size="md" fw={500}>
|
||||
For {planSelectedTier.upTo} users
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
||||
Upgrade
|
||||
</Button>
|
||||
|
||||
{/* Features */}
|
||||
<List
|
||||
spacing="xs"
|
||||
size="sm"
|
||||
icon={
|
||||
<ThemeIcon size={20} radius="xl">
|
||||
<IconCheck size={14} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
{plan.features.map((feature, featureIndex) => (
|
||||
<List.Item key={featureIndex}>{feature}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@ -25,6 +25,11 @@ export interface IBilling {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt: Date;
|
||||
billingScheme: string | null;
|
||||
tieredUpTo: string | null;
|
||||
tieredFlatAmount: number | null;
|
||||
tieredUnitAmount: number | null;
|
||||
planName: string | null;
|
||||
}
|
||||
|
||||
export interface ICheckoutLink {
|
||||
@ -42,9 +47,18 @@ export interface IBillingPlan {
|
||||
monthlyId: string;
|
||||
yearlyId: string;
|
||||
currency: string;
|
||||
price: {
|
||||
price?: {
|
||||
monthly: string;
|
||||
yearly: string;
|
||||
};
|
||||
features: string[];
|
||||
billingScheme: string | null;
|
||||
pricingTiers: PricingTier[];
|
||||
}
|
||||
|
||||
interface PricingTier {
|
||||
upTo: number;
|
||||
monthly?: number;
|
||||
yearly?: number;
|
||||
custom?: boolean;
|
||||
}
|
||||
41
apps/client/src/ee/components/posthog-user.tsx
Normal file
41
apps/client/src/ee/components/posthog-user.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { useEffect } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
|
||||
export function PosthogUser() {
|
||||
const posthog = usePostHog();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
const user = currentUser?.user;
|
||||
const workspace = currentUser?.workspace;
|
||||
if (!user || !workspace) return;
|
||||
|
||||
posthog?.identify(user.id, {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
workspaceId: user.workspaceId,
|
||||
workspaceHostname: workspace.hostname,
|
||||
lastActiveAt: new Date().toISOString(),
|
||||
createdAt: user.createdAt,
|
||||
source: "docmost-app",
|
||||
});
|
||||
posthog?.group("workspace", workspace.id, {
|
||||
name: workspace.name,
|
||||
hostname: workspace.hostname,
|
||||
plan: workspace?.plan,
|
||||
status: workspace.status,
|
||||
isOnTrial: !!workspace.trialEndAt,
|
||||
hasStripeCustomerId: !!workspace.stripeCustomerId,
|
||||
memberCount: workspace.memberCount,
|
||||
lastActiveAt: new Date().toISOString(),
|
||||
createdAt: workspace.createdAt,
|
||||
source: "docmost-app",
|
||||
});
|
||||
}
|
||||
}, [posthog, currentUser]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -18,6 +18,7 @@ import classes from "@/features/auth/components/auth.module.css";
|
||||
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SsoLogin from "@/ee/components/sso-login.tsx";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
@ -71,39 +72,43 @@ export function InviteSignUpForm() {
|
||||
{t("Join the workspace")}
|
||||
</Title>
|
||||
|
||||
<Stack align="stretch" justify="center" gap="xl">
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="name"
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
placeholder={t("enter your full name")}
|
||||
variant="filled"
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<SsoLogin />
|
||||
|
||||
<TextInput
|
||||
id="email"
|
||||
type="email"
|
||||
label={t("Email")}
|
||||
value={invitation.email}
|
||||
disabled
|
||||
variant="filled"
|
||||
mt="md"
|
||||
/>
|
||||
{!invitation.enforceSso && (
|
||||
<Stack align="stretch" justify="center" gap="xl">
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="name"
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
placeholder={t("enter your full name")}
|
||||
variant="filled"
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t("Password")}
|
||||
placeholder={t("Your password")}
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||
{t("Sign Up")}
|
||||
</Button>
|
||||
</form>
|
||||
</Stack>
|
||||
<TextInput
|
||||
id="email"
|
||||
type="email"
|
||||
label={t("Email")}
|
||||
value={invitation.email}
|
||||
disabled
|
||||
variant="filled"
|
||||
mt="md"
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t("Password")}
|
||||
placeholder={t("Your password")}
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||
{t("Sign Up")}
|
||||
</Button>
|
||||
</form>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@ -21,7 +21,7 @@ import { Link } from "react-router-dom";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
|
||||
const formSchema = z.object({
|
||||
workspaceName: z.string().trim().min(3).max(50),
|
||||
workspaceName: z.string().trim().max(50).optional(),
|
||||
name: z.string().min(1).max(50),
|
||||
email: z
|
||||
.string()
|
||||
@ -60,15 +60,17 @@ export function SetupWorkspaceForm() {
|
||||
{isCloud() && <SsoCloudSignup />}
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="workspaceName"
|
||||
type="text"
|
||||
label={t("Workspace Name")}
|
||||
placeholder={t("e.g ACME Inc")}
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("workspaceName")}
|
||||
/>
|
||||
{!isCloud() && (
|
||||
<TextInput
|
||||
id="workspaceName"
|
||||
type="text"
|
||||
label={t("Workspace Name")}
|
||||
placeholder={t("e.g ACME Inc")}
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("workspaceName")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
id="name"
|
||||
|
||||
@ -10,7 +10,7 @@ export interface IRegister {
|
||||
}
|
||||
|
||||
export interface ISetupWorkspace {
|
||||
workspaceName: string;
|
||||
workspaceName?: string;
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
@ -36,5 +36,5 @@ export interface IVerifyUserToken {
|
||||
}
|
||||
|
||||
export interface ICollabToken {
|
||||
token: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
@ -116,6 +116,12 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
},
|
||||
tippyOptions: {
|
||||
moveTransition: "transform 0.15s ease-out",
|
||||
onCreate: (instance) => {
|
||||
instance.popper.firstChild?.addEventListener("blur", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
});
|
||||
},
|
||||
onHide: () => {
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsTextAlignmentOpen(false);
|
||||
@ -177,8 +183,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
<LinkSelector
|
||||
editor={props.editor}
|
||||
isOpen={isLinkSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsLinkSelectorOpen(!isLinkSelectorOpen);
|
||||
setIsOpen={(value) => {
|
||||
setIsLinkSelectorOpen(value);
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsTextAlignmentOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
|
||||
@ -156,13 +156,11 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
editor.commands.unsetColor();
|
||||
name !== "Default" &&
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setColor(color || "")
|
||||
.run();
|
||||
if (name === "Default") {
|
||||
editor.commands.unsetColor();
|
||||
} else {
|
||||
editor.chain().focus().setColor(color || "").run();
|
||||
}
|
||||
setIsOpen(false);
|
||||
}}
|
||||
style={{ border: "none" }}
|
||||
|
||||
@ -18,7 +18,10 @@ import { useForm, zodResolver } from "@mantine/form";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "i18next";
|
||||
import { getEmbedProviderById, getEmbedUrlAndProvider } from '@docmost/editor-ext';
|
||||
import {
|
||||
getEmbedProviderById,
|
||||
getEmbedUrlAndProvider,
|
||||
} from "@docmost/editor-ext";
|
||||
|
||||
const schema = z.object({
|
||||
url: z
|
||||
@ -29,7 +32,7 @@ const schema = z.object({
|
||||
|
||||
export default function EmbedView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { node, selected, updateAttributes } = props;
|
||||
const { node, selected, updateAttributes, editor } = props;
|
||||
const { src, provider } = node.attrs;
|
||||
|
||||
const embedUrl = useMemo(() => {
|
||||
@ -47,8 +50,16 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
});
|
||||
|
||||
async function onSubmit(data: { url: string }) {
|
||||
if (!editor.isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider) {
|
||||
const embedProvider = getEmbedProviderById(provider);
|
||||
if (embedProvider.id === "iframe") {
|
||||
updateAttributes({ src: data.url });
|
||||
return;
|
||||
}
|
||||
if (embedProvider.regex.test(data.url)) {
|
||||
updateAttributes({ src: data.url });
|
||||
} else {
|
||||
@ -78,7 +89,13 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
</AspectRatio>
|
||||
</>
|
||||
) : (
|
||||
<Popover width={300} position="bottom" withArrow shadow="md">
|
||||
<Popover
|
||||
width={300}
|
||||
position="bottom"
|
||||
withArrow
|
||||
shadow="md"
|
||||
disabled={!editor.isEditable}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Card
|
||||
radius="md"
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
type LibraryItems = any;
|
||||
|
||||
type LibraryPersistedData = {
|
||||
libraryItems: LibraryItems;
|
||||
};
|
||||
|
||||
export interface LibraryPersistenceAdapter {
|
||||
load(metadata: { source: "load" | "save" }):
|
||||
| Promise<{ libraryItems: LibraryItems } | null>
|
||||
| {
|
||||
libraryItems: LibraryItems;
|
||||
}
|
||||
| null;
|
||||
|
||||
save(libraryData: LibraryPersistedData): Promise<void> | void;
|
||||
}
|
||||
|
||||
const LOCAL_STORAGE_KEY = "excalidrawLibrary";
|
||||
|
||||
export const localStorageLibraryAdapter: LibraryPersistenceAdapter = {
|
||||
async load() {
|
||||
try {
|
||||
const data = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error downloading Excalidraw library from localStorage", e);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
async save(libraryData) {
|
||||
try {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(libraryData));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"Error while saving library from Excalidraw to localStorage",
|
||||
e,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -13,7 +13,8 @@ import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||
import { svgStringToFile } from "@/lib";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types/types";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||
import { IAttachment } from "@/lib/types";
|
||||
import ReactClearModal from "react-clear-modal";
|
||||
import clsx from "clsx";
|
||||
@ -21,6 +22,8 @@ import { IconEdit } from "@tabler/icons-react";
|
||||
import { lazy } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
||||
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
||||
|
||||
const Excalidraw = lazy(() =>
|
||||
import("@excalidraw/excalidraw").then((module) => ({
|
||||
@ -35,6 +38,10 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
|
||||
const [excalidrawAPI, setExcalidrawAPI] =
|
||||
useState<ExcalidrawImperativeAPI>(null);
|
||||
useHandleLibrary({
|
||||
excalidrawAPI,
|
||||
adapter: localStorageLibraryAdapter,
|
||||
});
|
||||
const [excalidrawData, setExcalidrawData] = useState<any>(null);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
|
||||
@ -17,8 +17,8 @@ import {
|
||||
IconTable,
|
||||
IconTypography,
|
||||
IconMenu4,
|
||||
IconCalendar,
|
||||
} from "@tabler/icons-react";
|
||||
IconCalendar, IconAppWindow,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
CommandProps,
|
||||
SlashMenuGroupedItemsType,
|
||||
@ -357,6 +357,20 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Iframe embed",
|
||||
description: "Embed any Iframe",
|
||||
searchTerms: ["iframe"],
|
||||
icon: IconAppWindow,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setEmbed({ provider: "iframe" })
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Airtable",
|
||||
description: "Embed Airtable",
|
||||
|
||||
@ -17,9 +17,9 @@ import {
|
||||
IconColumnRemove,
|
||||
IconRowInsertBottom,
|
||||
IconRowInsertTop,
|
||||
IconRowRemove,
|
||||
IconRowRemove, IconTableColumn, IconTableRow,
|
||||
IconTrashX,
|
||||
} from "@tabler/icons-react";
|
||||
} from '@tabler/icons-react';
|
||||
import { isCellSelection } from "@docmost/editor-ext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -50,6 +50,14 @@ export const TableMenu = React.memo(
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
}, [editor]);
|
||||
|
||||
const toggleHeaderColumn = useCallback(() => {
|
||||
editor.chain().focus().toggleHeaderColumn().run();
|
||||
}, [editor]);
|
||||
|
||||
const toggleHeaderRow = useCallback(() => {
|
||||
editor.chain().focus().toggleHeaderRow().run();
|
||||
}, [editor]);
|
||||
|
||||
const addColumnLeft = useCallback(() => {
|
||||
editor.chain().focus().addColumnBefore().run();
|
||||
}, [editor]);
|
||||
@ -180,6 +188,30 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Toggle header row")}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={toggleHeaderRow}
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label={t("Toggle header row")}
|
||||
>
|
||||
<IconTableRow size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Toggle header column")}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={toggleHeaderColumn}
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label={t("Toggle header column")}
|
||||
>
|
||||
<IconTableColumn size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Delete table")}>
|
||||
<ActionIcon
|
||||
onClick={deleteTable}
|
||||
|
||||
@ -73,6 +73,7 @@ import i18n from "@/i18n.ts";
|
||||
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
|
||||
import EmojiCommand from "./emoji-command";
|
||||
import { CharacterCount } from "@tiptap/extension-character-count";
|
||||
import { countWords } from "alfaaz";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("mermaid", plaintext);
|
||||
@ -213,7 +214,9 @@ export const mainExtensions = [
|
||||
MarkdownClipboard.configure({
|
||||
transformPastedText: true,
|
||||
}),
|
||||
CharacterCount
|
||||
CharacterCount.configure({
|
||||
wordCounter: (text) => countWords(text),
|
||||
}),
|
||||
] as any;
|
||||
|
||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||
@ -229,4 +232,4 @@ export const collabExtensions: CollabExtensions = (provider, user) => [
|
||||
color: randomElement(userColors),
|
||||
},
|
||||
}),
|
||||
];
|
||||
];
|
||||
|
||||
@ -42,7 +42,11 @@ export function FullEditor({
|
||||
spaceSlug={spaceSlug}
|
||||
editable={editable}
|
||||
/>
|
||||
<MemoizedPageEditor pageId={pageId} editable={editable} content={content} />
|
||||
<MemoizedPageEditor
|
||||
pageId={pageId}
|
||||
editable={editable}
|
||||
content={content}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import "@/features/editor/styles/index.css";
|
||||
import React, {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@ -52,6 +51,7 @@ import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
|
||||
interface PageEditorProps {
|
||||
@ -71,7 +71,11 @@ export default function PageEditor({
|
||||
const [, setAsideState] = useAtom(asideStateAtom);
|
||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
const ydoc = useMemo(() => new Y.Doc(), [pageId]);
|
||||
const ydocRef = useRef<Y.Doc | null>(null);
|
||||
if (!ydocRef.current) {
|
||||
ydocRef.current = new Y.Doc();
|
||||
}
|
||||
const ydoc = ydocRef.current;
|
||||
const [isLocalSynced, setLocalSynced] = useState(false);
|
||||
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||
@ -85,67 +89,103 @@ export default function PageEditor({
|
||||
const [isCollabReady, setIsCollabReady] = useState(false);
|
||||
const { pageSlug } = useParams();
|
||||
const slugId = extractPageSlugId(pageSlug);
|
||||
const userPageEditMode =
|
||||
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||
|
||||
const localProvider = useMemo(() => {
|
||||
const provider = new IndexeddbPersistence(documentName, ydoc);
|
||||
// Providers only created once per pageId
|
||||
const providersRef = useRef<{
|
||||
local: IndexeddbPersistence;
|
||||
remote: HocuspocusProvider;
|
||||
} | null>(null);
|
||||
const [providersReady, setProvidersReady] = useState(false);
|
||||
|
||||
provider.on("synced", () => {
|
||||
setLocalSynced(true);
|
||||
});
|
||||
const localProvider = providersRef.current?.local;
|
||||
const remoteProvider = providersRef.current?.remote;
|
||||
|
||||
return provider;
|
||||
}, [pageId, ydoc]);
|
||||
// Track when collaborative provider is ready and synced
|
||||
const [collabReady, setCollabReady] = useState(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
remoteProvider?.status === WebSocketStatus.Connected &&
|
||||
isLocalSynced &&
|
||||
isRemoteSynced
|
||||
) {
|
||||
setCollabReady(true);
|
||||
}
|
||||
}, [remoteProvider?.status, isLocalSynced, isRemoteSynced]);
|
||||
|
||||
const remoteProvider = useMemo(() => {
|
||||
const provider = new HocuspocusProvider({
|
||||
name: documentName,
|
||||
url: collaborationURL,
|
||||
document: ydoc,
|
||||
token: collabQuery?.token,
|
||||
connect: false,
|
||||
preserveConnection: false,
|
||||
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
|
||||
const payload = jwtDecode(collabQuery?.token);
|
||||
const now = Date.now().valueOf() / 1000;
|
||||
const isTokenExpired = now >= payload.exp;
|
||||
if (isTokenExpired) {
|
||||
refetchCollabToken();
|
||||
}
|
||||
},
|
||||
onStatus: (status) => {
|
||||
if (status.status === "connected") {
|
||||
setYjsConnectionStatus(status.status);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
provider.on("synced", () => {
|
||||
setRemoteSynced(true);
|
||||
});
|
||||
|
||||
provider.on("disconnect", () => {
|
||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||
});
|
||||
|
||||
return provider;
|
||||
}, [ydoc, pageId, collabQuery?.token]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
remoteProvider.connect();
|
||||
useEffect(() => {
|
||||
if (!providersRef.current) {
|
||||
const local = new IndexeddbPersistence(documentName, ydoc);
|
||||
local.on("synced", () => setLocalSynced(true));
|
||||
const remote = new HocuspocusProvider({
|
||||
name: documentName,
|
||||
url: collaborationURL,
|
||||
document: ydoc,
|
||||
token: collabQuery?.token,
|
||||
connect: true,
|
||||
preserveConnection: false,
|
||||
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
|
||||
const payload = jwtDecode(collabQuery?.token);
|
||||
const now = Date.now().valueOf() / 1000;
|
||||
const isTokenExpired = now >= payload.exp;
|
||||
if (isTokenExpired) {
|
||||
refetchCollabToken();
|
||||
}
|
||||
},
|
||||
onStatus: (status) => {
|
||||
if (status.status === "connected") {
|
||||
setYjsConnectionStatus(status.status);
|
||||
}
|
||||
},
|
||||
});
|
||||
remote.on("synced", () => setRemoteSynced(true));
|
||||
remote.on("disconnect", () => {
|
||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||
});
|
||||
providersRef.current = { local, remote };
|
||||
setProvidersReady(true);
|
||||
} else {
|
||||
setProvidersReady(true);
|
||||
}
|
||||
// Only destroy on final unmount
|
||||
return () => {
|
||||
setRemoteSynced(false);
|
||||
setLocalSynced(false);
|
||||
remoteProvider.destroy();
|
||||
localProvider.destroy();
|
||||
providersRef.current?.remote.destroy();
|
||||
providersRef.current?.local.destroy();
|
||||
providersRef.current = null;
|
||||
};
|
||||
}, [remoteProvider, localProvider]);
|
||||
}, [pageId]);
|
||||
|
||||
// Only connect/disconnect on tab/idle, not destroy
|
||||
useEffect(() => {
|
||||
if (!providersReady || !providersRef.current) return;
|
||||
const remoteProvider = providersRef.current.remote;
|
||||
if (
|
||||
isIdle &&
|
||||
documentState === "hidden" &&
|
||||
remoteProvider.status === WebSocketStatus.Connected
|
||||
) {
|
||||
remoteProvider.disconnect();
|
||||
setIsCollabReady(false);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
documentState === "visible" &&
|
||||
remoteProvider.status === WebSocketStatus.Disconnected
|
||||
) {
|
||||
resetIdle();
|
||||
remoteProvider.connect();
|
||||
setTimeout(() => setIsCollabReady(true), 500);
|
||||
}
|
||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
if (!remoteProvider || !currentUser?.user) return mainExtensions;
|
||||
return [
|
||||
...mainExtensions,
|
||||
...collabExtensions(remoteProvider, currentUser?.user),
|
||||
];
|
||||
}, [ydoc, pageId, remoteProvider, currentUser?.user]);
|
||||
}, [remoteProvider, currentUser?.user]);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
@ -199,7 +239,7 @@ export default function PageEditor({
|
||||
debouncedUpdateContent(editorJson);
|
||||
},
|
||||
},
|
||||
[pageId, editable, remoteProvider?.status],
|
||||
[pageId, editable, remoteProvider],
|
||||
);
|
||||
|
||||
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
||||
@ -252,29 +292,6 @@ export default function PageEditor({
|
||||
}
|
||||
}, [remoteProvider?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isIdle &&
|
||||
documentState === "hidden" &&
|
||||
remoteProvider?.status === WebSocketStatus.Connected
|
||||
) {
|
||||
remoteProvider.disconnect();
|
||||
setIsCollabReady(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
documentState === "visible" &&
|
||||
remoteProvider?.status === WebSocketStatus.Disconnected
|
||||
) {
|
||||
resetIdle();
|
||||
remoteProvider.connect();
|
||||
setTimeout(() => {
|
||||
setIsCollabReady(true);
|
||||
}, 600);
|
||||
}
|
||||
}, [isIdle, documentState, remoteProvider]);
|
||||
|
||||
const isSynced = isLocalSynced && isRemoteSynced;
|
||||
|
||||
useEffect(() => {
|
||||
@ -290,11 +307,49 @@ export default function PageEditor({
|
||||
return () => clearTimeout(collabReadyTimeout);
|
||||
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
|
||||
|
||||
return isCollabReady ? (
|
||||
<div>
|
||||
useEffect(() => {
|
||||
// Only honor user default page edit mode preference and permissions
|
||||
if (editor) {
|
||||
if (userPageEditMode && editable) {
|
||||
if (userPageEditMode === PageEditMode.Edit) {
|
||||
editor.setEditable(true);
|
||||
} else if (userPageEditMode === PageEditMode.Read) {
|
||||
editor.setEditable(false);
|
||||
}
|
||||
} else {
|
||||
editor.setEditable(false);
|
||||
}
|
||||
}
|
||||
}, [userPageEditMode, editor, editable]);
|
||||
|
||||
const hasConnectedOnceRef = useRef(false);
|
||||
const [showStatic, setShowStatic] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hasConnectedOnceRef.current &&
|
||||
remoteProvider?.status === WebSocketStatus.Connected
|
||||
) {
|
||||
hasConnectedOnceRef.current = true;
|
||||
setShowStatic(false);
|
||||
}
|
||||
}, [remoteProvider?.status]);
|
||||
|
||||
if (showStatic) {
|
||||
return (
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={mainExtensions}
|
||||
content={content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<div ref={menuContainerRef}>
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
{editor && editor.isEditable && (
|
||||
<div>
|
||||
<EditorBubbleMenu editor={editor} />
|
||||
@ -308,21 +363,12 @@ export default function PageEditor({
|
||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => editor.commands.focus("end")}
|
||||
style={{ paddingBottom: "20vh" }}
|
||||
></div>
|
||||
</div>
|
||||
) : (
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={mainExtensions}
|
||||
content={content}
|
||||
></EditorProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -21,6 +21,8 @@ import { useTranslation } from "react-i18next";
|
||||
import EmojiCommand from "@/features/editor/extensions/emoji-command.ts";
|
||||
import { UpdateEvent } from "@/features/websocket/types";
|
||||
import localEmitter from "@/lib/local-emitter.ts";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
|
||||
export interface TitleEditorProps {
|
||||
pageId: string;
|
||||
@ -44,6 +46,9 @@ export function TitleEditor({
|
||||
const emit = useQueryEmit();
|
||||
const navigate = useNavigate();
|
||||
const [activePageId, setActivePageId] = useState(pageId);
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const userPageEditMode =
|
||||
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||
|
||||
const titleEditor = useEditor({
|
||||
extensions: [
|
||||
@ -136,9 +141,24 @@ export function TitleEditor({
|
||||
};
|
||||
}, [pageId]);
|
||||
|
||||
function handleTitleKeyDown(event) {
|
||||
if (!titleEditor || !pageEditor || event.shiftKey) return;
|
||||
useEffect(() => {
|
||||
// honor user default page edit mode preference
|
||||
if (userPageEditMode && titleEditor && editable) {
|
||||
if (userPageEditMode === PageEditMode.Edit) {
|
||||
titleEditor.setEditable(true);
|
||||
} else if (userPageEditMode === PageEditMode.Read) {
|
||||
titleEditor.setEditable(false);
|
||||
}
|
||||
}
|
||||
}, [userPageEditMode, titleEditor, editable]);
|
||||
|
||||
function handleTitleKeyDown(event: any) {
|
||||
if (!titleEditor || !pageEditor || event.shiftKey) return;
|
||||
|
||||
// Prevent focus shift when IME composition is active
|
||||
// `keyCode === 229` is added to support Safari where `isComposing` may not be reliable
|
||||
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
|
||||
|
||||
const { key } = event;
|
||||
const { $head } = titleEditor.state.selection;
|
||||
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import api from "@/lib/api-client";
|
||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
export async function getPageHistoryList(
|
||||
pageId: string,
|
||||
): Promise<IPageHistory[]> {
|
||||
): Promise<IPagination<IPageHistory>> {
|
||||
const req = await api.post("/pages/history", {
|
||||
pageId,
|
||||
});
|
||||
|
||||
@ -1,24 +1,30 @@
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
a {
|
||||
color: var(--mantine-color-default-color);
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.mantine-Breadcrumbs-breadcrumb {
|
||||
min-width: 1px;
|
||||
overflow: hidden;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
a {
|
||||
color: var(--mantine-color-default-color);
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.mantine-Breadcrumbs-breadcrumb {
|
||||
min-width: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.truncatedText {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 200px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.breadcrumbDiv {
|
||||
overflow: hidden;
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@ -161,7 +161,7 @@ export default function Breadcrumb() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ overflow: "hidden" }}>
|
||||
<div className={classes.breadcrumbDiv}>
|
||||
{breadcrumbNodes && (
|
||||
<Breadcrumbs className={classes.breadcrumbs}>
|
||||
{isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()}
|
||||
|
||||
@ -33,6 +33,7 @@ import {
|
||||
yjsConnectionStatusAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
||||
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
|
||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import ShareModal from "@/features/share/components/share-modal.tsx";
|
||||
@ -59,6 +60,8 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!readOnly && <PageStateSegmentedControl size="xs" />}
|
||||
|
||||
<ShareModal readOnly={readOnly} />
|
||||
|
||||
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
||||
|
||||
@ -1,15 +1,27 @@
|
||||
.header {
|
||||
height: 45px;
|
||||
background-color: var(--mantine-color-body);
|
||||
padding-left: var(--mantine-spacing-md);
|
||||
padding-right: var(--mantine-spacing-md);
|
||||
position: fixed;
|
||||
z-index: 99;
|
||||
top: var(--app-shell-header-offset, 0rem);
|
||||
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
|
||||
inset-inline-end: var(--app-shell-aside-offset, 0rem);
|
||||
height: 45px;
|
||||
background-color: var(--mantine-color-body);
|
||||
padding-left: var(--mantine-spacing-md);
|
||||
padding-right: var(--mantine-spacing-md);
|
||||
position: fixed;
|
||||
z-index: 99;
|
||||
top: var(--app-shell-header-offset, 0rem);
|
||||
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
|
||||
inset-inline-end: var(--app-shell-aside-offset, 0rem);
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
padding-left: var(--mantine-spacing-xs);
|
||||
padding-right: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
gap: var(--mantine-spacing-sm);
|
||||
padding-inline: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,10 +9,10 @@ interface Props {
|
||||
export default function PageHeader({ readOnly }: Props) {
|
||||
return (
|
||||
<div className={classes.header}>
|
||||
<Group justify="space-between" h="100%" px="md" wrap="nowrap">
|
||||
<Group justify="space-between" h="100%" px="md" wrap="nowrap" className={classes.group}>
|
||||
<Breadcrumb />
|
||||
|
||||
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
|
||||
<Group justify="flex-end" h="100%" px="md" wrap="nowrap" gap="var(--mantine-spacing-xs)">
|
||||
<PageHeaderMenu readOnly={readOnly} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@ -65,6 +65,7 @@ export interface IPageInput {
|
||||
icon: string;
|
||||
coverPhoto: string;
|
||||
position: string;
|
||||
isLocked: boolean;
|
||||
}
|
||||
|
||||
export interface IExportPageParams {
|
||||
|
||||
@ -42,14 +42,15 @@ function LanguageSwitcher() {
|
||||
label={t("Select language")}
|
||||
data={[
|
||||
{ value: "en-US", label: "English (US)" },
|
||||
{ value: "de-DE", label: "Deutsch (German)" },
|
||||
{ value: "nl-NL", label: "Dutch (Netherlands)" },
|
||||
{ value: "fr-FR", label: "Français (French)" },
|
||||
{ value: "es-ES", label: "Español (Spanish)" },
|
||||
{ value: "de-DE", label: "Deutsch (German)" },
|
||||
{ value: "fr-FR", label: "Français (French)" },
|
||||
{ value: "nl-NL", label: "Dutch (Netherlands)" },
|
||||
{ value: "pt-BR", label: "Português (Brasil)" },
|
||||
{ value: "it-IT", label: "Italiano (Italian)" },
|
||||
{ value: "ja-JP", label: "日本語 (Japanese)" },
|
||||
{ value: "ko-KR", label: "한국어 (Korean)" },
|
||||
{ value: "uk-UA", label: "Українська (Ukrainian)" },
|
||||
{ value: "ru-RU", label: "Русский (Russian)" },
|
||||
{ value: "zh-CN", label: "中文 (简体)" },
|
||||
]}
|
||||
|
||||
@ -11,7 +11,7 @@ import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(2).max(40),
|
||||
name: z.string().min(1).max(40),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
65
apps/client/src/features/user/components/page-state-pref.tsx
Normal file
65
apps/client/src/features/user/components/page-state-pref.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { Group, Text, MantineSize, SegmentedControl } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { updateUser } from "@/features/user/services/user-service.ts";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
|
||||
export default function PageStatePref() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">{t("Default page edit mode")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Choose your preferred page edit mode. Avoid accidental edits.")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<PageStateSegmentedControl />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageStateSegmentedControlProps {
|
||||
size?: MantineSize;
|
||||
}
|
||||
|
||||
export function PageStateSegmentedControl({
|
||||
size,
|
||||
}: PageStateSegmentedControlProps) {
|
||||
const { t } = useTranslation();
|
||||
const [user, setUser] = useAtom(userAtom);
|
||||
const pageEditMode =
|
||||
user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||
const [value, setValue] = useState(pageEditMode);
|
||||
|
||||
const handleChange = useCallback(
|
||||
async (value: string) => {
|
||||
const updatedUser = await updateUser({ pageEditMode: value });
|
||||
setValue(value);
|
||||
setUser(updatedUser);
|
||||
},
|
||||
[user, setUser],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (pageEditMode !== value) {
|
||||
setValue(pageEditMode);
|
||||
}
|
||||
}, [pageEditMode, value]);
|
||||
|
||||
return (
|
||||
<SegmentedControl
|
||||
size={size}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
data={[
|
||||
{ label: t("Edit"), value: PageEditMode.Edit },
|
||||
{ label: t("Read"), value: PageEditMode.Read },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -19,6 +19,7 @@ export interface IUser {
|
||||
deactivatedAt: Date;
|
||||
deletedAt: Date;
|
||||
fullPageWidth: boolean; // used for update
|
||||
pageEditMode: string; // used for update
|
||||
}
|
||||
|
||||
export interface ICurrentUser {
|
||||
@ -29,5 +30,11 @@ export interface ICurrentUser {
|
||||
export interface IUserSettings {
|
||||
preferences: {
|
||||
fullPageWidth: boolean;
|
||||
pageEditMode: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export enum PageEditMode {
|
||||
Read = "read",
|
||||
Edit = "edit",
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(4),
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
@ -173,7 +173,7 @@ export function useRevokeInvitationMutation() {
|
||||
|
||||
export function useGetInvitationQuery(
|
||||
invitationId: string,
|
||||
): UseQueryResult<any, Error> {
|
||||
): UseQueryResult<IInvitation, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["invitations", invitationId],
|
||||
queryFn: () => getInvitationById({ invitationId }),
|
||||
|
||||
@ -12,6 +12,7 @@ export interface IWorkspace {
|
||||
settings: any;
|
||||
status: string;
|
||||
enforceSso: boolean;
|
||||
stripeCustomerId: string;
|
||||
billingEmail: string;
|
||||
trialEndAt: Date;
|
||||
createdAt: Date;
|
||||
@ -35,6 +36,7 @@ export interface IInvitation {
|
||||
workspaceId: string;
|
||||
invitedById: string;
|
||||
createdAt: Date;
|
||||
enforceSso: boolean;
|
||||
}
|
||||
|
||||
export interface IInvitationLink {
|
||||
|
||||
@ -83,6 +83,18 @@ export function getBillingTrialDays() {
|
||||
return getConfigValue("BILLING_TRIAL_DAYS");
|
||||
}
|
||||
|
||||
export function getPostHogHost() {
|
||||
return getConfigValue("POSTHOG_HOST");
|
||||
}
|
||||
|
||||
export function isPostHogEnabled(): boolean {
|
||||
return Boolean(getPostHogHost() && getPostHogKey());
|
||||
}
|
||||
|
||||
export function getPostHogKey() {
|
||||
return getConfigValue("POSTHOG_KEY");
|
||||
}
|
||||
|
||||
function getConfigValue(key: string, defaultValue: string = undefined): string {
|
||||
const rawValue = import.meta.env.DEV
|
||||
? process?.env?.[key]
|
||||
|
||||
@ -3,7 +3,7 @@ import "@mantine/spotlight/styles.css";
|
||||
import "@mantine/notifications/styles.css";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import { mantineCssResolver, theme } from '@/theme';
|
||||
import { mantineCssResolver, theme } from "@/theme";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { ModalsProvider } from "@mantine/modals";
|
||||
@ -11,6 +11,14 @@ import { Notifications } from "@mantine/notifications";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
import "./i18n";
|
||||
import { PostHogProvider } from "posthog-js/react";
|
||||
import {
|
||||
getPostHogHost,
|
||||
getPostHogKey,
|
||||
isCloud,
|
||||
isPostHogEnabled,
|
||||
} from "@/lib/config.ts";
|
||||
import posthog from "posthog-js";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@ -23,9 +31,16 @@ export const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
if (isCloud() && isPostHogEnabled) {
|
||||
posthog.init(getPostHogKey(), {
|
||||
api_host: getPostHogHost(),
|
||||
defaults: "2025-05-24",
|
||||
disable_session_recording: true,
|
||||
});
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById("root") as HTMLElement
|
||||
document.getElementById("root") as HTMLElement,
|
||||
);
|
||||
|
||||
root.render(
|
||||
@ -35,10 +50,12 @@ root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Notifications position="bottom-center" limit={3} />
|
||||
<HelmetProvider>
|
||||
<App />
|
||||
<PostHogProvider client={posthog}>
|
||||
<App />
|
||||
</PostHogProvider>
|
||||
</HelmetProvider>
|
||||
</QueryClientProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</BrowserRouter>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
@ -12,6 +12,11 @@ import {
|
||||
SpaceCaslSubject,
|
||||
} from "@/features/space/permissions/permissions.type.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React from "react";
|
||||
|
||||
const MemoizedFullEditor = React.memo(FullEditor);
|
||||
const MemoizedPageHeader = React.memo(PageHeader);
|
||||
const MemoizedHistoryModal = React.memo(HistoryModal);
|
||||
|
||||
export default function Page() {
|
||||
const { t } = useTranslation();
|
||||
@ -49,14 +54,14 @@ export default function Page() {
|
||||
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
|
||||
</Helmet>
|
||||
|
||||
<PageHeader
|
||||
<MemoizedPageHeader
|
||||
readOnly={spaceAbility.cannot(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page,
|
||||
)}
|
||||
/>
|
||||
|
||||
<FullEditor
|
||||
<MemoizedFullEditor
|
||||
key={page.id}
|
||||
pageId={page.id}
|
||||
title={page.title}
|
||||
@ -68,7 +73,7 @@ export default function Page() {
|
||||
SpaceCaslSubject.Page,
|
||||
)}
|
||||
/>
|
||||
<HistoryModal pageId={page.id} />
|
||||
<MemoizedHistoryModal pageId={page.id} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
@ -2,6 +2,7 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||
import AccountLanguage from "@/features/user/components/account-language.tsx";
|
||||
import AccountTheme from "@/features/user/components/account-theme.tsx";
|
||||
import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
|
||||
import PageEditPref from "@/features/user/components/page-state-pref";
|
||||
import { getAppName } from "@/lib/config.ts";
|
||||
import { Divider } from "@mantine/core";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
@ -28,6 +29,10 @@ export default function AccountPreferences() {
|
||||
<Divider my={"md"} />
|
||||
|
||||
<PageWidthPref />
|
||||
|
||||
<Divider my={"md"} />
|
||||
|
||||
<PageEditPref />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -14,6 +14,8 @@ export default defineConfig(({ mode }) => {
|
||||
SUBDOMAIN_HOST,
|
||||
COLLAB_URL,
|
||||
BILLING_TRIAL_DAYS,
|
||||
POSTHOG_HOST,
|
||||
POSTHOG_KEY,
|
||||
} = loadEnv(mode, envPath, "");
|
||||
|
||||
return {
|
||||
@ -27,6 +29,8 @@ export default defineConfig(({ mode }) => {
|
||||
SUBDOMAIN_HOST,
|
||||
COLLAB_URL,
|
||||
BILLING_TRIAL_DAYS,
|
||||
POSTHOG_HOST,
|
||||
POSTHOG_KEY,
|
||||
},
|
||||
APP_VERSION: JSON.stringify(process.env.npm_package_version),
|
||||
},
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.20.4",
|
||||
"version": "0.21.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@ -36,54 +36,56 @@
|
||||
"@casl/ability": "^6.7.3",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/multipart": "^9.0.3",
|
||||
"@fastify/static": "^8.1.1",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@nestjs/bullmq": "^11.0.2",
|
||||
"@nestjs/common": "^11.0.20",
|
||||
"@nestjs/common": "^11.1.3",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.20",
|
||||
"@nestjs/event-emitter": "^3.0.0",
|
||||
"@nestjs/core": "^11.1.3",
|
||||
"@nestjs/event-emitter": "^3.0.1",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/mapped-types": "^2.1.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-fastify": "^11.0.20",
|
||||
"@nestjs/platform-socket.io": "^11.0.20",
|
||||
"@nestjs/schedule": "^5.0.1",
|
||||
"@nestjs/platform-fastify": "^11.1.3",
|
||||
"@nestjs/platform-socket.io": "^11.1.3",
|
||||
"@nestjs/schedule": "^6.0.0",
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/websockets": "^11.0.20",
|
||||
"@nestjs/websockets": "^11.1.3",
|
||||
"@node-saml/passport-saml": "^5.0.1",
|
||||
"@react-email/components": "0.0.28",
|
||||
"@react-email/render": "1.0.2",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bullmq": "^5.41.3",
|
||||
"cache-manager": "^6.4.0",
|
||||
"cheerio": "^1.0.0",
|
||||
"bullmq": "^5.53.2",
|
||||
"cache-manager": "^6.4.3",
|
||||
"cheerio": "^1.1.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie": "^1.0.2",
|
||||
"fs-extra": "^11.3.0",
|
||||
"happy-dom": "^15.11.6",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"kysely": "^0.27.5",
|
||||
"kysely": "^0.28.2",
|
||||
"kysely-migration-cli": "^0.4.2",
|
||||
"mime-types": "^2.1.35",
|
||||
"nanoid": "3.3.11",
|
||||
"nestjs-kysely": "^1.1.0",
|
||||
"nodemailer": "^6.10.0",
|
||||
"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",
|
||||
"pg": "^8.13.3",
|
||||
"pg": "^8.16.0",
|
||||
"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.1",
|
||||
"rxjs": "^7.8.2",
|
||||
"sanitize-filename-ts": "^1.0.2",
|
||||
"socket.io": "^4.8.1",
|
||||
"stripe": "^17.5.0",
|
||||
"tmp-promise": "^3.0.3",
|
||||
"ws": "^8.18.0",
|
||||
"ws": "^8.18.2",
|
||||
"yauzl": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -156,6 +156,7 @@ export class PersistenceExtension implements Extension {
|
||||
page: {
|
||||
...page,
|
||||
content: tiptapJson,
|
||||
textContent: textContent,
|
||||
lastUpdatedById: context.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
@ -16,6 +16,12 @@ export async function comparePasswordHash(
|
||||
return bcrypt.compare(plainPassword, passwordHash);
|
||||
}
|
||||
|
||||
export function generateRandomSuffixNumbers(length: number) {
|
||||
return Math.random()
|
||||
.toFixed(length)
|
||||
.substring(2, 2 + length);
|
||||
}
|
||||
|
||||
export type RedisConfig = {
|
||||
host: string;
|
||||
port: number;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,9 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
@ -23,7 +21,6 @@ import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||
import { PasswordResetDto } from './dto/password-reset.dto';
|
||||
import { VerifyUserTokenDto } from './dto/verify-user-token.dto';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { addDays } from 'date-fns';
|
||||
import { validateSsoEnforcement } from './auth.util';
|
||||
|
||||
@Controller('auth')
|
||||
@ -125,7 +122,7 @@ export class AuthController {
|
||||
res.setCookie('authToken', token, {
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
expires: addDays(new Date(), 30),
|
||||
expires: this.environmentService.getCookieExpiresIn(),
|
||||
secure: this.environmentService.isHttps(),
|
||||
});
|
||||
}
|
||||
|
||||
@ -6,3 +6,16 @@ export function validateSsoEnforcement(workspace: Workspace) {
|
||||
throw new BadRequestException('This workspace has enforced SSO login.');
|
||||
}
|
||||
}
|
||||
|
||||
export function validateAllowedEmail(userEmail: string, workspace: Workspace) {
|
||||
const emailParts = userEmail.split('@');
|
||||
const emailDomain = emailParts[1].toLowerCase();
|
||||
if (
|
||||
workspace.emailDomains?.length > 0 &&
|
||||
!workspace.emailDomains.includes(emailDomain)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`The email domain "${emailDomain}" is not approved for this workspace.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { CreateUserDto } from './create-user.dto';
|
||||
import {Transform, TransformFnParams} from "class-transformer";
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
|
||||
export class CreateAdminUserDto extends CreateUserDto {
|
||||
@IsNotEmpty()
|
||||
@ -9,10 +15,17 @@ export class CreateAdminUserDto extends CreateUserDto {
|
||||
@Transform(({ value }: TransformFnParams) => value?.trim())
|
||||
name: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@MinLength(3)
|
||||
@IsOptional()
|
||||
@MinLength(1)
|
||||
@MaxLength(50)
|
||||
@IsString()
|
||||
@Transform(({ value }: TransformFnParams) => value?.trim())
|
||||
workspaceName: string;
|
||||
|
||||
@IsOptional()
|
||||
@MinLength(4)
|
||||
@MaxLength(50)
|
||||
@IsString()
|
||||
@Transform(({ value }: TransformFnParams) => value?.trim())
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
@ -92,7 +92,8 @@ export class SignupService {
|
||||
|
||||
// create workspace with full setup
|
||||
const workspaceData: CreateWorkspaceDto = {
|
||||
name: createAdminUserDto.workspaceName,
|
||||
name: createAdminUserDto.workspaceName || 'My workspace',
|
||||
hostname: createAdminUserDto.hostname,
|
||||
};
|
||||
|
||||
workspace = await this.workspaceService.create(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
import { OmitType, PartialType } from '@nestjs/mapped-types';
|
||||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { CreateUserDto } from '../../auth/dto/create-user.dto';
|
||||
|
||||
export class UpdateUserDto extends PartialType(
|
||||
@ -13,7 +21,18 @@ export class UpdateUserDto extends PartialType(
|
||||
@IsBoolean()
|
||||
fullPageWidth: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['read', 'edit'])
|
||||
pageEditMode: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
locale: string;
|
||||
|
||||
@IsOptional()
|
||||
@MinLength(8)
|
||||
@MaxLength(70)
|
||||
@IsString()
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
@ -50,6 +50,6 @@ export class UserController {
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.userService.update(updateUserDto, user.id, workspace.id);
|
||||
return this.userService.update(updateUserDto, user.id, workspace);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,12 @@ import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { comparePasswordHash } from 'src/common/helpers/utils';
|
||||
import { Workspace } from '@docmost/db/types/entity.types';
|
||||
import { validateSsoEnforcement } from '../auth/auth.util';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
@ -17,9 +21,14 @@ export class UserService {
|
||||
async update(
|
||||
updateUserDto: UpdateUserDto,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
workspace: Workspace,
|
||||
) {
|
||||
const user = await this.userRepo.findById(userId, workspaceId);
|
||||
const includePassword =
|
||||
updateUserDto.email != null && updateUserDto.confirmPassword != null;
|
||||
|
||||
const user = await this.userRepo.findById(userId, workspace.id, {
|
||||
includePassword,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
@ -34,14 +43,40 @@ export class UserService {
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateUserDto.pageEditMode !== 'undefined') {
|
||||
return this.userRepo.updatePreference(
|
||||
userId,
|
||||
'pageEditMode',
|
||||
updateUserDto.pageEditMode.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
if (updateUserDto.name) {
|
||||
user.name = updateUserDto.name;
|
||||
}
|
||||
|
||||
if (updateUserDto.email && user.email != updateUserDto.email) {
|
||||
if (await this.userRepo.findByEmail(updateUserDto.email, workspaceId)) {
|
||||
validateSsoEnforcement(workspace);
|
||||
|
||||
if (!updateUserDto.confirmPassword) {
|
||||
throw new BadRequestException(
|
||||
'You must provide a password to change your email',
|
||||
);
|
||||
}
|
||||
|
||||
const isPasswordMatch = await comparePasswordHash(
|
||||
updateUserDto.confirmPassword,
|
||||
user.password,
|
||||
);
|
||||
|
||||
if (!isPasswordMatch) {
|
||||
throw new BadRequestException('You must provide the correct password to change your email');
|
||||
}
|
||||
|
||||
if (await this.userRepo.findByEmail(updateUserDto.email, workspace.id)) {
|
||||
throw new BadRequestException('A user with this email already exists');
|
||||
}
|
||||
|
||||
user.email = updateUserDto.email;
|
||||
}
|
||||
|
||||
@ -53,7 +88,9 @@ export class UserService {
|
||||
user.locale = updateUserDto.locale;
|
||||
}
|
||||
|
||||
await this.userRepo.updateUser(updateUserDto, userId, workspaceId);
|
||||
delete updateUserDto.confirmPassword;
|
||||
|
||||
await this.userRepo.updateUser(updateUserDto, userId, workspace.id);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,9 +29,7 @@ import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.fact
|
||||
import {
|
||||
WorkspaceCaslAction,
|
||||
WorkspaceCaslSubject,
|
||||
} from '../../casl/interfaces/workspace-ability.type';
|
||||
import { addDays } from 'date-fns';
|
||||
import { FastifyReply } from 'fastify';
|
||||
} from '../../casl/interfaces/workspace-ability.type';import { FastifyReply } from 'fastify';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import { CheckHostnameDto } from '../dto/check-hostname.dto';
|
||||
import { RemoveWorkspaceUserDto } from '../dto/remove-workspace-user.dto';
|
||||
@ -180,10 +178,13 @@ export class WorkspaceController {
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('invites/info')
|
||||
async getInvitationById(@Body() dto: InvitationIdDto, @Req() req: any) {
|
||||
async getInvitationById(
|
||||
@Body() dto: InvitationIdDto,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.workspaceInvitationService.getInvitationById(
|
||||
dto.invitationId,
|
||||
req.raw.workspaceId,
|
||||
workspace,
|
||||
);
|
||||
}
|
||||
|
||||
@ -253,18 +254,18 @@ export class WorkspaceController {
|
||||
@Post('invites/accept')
|
||||
async acceptInvite(
|
||||
@Body() acceptInviteDto: AcceptInviteDto,
|
||||
@Req() req: any,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) res: FastifyReply,
|
||||
) {
|
||||
const authToken = await this.workspaceInvitationService.acceptInvitation(
|
||||
acceptInviteDto,
|
||||
req.raw.workspaceId,
|
||||
workspace,
|
||||
);
|
||||
|
||||
res.setCookie('authToken', authToken, {
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
expires: addDays(new Date(), 30),
|
||||
expires: this.environmentService.getCookieExpiresIn(),
|
||||
secure: this.environmentService.isHttps(),
|
||||
});
|
||||
}
|
||||
|
||||
@ -28,6 +28,10 @@ import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||
import { Queue } from 'bullmq';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import {
|
||||
validateAllowedEmail,
|
||||
validateSsoEnforcement,
|
||||
} from '../../auth/auth.util';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceInvitationService {
|
||||
@ -63,19 +67,19 @@ export class WorkspaceInvitationService {
|
||||
return result;
|
||||
}
|
||||
|
||||
async getInvitationById(invitationId: string, workspaceId: string) {
|
||||
async getInvitationById(invitationId: string, workspace: Workspace) {
|
||||
const invitation = await this.db
|
||||
.selectFrom('workspaceInvitations')
|
||||
.select(['id', 'email', 'createdAt'])
|
||||
.where('id', '=', invitationId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('workspaceId', '=', workspace.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!invitation) {
|
||||
throw new NotFoundException('Invitation not found');
|
||||
}
|
||||
|
||||
return invitation;
|
||||
return { ...invitation, enforceSso: workspace.enforceSso };
|
||||
}
|
||||
|
||||
async getInvitationTokenById(invitationId: string, workspaceId: string) {
|
||||
@ -141,6 +145,10 @@ export class WorkspaceInvitationService {
|
||||
groupIds: validGroups?.map((group: Partial<Group>) => group.id),
|
||||
}));
|
||||
|
||||
if (invitesToInsert.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
invites = await trx
|
||||
.insertInto('workspaceInvitations')
|
||||
.values(invitesToInsert)
|
||||
@ -169,12 +177,12 @@ export class WorkspaceInvitationService {
|
||||
}
|
||||
}
|
||||
|
||||
async acceptInvitation(dto: AcceptInviteDto, workspaceId: string) {
|
||||
async acceptInvitation(dto: AcceptInviteDto, workspace: Workspace) {
|
||||
const invitation = await this.db
|
||||
.selectFrom('workspaceInvitations')
|
||||
.selectAll()
|
||||
.where('id', '=', dto.invitationId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('workspaceId', '=', workspace.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!invitation) {
|
||||
@ -185,6 +193,9 @@ export class WorkspaceInvitationService {
|
||||
throw new BadRequestException('Invalid invitation token');
|
||||
}
|
||||
|
||||
validateSsoEnforcement(workspace);
|
||||
validateAllowedEmail(invitation.email, workspace);
|
||||
|
||||
let newUser: User;
|
||||
|
||||
try {
|
||||
@ -197,7 +208,7 @@ export class WorkspaceInvitationService {
|
||||
password: dto.password,
|
||||
role: invitation.role,
|
||||
invitedById: invitation.invitedById,
|
||||
workspaceId: workspaceId,
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
@ -205,7 +216,7 @@ export class WorkspaceInvitationService {
|
||||
// add user to default group
|
||||
await this.groupUserRepo.addUserToDefaultGroup(
|
||||
newUser.id,
|
||||
workspaceId,
|
||||
workspace.id,
|
||||
trx,
|
||||
);
|
||||
|
||||
@ -215,7 +226,7 @@ export class WorkspaceInvitationService {
|
||||
.selectFrom('groups')
|
||||
.select(['id', 'name'])
|
||||
.where('groups.id', 'in', invitation.groupIds)
|
||||
.where('groups.workspaceId', '=', workspaceId)
|
||||
.where('groups.workspaceId', '=', workspace.id)
|
||||
.execute();
|
||||
|
||||
if (validGroups && validGroups.length > 0) {
|
||||
@ -256,7 +267,7 @@ export class WorkspaceInvitationService {
|
||||
// notify the inviter
|
||||
const invitedByUser = await this.userRepo.findById(
|
||||
invitation.invitedById,
|
||||
workspaceId,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
if (invitedByUser) {
|
||||
@ -273,7 +284,9 @@ export class WorkspaceInvitationService {
|
||||
}
|
||||
|
||||
if (this.environmentService.isCloud()) {
|
||||
await this.billingQueue.add(QueueJob.STRIPE_SEATS_SYNC, { workspaceId });
|
||||
await this.billingQueue.add(QueueJob.STRIPE_SEATS_SYNC, {
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
return this.tokenService.generateAccessToken(newUser);
|
||||
|
||||
@ -32,6 +32,7 @@ import { AttachmentType } from 'src/core/attachment/attachment.constants';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||
import { Queue } from 'bullmq';
|
||||
import { generateRandomSuffixNumbers } from '../../../common/helpers';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceService {
|
||||
@ -377,24 +378,20 @@ export class WorkspaceService {
|
||||
name: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<string> {
|
||||
const generateRandomSuffix = (length: number) =>
|
||||
Math.random()
|
||||
.toFixed(length)
|
||||
.substring(2, 2 + length);
|
||||
|
||||
let subdomain = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '')
|
||||
.substring(0, 20);
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.substring(0, 20)
|
||||
.replace(/^-+|-+$/g, ''); //remove any hyphen at the start or end
|
||||
// Ensure we leave room for a random suffix.
|
||||
const maxSuffixLength = 6;
|
||||
|
||||
if (subdomain.length < 4) {
|
||||
subdomain = `${subdomain}-${generateRandomSuffix(maxSuffixLength)}`;
|
||||
subdomain = `${subdomain}-${generateRandomSuffixNumbers(maxSuffixLength)}`;
|
||||
}
|
||||
|
||||
if (DISALLOWED_HOSTNAMES.includes(subdomain)) {
|
||||
subdomain = `workspace-${generateRandomSuffix(maxSuffixLength)}`;
|
||||
subdomain = `workspace-${generateRandomSuffixNumbers(maxSuffixLength)}`;
|
||||
}
|
||||
|
||||
let uniqueHostname = subdomain;
|
||||
@ -408,7 +405,7 @@ export class WorkspaceService {
|
||||
break;
|
||||
}
|
||||
// Append a random suffix and retry.
|
||||
const randomSuffix = generateRandomSuffix(maxSuffixLength);
|
||||
const randomSuffix = generateRandomSuffixNumbers(maxSuffixLength);
|
||||
uniqueHostname = `${subdomain}-${randomSuffix}`.substring(0, 25);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
import { type Kysely } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('billing')
|
||||
.addColumn('billing_scheme', 'varchar', (col) => col)
|
||||
.addColumn('tiered_up_to', 'varchar', (col) => col)
|
||||
.addColumn('tiered_flat_amount', 'int8', (col) => col)
|
||||
.addColumn('tiered_unit_amount', 'int8', (col) => col)
|
||||
.addColumn('plan_name', 'varchar', (col) => col)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('billing')
|
||||
.dropColumn('billing_scheme')
|
||||
.dropColumn('tiered_up_to')
|
||||
.dropColumn('tiered_flat_amount')
|
||||
.dropColumn('tiered_unit_amount')
|
||||
.dropColumn('plan_name')
|
||||
.execute();
|
||||
}
|
||||
5
apps/server/src/database/types/db.d.ts
vendored
5
apps/server/src/database/types/db.d.ts
vendored
@ -84,6 +84,7 @@ export interface Backlinks {
|
||||
|
||||
export interface Billing {
|
||||
amount: Int8 | null;
|
||||
billingScheme: string | null;
|
||||
cancelAt: Timestamp | null;
|
||||
cancelAtPeriodEnd: boolean | null;
|
||||
canceledAt: Timestamp | null;
|
||||
@ -96,6 +97,7 @@ export interface Billing {
|
||||
metadata: Json | null;
|
||||
periodEndAt: Timestamp | null;
|
||||
periodStartAt: Timestamp;
|
||||
planName: string | null;
|
||||
quantity: Int8 | null;
|
||||
status: string;
|
||||
stripeCustomerId: string | null;
|
||||
@ -103,6 +105,9 @@ export interface Billing {
|
||||
stripePriceId: string | null;
|
||||
stripeProductId: string | null;
|
||||
stripeSubscriptionId: string;
|
||||
tieredFlatAmount: Int8 | null;
|
||||
tieredUnitAmount: Int8 | null;
|
||||
tieredUpTo: string | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
Submodule apps/server/src/ee updated: 70eb45eaec...19197d2610
@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import ms, { StringValue } from 'ms';
|
||||
|
||||
@Injectable()
|
||||
export class EnvironmentService {
|
||||
@ -56,7 +57,18 @@ export class EnvironmentService {
|
||||
}
|
||||
|
||||
getJwtTokenExpiresIn(): string {
|
||||
return this.configService.get<string>('JWT_TOKEN_EXPIRES_IN', '30d');
|
||||
return this.configService.get<string>('JWT_TOKEN_EXPIRES_IN', '90d');
|
||||
}
|
||||
|
||||
getCookieExpiresIn(): Date {
|
||||
const expiresInStr = this.getJwtTokenExpiresIn();
|
||||
let msUntilExpiry: number;
|
||||
try {
|
||||
msUntilExpiry = ms(expiresInStr as StringValue);
|
||||
} catch (err) {
|
||||
msUntilExpiry = ms('90d');
|
||||
}
|
||||
return new Date(Date.now() + msUntilExpiry);
|
||||
}
|
||||
|
||||
getStorageDriver(): string {
|
||||
@ -193,4 +205,12 @@ export class EnvironmentService {
|
||||
.toLowerCase();
|
||||
return disable === 'true';
|
||||
}
|
||||
|
||||
getPostHogHost(): string {
|
||||
return this.configService.get<string>('POSTHOG_HOST');
|
||||
}
|
||||
|
||||
getPostHogKey(): string {
|
||||
return this.configService.get<string>('POSTHOG_KEY');
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,6 +37,8 @@ export class StaticModule implements OnModuleInit {
|
||||
CLOUD: this.environmentService.isCloud(),
|
||||
FILE_UPLOAD_SIZE_LIMIT:
|
||||
this.environmentService.getFileUploadSizeLimit(),
|
||||
FILE_IMPORT_SIZE_LIMIT:
|
||||
this.environmentService.getFileImportSizeLimit(),
|
||||
DRAWIO_URL: this.environmentService.getDrawioUrl(),
|
||||
SUBDOMAIN_HOST: this.environmentService.isCloud()
|
||||
? this.environmentService.getSubdomainHost()
|
||||
@ -45,6 +47,8 @@ export class StaticModule implements OnModuleInit {
|
||||
BILLING_TRIAL_DAYS: this.environmentService.isCloud()
|
||||
? this.environmentService.getBillingTrialDays()
|
||||
: undefined,
|
||||
POSTHOG_HOST: this.environmentService.getPostHogHost(),
|
||||
POSTHOG_KEY: this.environmentService.getPostHogKey(),
|
||||
};
|
||||
|
||||
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "docmost",
|
||||
"homepage": "https://docmost.com",
|
||||
"version": "0.20.4",
|
||||
"version": "0.21.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nx run-many -t build",
|
||||
@ -63,15 +63,16 @@
|
||||
"bytes": "^3.1.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"dompurify": "^3.2.6",
|
||||
"fractional-indexing-jittered": "^1.0.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"jszip": "^3.10.1",
|
||||
"linkifyjs": "^4.2.0",
|
||||
"marked": "^13.0.3",
|
||||
"marked": "13.0.3",
|
||||
"ms": "3.0.0-canary.1",
|
||||
"uuid": "^11.1.0",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"yjs": "^13.6.20"
|
||||
"yjs": "^13.6.27"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nx/js": "20.4.5",
|
||||
|
||||
@ -104,6 +104,14 @@ export const embedProviders: IEmbedProvider[] = [
|
||||
return url;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "iframe",
|
||||
name: "Iframe",
|
||||
regex: /any-iframe/,
|
||||
getEmbedUrl: (match, url) => {
|
||||
return url;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function getEmbedProviderById(id: string) {
|
||||
|
||||
2787
pnpm-lock.yaml
generated
2787
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user