diff --git a/apps/app/public/static/locales/en_US/translation.json b/apps/app/public/static/locales/en_US/translation.json index 84a0dbd6c51..2176a1d0a5a 100644 --- a/apps/app/public/static/locales/en_US/translation.json +++ b/apps/app/public/static/locales/en_US/translation.json @@ -152,6 +152,8 @@ "Page Tree": "Page Tree", "Bookmarks": "Bookmarks", "In-App Notification": "Notifications", + "AI Assistant": "AI Assistant", + "Knowledge Assistant": "Knowledge Assistant", "original_path": "Original path", "new_path": "New path", "duplicated_path": "Duplicated path", @@ -499,7 +501,36 @@ "budget_exceeded_for_growi_cloud": "You have reached your OpenAI API usage limit. To use the Knowledge Assistant again, please add credits from the GROWI.cloud admin page for Hosted users or from the OpenAI billing page for Owned users.", "error_message": "An error has occurred", "show_error_detail": "Show error details" - + }, + "modal_ai_assistant": { + "default_instruction": "You are the knowledge assistant for this Wiki. Please provide support according to the following guidelines:\n\n- Analyze document relevance and connect information\n- Suggest new perspectives\n- Provide accurate information based on understanding the intent of questions\nI will provide information in a structured format when necessary.", + "page_mode_title": { + "share": "Assistant Sharing", + "pages": "Reference Pages", + "instruction": "Assistant Instructions" + }, + "access_scope": { + "owner": "All pages accessible by {{username}}", + "groups": "Specify groups", + "publicOnly": "Public pages only" + }, + "share_scope": { + "owner": { + "label": "{{username}} only" + }, + "publicOnly": { + "label": "Public", + "desc": "Shared with all users" + }, + "groups": { + "label": "Specify groups", + "desc": "Shared only with members of selected groups" + }, + "sameAsAccessScope": { + "label": "Same as page access scope", + "desc": "Shared with the same scope as page access" + } + } }, "link_edit": { "edit_link": "Edit Link", diff --git a/apps/app/public/static/locales/fr_FR/translation.json b/apps/app/public/static/locales/fr_FR/translation.json index 8f58a5f9d45..1494be3fedd 100644 --- a/apps/app/public/static/locales/fr_FR/translation.json +++ b/apps/app/public/static/locales/fr_FR/translation.json @@ -153,6 +153,8 @@ "Page Tree": "Arborescence", "Bookmarks": "Favoris", "In-App Notification": "Notifications", + "AI Assistant": "Assistant IA", + "Knowledge Assistant": "Assistant de Connaissance", "original_path": "Chemin originel", "new_path": "Nouveau chemin", "duplicated_path": "Chemin dupliqué", @@ -495,6 +497,36 @@ "error_message": "Erreur", "show_error_detail": "Détails de l'exposition" }, + "modal_ai_assistant": { + "default_instruction": "Vous êtes l'assistant de connaissances pour ce Wiki. Veuillez fournir un support selon les directives suivantes :\n\n- Analyser la pertinence des documents et relier les informations\n- Proposer de nouvelles perspectives\n- Fournir des informations précises en comprenant l'intention des questions\nJe fournirai les informations sous forme structurée si nécessaire.", + "page_mode_title": { + "share": "Partage de l'assistant", + "pages": "Pages de référence", + "instruction": "Instructions de l'assistant" + }, + "access_scope": { + "owner": "Toutes les pages accessibles par {{username}}", + "groups": "Spécifier les groupes", + "publicOnly": "Pages publiques uniquement" + }, + "share_scope": { + "owner": { + "label": "Seulement {{username}}" + }, + "publicOnly": { + "label": "Public", + "desc": "Partagé avec tous les utilisateurs" + }, + "groups": { + "label": "Spécifier les groupes", + "desc": "Partagé uniquement avec les membres des groupes sélectionnés" + }, + "sameAsAccessScope": { + "label": "Même portée que l'accès à la page", + "desc": "Partagé avec la même portée que l'accès à la page" + } + } + }, "link_edit": { "edit_link": "Modifier lien", "set_link_and_label": "Ajouter lien et étiquette", diff --git a/apps/app/public/static/locales/ja_JP/translation.json b/apps/app/public/static/locales/ja_JP/translation.json index 0d84b4c0bf6..d03f0f7e12c 100644 --- a/apps/app/public/static/locales/ja_JP/translation.json +++ b/apps/app/public/static/locales/ja_JP/translation.json @@ -153,6 +153,8 @@ "Page Tree": "ページツリー", "Bookmarks": "ブックマーク", "In-App Notification": "通知", + "AI Assistant": "AI アシスタント", + "Knowledge Assistant": "ナレッジアシスタント", "original_path": "元のパス", "new_path": "新しいパス", "duplicated_path": "重複したパス", @@ -533,6 +535,36 @@ "error_message": "エラーが発生しました", "show_error_detail": "詳細を表示" }, + "modal_ai_assistant": { + "default_instruction": "あなたはこのWikiの知識アシスタントです。以下の方針で支援を行ってください:\n\n- 文書の関連性分析と情報の関連付け\n- 新しい視点の提案\n- 質問の意図を理解した的確な情報提供 必要に応じて構造化された形式で情報を提供します。", + "page_mode_title": { + "share": "アシスタントの共有", + "pages": "参照ページ", + "instruction": "アシスタントへの指示" + }, + "access_scope": { + "owner": "{{username}} がアクセス可能な全てのページ", + "groups": "グループを指定", + "publicOnly": "公開ページのみ" + }, + "share_scope": { + "owner": { + "label": "{{username}} のみ" + }, + "publicOnly": { + "label": "全体公開", + "desc": "すべてのユーザーに共有されます" + }, + "groups": { + "label": "グループを指定", + "desc": "選択したグループのメンバーにのみ共有されます" + }, + "sameAsAccessScope": { + "label": "ページのアクセス権限と同じ範囲", + "desc": "ページのアクセス権限と同じ範囲で共有されます" + } + } + }, "link_edit": { "edit_link": "リンク編集", "set_link_and_label": "リンク情報", diff --git a/apps/app/public/static/locales/zh_CN/translation.json b/apps/app/public/static/locales/zh_CN/translation.json index c558ab9a9e7..0a1a61eae52 100644 --- a/apps/app/public/static/locales/zh_CN/translation.json +++ b/apps/app/public/static/locales/zh_CN/translation.json @@ -158,6 +158,8 @@ "Page Tree": "页面树", "Bookmarks": "书签", "In-App Notification": "通知", + "AI Assistant": "AI助手", + "Knowledge Assistant": "知识助手", "original_path": "Original path", "new_path": "New path", "duplicated_path": "Duplicated path", @@ -489,6 +491,36 @@ "error_message": "错误", "show_error_detail": "显示详情" }, + "modal_ai_assistant": { + "default_instruction": "您是这个Wiki的知识助手。请按照以下方针提供支持:\n\n- 分析文档相关性并连接信息\n- 提出新的观点\n- 理解问题意图并提供准确信息\n必要时我会以结构化的形式提供信息。", + "page_mode_title": { + "share": "助理共享", + "pages": "参考页面", + "instruction": "助理指示" + }, + "access_scope": { + "owner": "{{username}} 可访问的所有页面", + "groups": "指定群组", + "publicOnly": "仅公开页面" + }, + "share_scope": { + "owner": { + "label": "仅 {{ username }}" + }, + "publicOnly": { + "label": "公开", + "desc": "与所有用户共享" + }, + "groups": { + "label": "指定群组", + "desc": "仅与选定组的成员共享" + }, + "sameAsAccessScope": { + "label": "与页面访问范围相同", + "desc": "与页面访问范围相同的范围共享" + } + } + }, "link_edit": { "edit_link": "Edit Link", "set_link_and_label": "Set link and label", diff --git a/apps/app/src/client/components/PageHeader/PagePathHeader.tsx b/apps/app/src/client/components/PageHeader/PagePathHeader.tsx index 7975712cdcc..12799a13987 100644 --- a/apps/app/src/client/components/PageHeader/PagePathHeader.tsx +++ b/apps/app/src/client/components/PageHeader/PagePathHeader.tsx @@ -3,6 +3,8 @@ import { useState, useCallback, memo, } from 'react'; +import nodePath from 'path'; + import type { IPagePopulatedToShowRevision } from '@growi/core'; import { DevidedPagePath } from '@growi/core/dist/models'; import { normalizePath } from '@growi/core/dist/utils/path-utils'; @@ -11,13 +13,13 @@ import { debounce } from 'throttle-debounce'; import type { InputValidationResult } from '~/client/util/use-input-validator'; import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator'; +import type { IPageForItem } from '~/interfaces/page'; import LinkedPagePath from '~/models/linked-page-path'; import { usePageSelectModal } from '~/stores/modal'; import { PagePathHierarchicalLink } from '../../../components/Common/PagePathHierarchicalLink'; import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput'; import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils'; -import { PageSelectModal } from '../PageSelectModal/PageSelectModal'; import styles from './PagePathHeader.module.scss'; @@ -45,8 +47,7 @@ export const PagePathHeader = memo((props: Props): JSX.Element => { const [isRenameInputShown, setRenameInputShown] = useState(false); const [isHover, setHover] = useState(false); - const { data: PageSelectModalData, open: openPageSelectModal } = usePageSelectModal(); - const isOpened = PageSelectModalData?.isOpened ?? false; + const { open: openPageSelectModal } = usePageSelectModal(); const [validationResult, setValidationResult] = useState(); @@ -61,6 +62,20 @@ export const PagePathHeader = memo((props: Props): JSX.Element => { const pagePathRenameHandler = usePagePathRenameHandler(currentPage); + const onClickOpenPageSelectModalButton = useCallback(() => { + const onSelected = (page: IPageForItem): void => { + if (page == null || page.path == null) { + return; + } + + const currentPageTitle = nodePath.basename(currentPage?.path ?? '') || '/'; + const newPagePath = nodePath.resolve(page.path, currentPageTitle); + + pagePathRenameHandler(newPagePath); + }; + + openPageSelectModal({ onSelected }); + }, [currentPage?.path, openPageSelectModal, pagePathRenameHandler]); const rename = useCallback((inputText) => { const pathToRename = normalizePath(`${inputText}/${dPagePath.latter}`); @@ -144,13 +159,11 @@ export const PagePathHeader = memo((props: Props): JSX.Element => { - - {isOpened && } ); }); diff --git a/apps/app/src/client/components/PageSelectModal/PageSelectModal.tsx b/apps/app/src/client/components/PageSelectModal/PageSelectModal.tsx index 5c927ae2f7a..d253ea2c97e 100644 --- a/apps/app/src/client/components/PageSelectModal/PageSelectModal.tsx +++ b/apps/app/src/client/components/PageSelectModal/PageSelectModal.tsx @@ -19,71 +19,62 @@ import { useSWRxCurrentPage } from '~/stores/page'; import { ItemsTree } from '../ItemsTree'; import ItemsTreeContentSkeleton from '../ItemsTree/ItemsTreeContentSkeleton'; -import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils'; import { TreeItemForModal } from './TreeItemForModal'; - -export const PageSelectModal: FC = () => { +const PageSelectModalSubstance: FC = () => { const { data: PageSelectModalData, close: closeModal, } = usePageSelectModal(); - const isOpened = PageSelectModalData?.isOpened ?? false; - - const [clickedParentPagePath, setClickedParentPagePath] = useState(null); + const [clickedParentPage, setClickedParentPage] = useState(null); + const [isIncludeSubPage, setIsIncludeSubPage] = useState(true); const { t } = useTranslation(); const { data: isGuestUser } = useIsGuestUser(); const { data: isReadOnlyUser } = useIsReadOnlyUser(); const { data: currentPage } = useSWRxCurrentPage(); + const { data: pageSelectModalData } = usePageSelectModal(); - const pagePathRenameHandler = usePagePathRenameHandler(currentPage); + const isHierarchicalSelectionMode = pageSelectModalData?.opts?.isHierarchicalSelectionMode ?? false; const onClickTreeItem = useCallback((page: IPageForItem) => { const parentPagePath = page.path; if (parentPagePath == null) { - return <>; + return; } - setClickedParentPagePath(parentPagePath); + setClickedParentPage(page); }, []); const onClickCancel = useCallback(() => { - setClickedParentPagePath(null); + setClickedParentPage(null); closeModal(); }, [closeModal]); const onClickDone = useCallback(() => { - if (clickedParentPagePath != null) { - const currentPageTitle = nodePath.basename(currentPage?.path ?? '') || '/'; - const newPagePath = nodePath.resolve(clickedParentPagePath, currentPageTitle); - - pagePathRenameHandler(newPagePath); + if (clickedParentPage != null) { + PageSelectModalData?.opts?.onSelected?.(clickedParentPage, isIncludeSubPage); } closeModal(); - }, [clickedParentPagePath, closeModal, currentPage?.path, pagePathRenameHandler]); + }, [PageSelectModalData?.opts, clickedParentPage, closeModal, isIncludeSubPage]); const parentPagePath = pathUtils.addTrailingSlash(nodePath.dirname(currentPage?.path ?? '')); - const targetPathOrId = clickedParentPagePath || parentPagePath; + const targetPathOrId = clickedParentPage?.path || parentPagePath; - const targetPath = clickedParentPagePath || parentPagePath; + const targetPath = clickedParentPage?.path || parentPagePath; if (isGuestUser == null) { return <>; } return ( - + <> {t('page_select_modal.select_page_location')} }> @@ -101,10 +92,45 @@ export const PageSelectModal: FC = () => { - - - + + { isHierarchicalSelectionMode && ( +
+ setIsIncludeSubPage(!isIncludeSubPage)} + /> + +
+ )} +
+ + +
+ + ); +}; + +export const PageSelectModal = (): JSX.Element => { + const { data: pageSelectModalData, close: closePageSelectModal } = usePageSelectModal(); + const isOpen = pageSelectModalData?.isOpened ?? false; + + if (!isOpen) { + return <>; + } + + return ( + + ); }; diff --git a/apps/app/src/client/components/Sidebar/SidebarContents.tsx b/apps/app/src/client/components/Sidebar/SidebarContents.tsx index 98e2b385851..698889a1c8a 100644 --- a/apps/app/src/client/components/Sidebar/SidebarContents.tsx +++ b/apps/app/src/client/components/Sidebar/SidebarContents.tsx @@ -1,5 +1,6 @@ import React, { memo, useMemo } from 'react'; +import { AiAssistant } from '~/features/openai/client/components/AiAssistant/Sidebar/AiAssistant'; import { SidebarContentsType } from '~/interfaces/ui'; import { useCollapsedContentsOpened, useCurrentSidebarContents, useSidebarMode } from '~/stores/ui'; @@ -32,6 +33,8 @@ export const SidebarContents = memo(() => { return Bookmarks; case SidebarContentsType.NOTIFICATION: return InAppNotification; + case SidebarContentsType.AI_ASSISTANT: + return AiAssistant; default: return PageTree; } diff --git a/apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItem.tsx b/apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItem.tsx index 9edb7d577b0..4a4bd065358 100644 --- a/apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItem.tsx +++ b/apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItem.tsx @@ -22,6 +22,7 @@ export type PrimaryItemProps = { label: string, iconName: string, sidebarMode: SidebarMode, + isCustomIcon?: boolean, badgeContents?: number, onHover?: (contents: SidebarContentsType) => void, onClick?: () => void, @@ -29,7 +30,7 @@ export type PrimaryItemProps = { export const PrimaryItem = (props: PrimaryItemProps): JSX.Element => { const { - contents, label, iconName, sidebarMode, badgeContents, + contents, label, iconName, sidebarMode, badgeContents, isCustomIcon, onClick, onHover, } = props; @@ -80,7 +81,10 @@ export const PrimaryItem = (props: PrimaryItemProps): JSX.Element => { { badgeContents != null && ( {badgeContents} )} - {iconName} + { isCustomIcon + ? ({iconName}) + : ({iconName}) + } { diff --git a/apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItems.tsx b/apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItems.tsx index eb863a5df14..d4ef64cad87 100644 --- a/apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItems.tsx +++ b/apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItems.tsx @@ -3,6 +3,7 @@ import { memo } from 'react'; import dynamic from 'next/dynamic'; import { SidebarContentsType } from '~/interfaces/ui'; +import { useIsAiEnabled } from '~/stores-universal/context'; import { useSidebarMode } from '~/stores/ui'; import { PrimaryItem } from './PrimaryItem'; @@ -22,6 +23,7 @@ export const PrimaryItems = memo((props: Props) => { const { onItemHover } = props; const { data: sidebarMode } = useSidebarMode(); + const { data: isAiEnabled } = useIsAiEnabled(); if (sidebarMode == null) { return <>; @@ -35,6 +37,16 @@ export const PrimaryItems = memo((props: Props) => { + {isAiEnabled && ( + + )} ); }); diff --git a/apps/app/src/components/Layout/BasicLayout.tsx b/apps/app/src/components/Layout/BasicLayout.tsx index d7f19e63c40..8f50ce4385b 100644 --- a/apps/app/src/components/Layout/BasicLayout.tsx +++ b/apps/app/src/components/Layout/BasicLayout.tsx @@ -8,6 +8,12 @@ import { RawLayout } from './RawLayout'; import styles from './BasicLayout.module.scss'; +const AiAssistantChatSidebar = dynamic( + () => import('~/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar') + .then(mod => mod.AiAssistantChatSidebar), { ssr: false }, +); + + const moduleClass = styles['grw-basic-layout'] ?? ''; @@ -35,6 +41,11 @@ const DeleteBookmarkFolderModal = dynamic( ); const SearchModal = dynamic(() => import('../../features/search/client/components/SearchModal'), { ssr: false }); const AiChatModal = dynamic(() => import('~/features/openai/chat/components/AiChatModal').then(mod => mod.AiChatModal), { ssr: false }); +const AiAssistantManagementModal = dynamic( + () => import('~/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal') + .then(mod => mod.AiAssistantManagementModal), { ssr: false }, +); +const PageSelectModal = dynamic(() => import('~/client/components/PageSelectModal/PageSelectModal').then(mod => mod.PageSelectModal), { ssr: false }); type Props = { children?: ReactNode @@ -54,6 +65,8 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => { {children} + + @@ -66,8 +79,10 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => { + + diff --git a/apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.tsx b/apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.tsx index 163bbf25a9f..15b84ce3347 100644 --- a/apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.tsx +++ b/apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.tsx @@ -1,12 +1,12 @@ import type { KeyboardEvent } from 'react'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { useForm, Controller } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { Collapse, Modal, ModalBody, ModalFooter, ModalHeader, - UncontrolledTooltip, + UncontrolledTooltip, Input, } from 'reactstrap'; import { apiv3Post } from '~/client/util/apiv3-client'; @@ -14,6 +14,7 @@ import { toastError } from '~/client/util/toastr'; import { useGrowiCloudUri } from '~/stores-universal/context'; import loggerFactory from '~/utils/logger'; +import { SelectedPageList } from '../../../client/components/Common/SelectedPageList'; import { useRagSearchModal } from '../../../client/stores/rag-search'; import { MessageErrorCode, StreamErrorCode } from '../../../interfaces/message-error'; @@ -193,7 +194,46 @@ const AiChatModalSubstance = (): JSX.Element => { return ( <> -
+
+ + + + + + + +
+ +
+ ここに設定したアシスタントの説明が入ります。ここに設定したアシスタントの説明が入ります。 +
+ +
+

アシスタントへの指示

+
+

+ あなたは生成AIの専門家および、リサーチャーです。ナレッジベースのWikiツールである GROWIのAI機能に関する情報を提示したり、使われている技術に関する説明をしたりします。 +

+
+
+ +
+

参照するページ

+ help +
+ + +
{ messageLogs.map(message => ( {message.content} )) } @@ -315,7 +355,7 @@ export const AiChatModal = (): JSX.Element => { - knowledge_assistant + ai_assistant {t('modal_aichat.title')} {t('modal_aichat.title_beta_label')} diff --git a/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.module.scss b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.module.scss new file mode 100644 index 00000000000..03d7ff83665 --- /dev/null +++ b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.module.scss @@ -0,0 +1,14 @@ +@use '@growi/core-styles/scss/bootstrap/init' as bs; +@use '@growi/core-styles/scss/variables/growi-official-colors'; +@use '@growi/ui/scss/atoms/btn-muted'; + +// == Colors +.grw-ai-assistant-chat-sidebar :global { + .growi-ai-chat-icon { + color: growi-official-colors.$growi-ai-purple; + } + + .btn-submit { + @include btn-muted.colorize(bs.$purple, bs.$purple); + } +} diff --git a/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.tsx b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.tsx new file mode 100644 index 00000000000..80032306301 --- /dev/null +++ b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.tsx @@ -0,0 +1,90 @@ +import { + type FC, memo, useRef, useEffect, +} from 'react'; + +import SimpleBar from 'simplebar-react'; + +import type { AiAssistantHasId } from '../../../../interfaces/ai-assistant'; +import { useAiAssistantChatSidebar } from '../../../stores/ai-assistant'; + +import styles from './AiAssistantChatSidebar.module.scss'; + +const moduleClass = styles['grw-ai-assistant-chat-sidebar'] ?? ''; + +const RIGHT_SIDEBAR_WIDTH = 500; + +type AiAssistantChatSidebarSubstanceProps = { + aiAssistantData?: AiAssistantHasId; + closeAiAssistantChatSidebar: () => void +} + +const AiAssistantChatSidebarSubstance: React.FC = (props: AiAssistantChatSidebarSubstanceProps) => { + const { aiAssistantData, closeAiAssistantChatSidebar } = props; + + return ( + <> +
+ ai_assistant +
{aiAssistantData?.name}
+ +
+ +
+ {/* AI Chat Screen Implementation */} + {/* TODO: https://redmine.weseek.co.jp/issues/161511 */} +
+ + ); +}; + + +export const AiAssistantChatSidebar: FC = memo((): JSX.Element => { + const sidebarRef = useRef(null); + const sidebarScrollerRef = useRef(null); + + const { data: aiAssistantChatSidebarData, close: closeAiAssistantChatSidebar } = useAiAssistantChatSidebar(); + const isOpened = aiAssistantChatSidebarData?.isOpened ?? false; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (isOpened && sidebarRef.current && !sidebarRef.current.contains(event.target as Node)) { + closeAiAssistantChatSidebar(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [closeAiAssistantChatSidebar, isOpened]); + + return ( + <> + {isOpened && ( +
+ + + +
+ )} + + ); +}); diff --git a/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AccessScopeDropdown.tsx b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AccessScopeDropdown.tsx new file mode 100644 index 00000000000..e894494ecb1 --- /dev/null +++ b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AccessScopeDropdown.tsx @@ -0,0 +1,66 @@ +import React, { useCallback } from 'react'; + +import { useTranslation } from 'react-i18next'; +import { + UncontrolledDropdown, DropdownToggle, DropdownMenu, DropdownItem, Label, +} from 'reactstrap'; + +import { useCurrentUser } from '~/stores-universal/context'; + +import { AiAssistantAccessScope } from '../../../../interfaces/ai-assistant'; + +type Props = { + isDisabled: boolean, + isDisabledGroups: boolean, + selectedAccessScope: AiAssistantAccessScope, + onSelect: (accessScope: AiAssistantAccessScope) => void, +} + +export const AccessScopeDropdown: React.FC = (props: Props) => { + const { + isDisabled, + isDisabledGroups, + selectedAccessScope, + onSelect, + } = props; + + const { t } = useTranslation(); + const { data: currentUser } = useCurrentUser(); + + const getAccessScopeLabel = useCallback((accessScope: AiAssistantAccessScope) => { + const baseLabel = `modal_ai_assistant.access_scope.${accessScope}`; + return accessScope === AiAssistantAccessScope.OWNER + ? t(baseLabel, { username: currentUser?.username }) + : t(baseLabel); + }, [currentUser?.username, t]); + + const selectAccessScopeHandler = useCallback((accessScope: AiAssistantAccessScope) => { + onSelect(accessScope); + }, [onSelect]); + + return ( +
+ + + + {getAccessScopeLabel(selectedAccessScope)} + + + { [AiAssistantAccessScope.OWNER, AiAssistantAccessScope.GROUPS, AiAssistantAccessScope.PUBLIC_ONLY].map(accessScope => ( + selectAccessScopeHandler(accessScope)} + key={accessScope} + > + {getAccessScopeLabel(accessScope)} + + ))} + + +
+ ); +}; diff --git a/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditInstruction.tsx b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditInstruction.tsx new file mode 100644 index 00000000000..c33879ff589 --- /dev/null +++ b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditInstruction.tsx @@ -0,0 +1,39 @@ +import { ModalBody, Input } from 'reactstrap'; + +import { AiAssistantManagementHeader } from './AiAssistantManagementHeader'; + +type Props = { + instruction: string; + onChange: (value: string) => void; + onReset: () => void; +} + +export const AiAssistantManagementEditInstruction = (props: Props): JSX.Element => { + const { instruction, onChange, onReset } = props; + + return ( + <> + + + +

+ アシスタントの振る舞いを決める指示文を設定できます。
+ この指示に従ってにアシスタントの回答や分析を行います。 +

+ + onChange(e.target.value)} + /> + + +
+ + ); +}; diff --git a/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditPages.tsx b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditPages.tsx new file mode 100644 index 00000000000..0bb17b536e8 --- /dev/null +++ b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditPages.tsx @@ -0,0 +1,52 @@ +import React, { useCallback } from 'react'; + +import { ModalBody } from 'reactstrap'; + +import type { IPageForItem } from '~/interfaces/page'; +import { usePageSelectModal } from '~/stores/modal'; + +import type { SelectedPage } from '../../../../interfaces/selected-page'; +import { SelectedPageList } from '../../Common/SelectedPageList'; + +import { AiAssistantManagementHeader } from './AiAssistantManagementHeader'; + + +type Props = { + selectedPages: SelectedPage[]; + onSelect: (page: IPageForItem, isIncludeSubPage: boolean) => void; + onRemove: (pageId: string) => void; +} + +export const AiAssistantManagementEditPages = (props: Props): JSX.Element => { + const { selectedPages, onSelect, onRemove } = props; + + const { open: openPageSelectModal } = usePageSelectModal(); + + const clickOpenPageSelectModalHandler = useCallback(() => { + openPageSelectModal({ onSelected: onSelect, isHierarchicalSelectionMode: true }); + }, [onSelect, openPageSelectModal]); + + return ( + <> + + + +

+ アシスタントが参照するページを編集します。
+ 参照できるページは配下ページも含めて200ページまでです。 +

+ + + + +
+ + ); +}; diff --git a/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditShare.tsx b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditShare.tsx new file mode 100644 index 00000000000..f092c496ad6 --- /dev/null +++ b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditShare.tsx @@ -0,0 +1,136 @@ +import React, { useCallback, useState } from 'react'; + +import { + ModalBody, Input, Label, +} from 'reactstrap'; + +import { AiAssistantShareScope, AiAssistantAccessScope } from '~/features/openai/interfaces/ai-assistant'; +import type { PopulatedGrantedGroup } from '~/interfaces/page-grant'; +import { useSWRxUserRelatedGroups } from '~/stores/user'; + +import { AccessScopeDropdown } from './AccessScopeDropdown'; +import { AiAssistantManagementHeader } from './AiAssistantManagementHeader'; +import { SelectUserGroupModal } from './SelectUserGroupModal'; +import { ShareScopeSwitch } from './ShareScopeSwitch'; + +const ScopeType = { + ACCESS: 'Access', + SHARE: 'Share', +} as const; + +type ScopeType = typeof ScopeType[keyof typeof ScopeType]; + +type Props = { + selectedShareScope: AiAssistantShareScope, + selectedAccessScope: AiAssistantAccessScope, + selectedUserGroupsForShareScope: PopulatedGrantedGroup[], + selectedUserGroupsForAccessScope: PopulatedGrantedGroup[], + onSelectShareScope: (scope: AiAssistantShareScope) => void, + onSelectAccessScope: (scope: AiAssistantAccessScope) => void, + onSelectShareScopeUserGroups: (userGroup: PopulatedGrantedGroup) => void, + onSelectAccessScopeUserGroups: (userGroup: PopulatedGrantedGroup) => void, +} + +export const AiAssistantManagementEditShare = (props: Props): JSX.Element => { + const { + selectedShareScope, + selectedAccessScope, + selectedUserGroupsForShareScope, + selectedUserGroupsForAccessScope, + onSelectShareScope, + onSelectAccessScope, + onSelectShareScopeUserGroups, + onSelectAccessScopeUserGroups, + } = props; + + const { data: userRelatedGroups } = useSWRxUserRelatedGroups(); + const hasNoRelatedGroups = userRelatedGroups == null || userRelatedGroups.relatedGroups.length === 0; + + const [isShared, setIsShared] = useState(false); + const [isSelectUserGroupModalOpen, setIsSelectUserGroupModalOpen] = useState(false); + const [selectedUserGroupType, setSelectedUserGroupType] = useState(ScopeType.ACCESS); + + const changeShareToggleHandler = useCallback(() => { + setIsShared((prev) => { + if (prev) { // if isShared === true + onSelectShareScope(AiAssistantShareScope.SAME_AS_ACCESS_SCOPE); + onSelectAccessScope(AiAssistantAccessScope.OWNER); + } + else { + onSelectShareScope(AiAssistantShareScope.PUBLIC_ONLY); + } + return !prev; + }); + }, [onSelectAccessScope, onSelectShareScope]); + + const selectGroupScopeHandler = useCallback((scopeType: ScopeType) => { + setSelectedUserGroupType(scopeType); + setIsSelectUserGroupModalOpen(true); + }, []); + + const selectShareScopeHandler = useCallback((shareScope: AiAssistantShareScope) => { + onSelectShareScope(shareScope); + if (shareScope === AiAssistantShareScope.GROUPS && !hasNoRelatedGroups) { + selectGroupScopeHandler(ScopeType.SHARE); + } + }, [hasNoRelatedGroups, onSelectShareScope, selectGroupScopeHandler]); + + const selectAccessScopeHandler = useCallback((accessScope: AiAssistantAccessScope) => { + onSelectAccessScope(accessScope); + if (accessScope === AiAssistantAccessScope.GROUPS && !hasNoRelatedGroups) { + selectGroupScopeHandler(ScopeType.ACCESS); + } + }, [hasNoRelatedGroups, onSelectAccessScope, selectGroupScopeHandler]); + + + return ( + <> + + + +
+ + +
+ + + + + + setIsSelectUserGroupModalOpen(false)} + selectedUserGroups={selectedUserGroupType === ScopeType.ACCESS ? selectedUserGroupsForAccessScope : selectedUserGroupsForShareScope} + onSelect={(userGroup) => { + if (selectedUserGroupType === ScopeType.ACCESS) { + onSelectAccessScopeUserGroups(userGroup); + } + else { + onSelectShareScopeUserGroups(userGroup); + } + }} + /> +
+ + ); +}; diff --git a/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHeader.tsx b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHeader.tsx new file mode 100644 index 00000000000..ada9df04d6a --- /dev/null +++ b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHeader.tsx @@ -0,0 +1,26 @@ +import { useTranslation } from 'react-i18next'; +import { ModalHeader } from 'reactstrap'; + +import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode } from '../../../stores/ai-assistant'; + +export const AiAssistantManagementHeader = (): JSX.Element => { + const { t } = useTranslation(); + const { data, close, changePageMode } = useAiAssistantManagementModal(); + + return ( + + close + + )} + > +
+ + {t(`modal_ai_assistant.page_mode_title.${data?.pageMode}`)} +
+
+ ); +}; diff --git a/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHome.tsx b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHome.tsx new file mode 100644 index 00000000000..8546b832857 --- /dev/null +++ b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHome.tsx @@ -0,0 +1,151 @@ +import React, { useCallback, useState } from 'react'; + +import { useTranslation } from 'react-i18next'; +import { + ModalHeader, ModalBody, ModalFooter, Input, +} from 'reactstrap'; + +import { AiAssistantShareScope } from '~/features/openai/interfaces/ai-assistant'; +import { useCurrentUser } from '~/stores-universal/context'; + +import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode } from '../../../stores/ai-assistant'; + +import { ShareScopeWarningModal } from './ShareScopeWarningModal'; + +type Props = { + name: string; + description: string; + instruction: string; + shareScope: AiAssistantShareScope + onNameChange: (value: string) => void; + onDescriptionChange: (value: string) => void; + onCreateAiAssistant: () => Promise +} + +export const AiAssistantManagementHome = (props: Props): JSX.Element => { + const { + name, + description, + instruction, + shareScope, + onNameChange, + onDescriptionChange, + onCreateAiAssistant, + } = props; + + const { t } = useTranslation(); + const { data: currentUser } = useCurrentUser(); + const { close: closeAiAssistantManagementModal, changePageMode } = useAiAssistantManagementModal(); + + const [isShareScopeWarningModalOpen, setIsShareScopeWarningModalOpen] = useState(false); + + const getShareScopeLabel = useCallback((shareScope: AiAssistantShareScope) => { + const baseLabel = `modal_ai_assistant.share_scope.${shareScope}.label`; + return shareScope === AiAssistantShareScope.OWNER + ? t(baseLabel, { username: currentUser?.username }) + : t(baseLabel); + }, [currentUser?.username, t]); + + const createAiAssistantHandler = useCallback(async() => { + // TODO: Implement the logic to check if the assistant has a share scope that includes private pages + // task: https://redmine.weseek.co.jp/issues/161341 + if (true) { + setIsShareScopeWarningModalOpen(true); + return; + } + + await onCreateAiAssistant(); + }, [onCreateAiAssistant]); + + return ( + <> + + ai_assistant + 新規アシスタントの追加 {/* TODO i18n */} + + +
+ +
+ onNameChange(e.target.value)} + /> +
+ +
+
+ アシスタントのメモ + 任意 +
+ onDescriptionChange(e.target.value)} + /> + + メモの内容はアシスタントの処理に影響しません。 + +
+ +
+ + + + + +
+
+ + + + + +
+ + setIsShareScopeWarningModalOpen(false)} + onSubmit={onCreateAiAssistant} + /> + + ); +}; diff --git a/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.module.scss b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.module.scss new file mode 100644 index 00000000000..bad98fa652a --- /dev/null +++ b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.module.scss @@ -0,0 +1,15 @@ +@use '@growi/core-styles/scss/variables/growi-official-colors'; +@use '@growi/core-styles/scss/bootstrap/init' as bs; + +// == Colors +.grw-ai-assistant-management :global { + .growi-ai-assistant-icon { + color: growi-official-colors.$growi-ai-purple; + } + .growi-ai-assistant-name { + .form-control:focus { + border-color: var(--bs-primary) !important; + box-shadow: none; + } + } +} diff --git a/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx new file mode 100644 index 00000000000..1d06cd1d8c9 --- /dev/null +++ b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx @@ -0,0 +1,231 @@ +import React, { useCallback, useState } from 'react'; + +import type { IGrantedGroup } from '@growi/core'; +import { useTranslation } from 'react-i18next'; +import { Modal, TabContent, TabPane } from 'reactstrap'; + +import { toastError, toastSuccess } from '~/client/util/toastr'; +import { AiAssistantAccessScope, AiAssistantShareScope } from '~/features/openai/interfaces/ai-assistant'; +import type { IPageForItem } from '~/interfaces/page'; +import type { PopulatedGrantedGroup } from '~/interfaces/page-grant'; +import loggerFactory from '~/utils/logger'; + +import type { SelectedPage } from '../../../../interfaces/selected-page'; +import { createAiAssistant } from '../../../services/ai-assistant'; +import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode, useSWRxAiAssistants } from '../../../stores/ai-assistant'; + +import { AiAssistantManagementEditInstruction } from './AiAssistantManagementEditInstruction'; +import { AiAssistantManagementEditPages } from './AiAssistantManagementEditPages'; +import { AiAssistantManagementEditShare } from './AiAssistantManagementEditShare'; +import { AiAssistantManagementHome } from './AiAssistantManagementHome'; + +import styles from './AiAssistantManagementModal.module.scss'; + +const moduleClass = styles['grw-ai-assistant-management'] ?? ''; + +const logger = loggerFactory('growi:openai:client:components:AiAssistantManagementModal'); + +// PopulatedGrantedGroup[] -> IGrantedGroup[] +const convertToGrantedGroups = (selectedGroups: PopulatedGrantedGroup[]): IGrantedGroup[] => { + return selectedGroups.map(group => ({ + type: group.type, + item: group.item._id, + })); +}; + +const AiAssistantManagementModalSubstance = (): JSX.Element => { + // Hooks + const { t } = useTranslation(); + const { mutate: mutateAiAssistants } = useSWRxAiAssistants(); + const { data: aiAssistantManagementModalData, close: closeAiAssistantManagementModal } = useAiAssistantManagementModal(); + + const pageMode = aiAssistantManagementModalData?.pageMode ?? AiAssistantManagementModalPageMode.HOME; + + // States + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [selectedShareScope, setSelectedShareScope] = useState(AiAssistantShareScope.SAME_AS_ACCESS_SCOPE); + const [selectedAccessScope, setSelectedAccessScope] = useState(AiAssistantAccessScope.OWNER); + const [selectedUserGroupsForAccessScope, setSelectedUserGroupsForAccessScope] = useState([]); + const [selectedUserGroupsForShareScope, setSelectedUserGroupsForShareScope] = useState([]); + const [selectedPages, setSelectedPages] = useState([]); + const [instruction, setInstruction] = useState(t('modal_ai_assistant.default_instruction')); + + + /* + * For AiAssistantManagementHome methods + */ + const changeNameHandler = useCallback((value: string) => { + setName(value); + }, []); + + const changeDescriptionHandler = useCallback((value: string) => { + setDescription(value); + }, []); + + const createAiAssistantHandler = useCallback(async() => { + try { + const pagePathPatterns = selectedPages + .map(selectedPage => (selectedPage.isIncludeSubPage ? `${selectedPage.page.path}/*` : selectedPage.page.path)) + .filter((path): path is string => path !== undefined && path !== null); + + const grantedGroupsForShareScope = convertToGrantedGroups(selectedUserGroupsForShareScope); + const grantedGroupsForAccessScope = convertToGrantedGroups(selectedUserGroupsForAccessScope); + + await createAiAssistant({ + name, + description, + additionalInstruction: instruction, + pagePathPatterns, + shareScope: selectedShareScope, + accessScope: selectedAccessScope, + grantedGroupsForShareScope: selectedShareScope === AiAssistantShareScope.GROUPS ? grantedGroupsForShareScope : undefined, + grantedGroupsForAccessScope: selectedAccessScope === AiAssistantAccessScope.GROUPS ? grantedGroupsForAccessScope : undefined, + }); + + toastSuccess('アシスタントを作成しました'); + mutateAiAssistants(); + closeAiAssistantManagementModal(); + } + catch (err) { + toastError('アシスタントの作成に失敗しました'); + logger.error(err); + } + }, [ + mutateAiAssistants, + closeAiAssistantManagementModal, + description, + instruction, + name, + selectedAccessScope, + selectedPages, + selectedShareScope, + selectedUserGroupsForAccessScope, + selectedUserGroupsForShareScope, + ]); + + + /* + * For AiAssistantManagementEditShare methods + */ + const selectShareScopeHandler = useCallback((shareScope: AiAssistantShareScope) => { + setSelectedShareScope(shareScope); + }, []); + + const selectAccessScopeHandler = useCallback((accessScope: AiAssistantAccessScope) => { + setSelectedAccessScope(accessScope); + }, []); + + const selectShareScopeUserGroups = useCallback((targetUserGroup: PopulatedGrantedGroup) => { + const selectedUserGroupIds = selectedUserGroupsForShareScope.map(userGroup => userGroup.item._id); + if (selectedUserGroupIds.includes(targetUserGroup.item._id)) { + // if selected, remove it + setSelectedUserGroupsForShareScope(selectedUserGroupsForShareScope.filter(userGroup => userGroup.item._id !== targetUserGroup.item._id)); + } + else { + // if not selected, add it + setSelectedUserGroupsForShareScope([...selectedUserGroupsForShareScope, targetUserGroup]); + } + }, [selectedUserGroupsForShareScope]); + + const selectAccessScopeUserGroups = useCallback((targetUserGroup: PopulatedGrantedGroup) => { + const selectedUserGroupIds = selectedUserGroupsForAccessScope.map(userGroup => userGroup.item._id); + if (selectedUserGroupIds.includes(targetUserGroup.item._id)) { + // if selected, remove it + setSelectedUserGroupsForAccessScope(selectedUserGroupsForAccessScope.filter(userGroup => userGroup.item._id !== targetUserGroup.item._id)); + } + else { + // if not selected, add it + setSelectedUserGroupsForAccessScope([...selectedUserGroupsForAccessScope, targetUserGroup]); + } + }, [selectedUserGroupsForAccessScope]); + + + /* + * For AiAssistantManagementEditPages methods + */ + const selectPageHandler = useCallback((page: IPageForItem, isIncludeSubPage: boolean) => { + const selectedPageIds = selectedPages.map(selectedPage => selectedPage.page._id); + if (page._id != null && !selectedPageIds.includes(page._id)) { + setSelectedPages([...selectedPages, { page, isIncludeSubPage }]); + } + }, [selectedPages]); + + const removePageHandler = useCallback((pageId: string) => { + setSelectedPages(selectedPages.filter(selectedPage => selectedPage.page._id !== pageId)); + }, [selectedPages]); + + + /* + * For AiAssistantManagementEditInstruction methods + */ + const changeInstructionHandler = useCallback((value: string) => { + setInstruction(value); + }, []); + + const resetInstructionHandler = useCallback(() => { + setInstruction(t('modal_ai_assistant.default_instruction')); + }, [t]); + + return ( + <> + + + + + + + + + + + + + + + + + + + ); +}; + + +export const AiAssistantManagementModal = (): JSX.Element => { + const { data: aiAssistantManagementModalData, close: closeAiAssistantManagementModal } = useAiAssistantManagementModal(); + + const isOpened = aiAssistantManagementModalData?.isOpened ?? false; + + return ( + + { isOpened && ( + + ) } + + ); +}; diff --git a/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectUserGroupModal.tsx b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectUserGroupModal.tsx new file mode 100644 index 00000000000..de3d96d0fa7 --- /dev/null +++ b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectUserGroupModal.tsx @@ -0,0 +1,74 @@ +import React, { useCallback } from 'react'; + +import { GroupType } from '@growi/core'; +import { useTranslation } from 'react-i18next'; +import { + Modal, ModalHeader, ModalBody, +} from 'reactstrap'; + +import type { PopulatedGrantedGroup } from '~/interfaces/page-grant'; + +type Props = { + isOpen: boolean, + userRelatedGroups?: PopulatedGrantedGroup[], + selectedUserGroups: PopulatedGrantedGroup[], + closeModal: () => void, + onSelect: (userGroup: PopulatedGrantedGroup) => void, +} + +const SelectUserGroupModalSubstance: React.FC = (props: Props) => { + const { + userRelatedGroups, + selectedUserGroups, + onSelect, + closeModal, + } = props; + + const { t } = useTranslation(); + + const checked = useCallback((targetUserGroup: PopulatedGrantedGroup) => { + const selectedUserGroupIds = selectedUserGroups.map(userGroup => userGroup.item._id); + return selectedUserGroupIds.includes(targetUserGroup.item._id); + }, [selectedUserGroups]); + + return ( + + {userRelatedGroups != null && userRelatedGroups.map(userGroup => ( + + ))} + + + + ); +}; + +export const SelectUserGroupModal: React.FC = (props) => { + const { t } = useTranslation(); + + const { isOpen, closeModal } = props; + + return ( + + + {t('user_group.select_group')} + + + + ); +}; diff --git a/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeSwitch.tsx b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeSwitch.tsx new file mode 100644 index 00000000000..9ee9188cd4d --- /dev/null +++ b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeSwitch.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +import { useTranslation } from 'react-i18next'; +import { + Input, Label, FormGroup, +} from 'reactstrap'; + +import { AiAssistantShareScope } from '../../../../interfaces/ai-assistant'; + +type Props = { + isDisabled: boolean, + isDisabledGroups: boolean, + selectedShareScope: AiAssistantShareScope, + onSelect: (shareScope: AiAssistantShareScope) => void, +} + +export const ShareScopeSwitch: React.FC = (props: Props) => { + const { + isDisabled, + isDisabledGroups, + selectedShareScope, + onSelect, + } = props; + + const { t } = useTranslation(); + + return ( +
+ +
+ + {[AiAssistantShareScope.PUBLIC_ONLY, AiAssistantShareScope.GROUPS, AiAssistantShareScope.SAME_AS_ACCESS_SCOPE].map(shareScope => ( + + onSelect(shareScope)} + checked={selectedShareScope === shareScope} + /> + + + ))} +
+
+ ); +}; diff --git a/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeWarningModal.tsx b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeWarningModal.tsx new file mode 100644 index 00000000000..8956365b14c --- /dev/null +++ b/apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeWarningModal.tsx @@ -0,0 +1,71 @@ +import React, { useCallback } from 'react'; + +import { + Modal, ModalHeader, ModalBody, ModalFooter, +} from 'reactstrap'; + +type Props = { + isOpen: boolean, + closeModal: () => void, + onSubmit: () => Promise, +} + +export const ShareScopeWarningModal = (props: Props): JSX.Element => { + const { + isOpen, + closeModal, + onSubmit, + } = props; + + const createAiAssistantHandler = useCallback(() => { + closeModal(); + onSubmit(); + }, [closeModal, onSubmit]); + + return ( + + +
+ warning + 共有範囲の確認 +
+
+ + +

+ このアシスタントには限定公開されているページが含まれています。
+ 現在の設定では、アシスタントを通じてこれらのページの情報が、本来のアクセス権限を超えて共有される可能性があります。 +

+ +
+

含まれる限定公開ページ

+ + /Project/GROWI/新機能/GROWI AI + +
+ +

+ 続行する場合、これらのページの内容がアシスタントの公開範囲内で共有される可能性があることを確認してください。 +

+
+ + + + + + +
+ ); +}; diff --git a/apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistant.tsx b/apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistant.tsx new file mode 100644 index 00000000000..28523169da8 --- /dev/null +++ b/apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistant.tsx @@ -0,0 +1,37 @@ +import React, { Suspense } from 'react'; + +import dynamic from 'next/dynamic'; +import { useTranslation } from 'react-i18next'; + +import ItemsTreeContentSkeleton from '~/client/components/ItemsTree/ItemsTreeContentSkeleton'; +import { useIsGuestUser } from '~/stores-universal/context'; + +const AiAssistantContent = dynamic(() => import('./AiAssistantSubstance').then(mod => mod.AiAssistantContent), { ssr: false }); + +export const AiAssistant = (): JSX.Element => { + const { t } = useTranslation(); + const { data: isGuestUser } = useIsGuestUser(); + + return ( +
+
+

+ {t('Knowledge Assistant')} +

+
+ + { isGuestUser + ? ( +

+ { t('Not available for guest') } +

+ ) + : ( + }> + + + ) + } +
+ ); +}; diff --git a/apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.module.scss b/apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.module.scss new file mode 100644 index 00000000000..b596b1fb880 --- /dev/null +++ b/apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.module.scss @@ -0,0 +1,5 @@ +.grw-ai-assistant-substance :global { + .grw-ai-assistant-substance-header { + font-size: 14px; + } +} diff --git a/apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.tsx b/apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.tsx new file mode 100644 index 00000000000..1263cd0bfa8 --- /dev/null +++ b/apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +import { useAiAssistantManagementModal, useSWRxAiAssistants } from '../../../stores/ai-assistant'; + +import { AiAssistantTree } from './AiAssistantTree'; + +import styles from './AiAssistantSubstance.module.scss'; + +const moduleClass = styles['grw-ai-assistant-substance'] ?? ''; + +export const AiAssistantContent = (): JSX.Element => { + const { open } = useAiAssistantManagementModal(); + const { data: aiAssistants, mutate: mutateAiAssistants } = useSWRxAiAssistants(); + + return ( +
+ + +
+
+

+ マイアシスタント +

+ {aiAssistants?.myAiAssistants != null && aiAssistants.myAiAssistants.length !== 0 && ( + + )} +
+ +
+

+ チームアシスタント +

+ {aiAssistants?.teamAiAssistants != null && aiAssistants.teamAiAssistants.length !== 0 && ( + + )} +
+
+
+ ); +}; diff --git a/apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.module.scss b/apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.module.scss new file mode 100644 index 00000000000..f571f8bea17 --- /dev/null +++ b/apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.module.scss @@ -0,0 +1,45 @@ +// == Colors +.ai-assistant-tree-item :global { + .grw-ai-assistant-actions { + .btn-link { + &:hover { + color: var(--bs-gray-800) !important; + } + } + } +} + + +.ai-assistant-tree-item :global { + .list-group-item { + height: 40px; + padding-left: 4px; + + .grw-ai-assistant-triangle-btn { + border: 0; + transition: transform 0.2s ease-out; + transform: rotate(0deg); + + &.grw-ai-assistant-open { + transform: rotate(90deg); + } + } + + .grw-ai-assistant-title-anchor { + width: 100%; + overflow: hidden; + font-size: 14px; + } + + + .grw-ai-assistant-actions { + transition: opacity 0.2s ease-out; + } + + &:hover { + .grw-ai-assistant-actions { + opacity: 1 !important; + } + } + } +} diff --git a/apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx b/apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx new file mode 100644 index 00000000000..d4494f6030b --- /dev/null +++ b/apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx @@ -0,0 +1,205 @@ +import React, { useCallback, useState } from 'react'; + +import { getIdStringForRef } from '@growi/core'; + +import { toastError, toastSuccess } from '~/client/util/toastr'; +import { useCurrentUser } from '~/stores-universal/context'; + +import type { AiAssistantAccessScope } from '../../../../interfaces/ai-assistant'; +import { AiAssistantShareScope, type AiAssistantHasId } from '../../../../interfaces/ai-assistant'; +import { deleteAiAssistant } from '../../../services/ai-assistant'; +import { useAiAssistantChatSidebar } from '../../../stores/ai-assistant'; + +import styles from './AiAssistantTree.module.scss'; + +const moduleClass = styles['ai-assistant-tree-item'] ?? ''; + +type Thread = { + _id: string; + name: string; +} + +const dummyThreads: Thread[] = [ + { _id: '1', name: 'thread1' }, + { _id: '2', name: 'thread2' }, + { _id: '3', name: 'thread3' }, +]; + +type ThreadItemProps = { + thread: Thread; +}; + +const ThreadItem: React.FC = ({ + thread, +}) => { + + const deleteThreadHandler = useCallback(() => { + // TODO: https://redmine.weseek.co.jp/issues/161490 + }, []); + + const openChatHandler = useCallback(() => { + // TODO: https://redmine.weseek.co.jp/issues/159530 + }, []); + + return ( +
  • +
    + chat +
    + +
    +

    {thread.name}

    +
    + +
    + +
    +
  • + ); +}; + + +const getShareScopeIcon = (shareScope: AiAssistantShareScope, accessScope: AiAssistantAccessScope): string => { + const determinedSharedScope = shareScope === AiAssistantShareScope.SAME_AS_ACCESS_SCOPE ? accessScope : shareScope; + switch (determinedSharedScope) { + case AiAssistantShareScope.OWNER: + return 'lock'; + case AiAssistantShareScope.GROUPS: + return 'account_tree'; + case AiAssistantShareScope.PUBLIC_ONLY: + return 'group'; + } +}; + +type AiAssistantItemProps = { + currentUserId?: string; + aiAssistant: AiAssistantHasId; + threads: Thread[]; + onItemClicked?: (aiAssistantData: AiAssistantHasId) => void; + onDeleted?: () => void; +}; + +const AiAssistantItem: React.FC = ({ + currentUserId, + aiAssistant, + threads, + onItemClicked, + onDeleted, +}) => { + const [isThreadsOpened, setIsThreadsOpened] = useState(false); + + const openChatHandler = useCallback((aiAssistantData: AiAssistantHasId) => { + onItemClicked?.(aiAssistantData); + }, [onItemClicked]); + + const openThreadsHandler = useCallback(() => { + setIsThreadsOpened(toggle => !toggle); + }, []); + + const deleteAiAssistantHandler = useCallback(async() => { + try { + await deleteAiAssistant(aiAssistant._id); + onDeleted?.(); + toastSuccess('アシスタントを削除しました'); + } + catch (err) { + toastError('アシスタントの削除に失敗しました'); + } + }, [aiAssistant._id, onDeleted]); + + const isOperable = currentUserId != null && getIdStringForRef(aiAssistant.owner) === currentUserId; + + return ( + <> +
  • openChatHandler(aiAssistant)} + role="button" + className="list-group-item list-group-item-action border-0 d-flex align-items-center rounded-1" + > +
    + +
    + +
    + {getShareScopeIcon(aiAssistant.shareScope, aiAssistant.accessScope)} +
    + +
    +

    {aiAssistant.name}

    +
    + + { isOperable && ( +
    + + +
    + )} +
  • + + {isThreadsOpened && threads.length > 0 && ( +
    + {threads.map(thread => ( + + ))} +
    + )} + + ); +}; + +type AiAssistantTreeProps = { + aiAssistants: AiAssistantHasId[]; + onDeleted?: () => void; +}; + +export const AiAssistantTree: React.FC = ({ aiAssistants, onDeleted }) => { + const { data: currentUser } = useCurrentUser(); + const { open: openAiAssistantChatSidebar } = useAiAssistantChatSidebar(); + + return ( +
      + {aiAssistants.map(assistant => ( + + ))} +
    + ); +}; diff --git a/apps/app/src/features/openai/client/components/Common/SelectedPageList.tsx b/apps/app/src/features/openai/client/components/Common/SelectedPageList.tsx new file mode 100644 index 00000000000..9b58d2c469f --- /dev/null +++ b/apps/app/src/features/openai/client/components/Common/SelectedPageList.tsx @@ -0,0 +1,43 @@ +import { memo } from 'react'; + +import type { SelectedPage } from '../../../interfaces/selected-page'; + +type SelectedPageListProps = { + selectedPages: SelectedPage[]; + onRemove?: (pageId?: string) => void; +}; + +const SelectedPageListBase: React.FC = ({ selectedPages, onRemove }: SelectedPageListProps) => { + if (selectedPages.length === 0) { + return <>; + } + + return ( +
    + {selectedPages.map(({ page, isIncludeSubPage }) => ( +
    +
    + { isIncludeSubPage + ? <>{`${page.path}/*`} + : <>{page.path} + } +
    + {onRemove != null && page._id != null && page._id && ( + + )} +
    + ))} +
    + ); +}; + +export const SelectedPageList = memo(SelectedPageListBase); diff --git a/apps/app/src/features/openai/client/components/RagSearchButton.tsx b/apps/app/src/features/openai/client/components/RagSearchButton.tsx index 458dea06d06..1e8686d0d50 100644 --- a/apps/app/src/features/openai/client/components/RagSearchButton.tsx +++ b/apps/app/src/features/openai/client/components/RagSearchButton.tsx @@ -27,7 +27,7 @@ const RagSearchButton = (): JSX.Element => { onClick={ragSearchButtonClickHandler} data-testid="open-search-modal-button" > - knowledge_assistant + ai_assistant ); diff --git a/apps/app/src/features/openai/client/services/ai-assistant.ts b/apps/app/src/features/openai/client/services/ai-assistant.ts new file mode 100644 index 00000000000..9b55b684768 --- /dev/null +++ b/apps/app/src/features/openai/client/services/ai-assistant.ts @@ -0,0 +1,11 @@ +import { apiv3Post, apiv3Delete } from '~/client/util/apiv3-client'; + +import type { UpsertAiAssistantData } from '../../interfaces/ai-assistant'; + +export const createAiAssistant = async(body: UpsertAiAssistantData): Promise => { + await apiv3Post('/openai/ai-assistant', body); +}; + +export const deleteAiAssistant = async(id: string): Promise => { + await apiv3Delete(`/openai/ai-assistant/${id}`); +}; diff --git a/apps/app/src/features/openai/client/stores/ai-assistant.tsx b/apps/app/src/features/openai/client/stores/ai-assistant.tsx new file mode 100644 index 00000000000..df53758f8b0 --- /dev/null +++ b/apps/app/src/features/openai/client/stores/ai-assistant.tsx @@ -0,0 +1,77 @@ +import { useCallback } from 'react'; + +import { useSWRStatic } from '@growi/core/dist/swr'; +import { type SWRResponse } from 'swr'; +import useSWRImmutable from 'swr/immutable'; + +import { apiv3Get } from '~/client/util/apiv3-client'; + +import { type AccessibleAiAssistantsHasId, type AiAssistantHasId } from '../../interfaces/ai-assistant'; + +export const AiAssistantManagementModalPageMode = { + HOME: 'home', + SHARE: 'share', + PAGES: 'pages', + INSTRUCTION: 'instruction', +} as const; + +type AiAssistantManagementModalPageMode = typeof AiAssistantManagementModalPageMode[keyof typeof AiAssistantManagementModalPageMode]; + +type AiAssistantManagementModalStatus = { + isOpened: boolean, + pageMode?: AiAssistantManagementModalPageMode, +} + +type AiAssistantManagementModalUtils = { + open(): void + close(): void + changePageMode(pageType: AiAssistantManagementModalPageMode): void +} + +export const useAiAssistantManagementModal = ( + status?: AiAssistantManagementModalStatus, +): SWRResponse & AiAssistantManagementModalUtils => { + const initialStatus = { isOpened: false, pageType: AiAssistantManagementModalPageMode.HOME }; + const swrResponse = useSWRStatic('AiAssistantManagementModal', status, { fallbackData: initialStatus }); + + return { + ...swrResponse, + open: useCallback(() => { swrResponse.mutate({ isOpened: true }) }, [swrResponse]), + close: useCallback(() => swrResponse.mutate({ isOpened: false }), [swrResponse]), + changePageMode: useCallback((pageMode: AiAssistantManagementModalPageMode) => { + swrResponse.mutate({ isOpened: swrResponse.data?.isOpened ?? false, pageMode }); + }, [swrResponse]), + }; +}; + + +export const useSWRxAiAssistants = (): SWRResponse => { + return useSWRImmutable( + ['/openai/ai-assistants'], + ([endpoint]) => apiv3Get(endpoint).then(response => response.data.accessibleAiAssistants), + ); +}; + + +type AiAssistantChatSidebarStatus = { + isOpened: boolean, + aiAssistantData?: AiAssistantHasId; +} + +type AiAssistantChatSidebarUtils = { + open(aiAssistantData: AiAssistantHasId): void + close(): void +} + +export const useAiAssistantChatSidebar = ( + status?: AiAssistantChatSidebarStatus, +): SWRResponse & AiAssistantChatSidebarUtils => { + const initialStatus = { isOpened: false }; + const swrResponse = useSWRStatic('AiAssistantChatSidebar', status, { fallbackData: initialStatus }); + + return { + ...swrResponse, + open: useCallback((aiAssistantData: AiAssistantHasId) => { swrResponse.mutate({ isOpened: true, aiAssistantData }) }, [swrResponse]), + close: useCallback(() => swrResponse.mutate({ isOpened: false }), [swrResponse]), + }; +}; diff --git a/apps/app/src/features/openai/interfaces/ai-assistant.ts b/apps/app/src/features/openai/interfaces/ai-assistant.ts new file mode 100644 index 00000000000..aab6d00ec67 --- /dev/null +++ b/apps/app/src/features/openai/interfaces/ai-assistant.ts @@ -0,0 +1,54 @@ +import type { + IGrantedGroup, IUser, Ref, HasObjectId, +} from '@growi/core'; + +import type { VectorStore } from '../server/models/vector-store'; + +/* +* Objects +*/ +export const AiAssistantShareScope = { + SAME_AS_ACCESS_SCOPE: 'sameAsAccessScope', + PUBLIC_ONLY: 'publicOnly', // TODO: Rename to "PUBLIC" + OWNER: 'owner', + GROUPS: 'groups', +} as const; + +export const AiAssistantAccessScope = { + PUBLIC_ONLY: 'publicOnly', + OWNER: 'owner', + GROUPS: 'groups', +} as const; + +/* +* Interfaces +*/ +export type AiAssistantShareScope = typeof AiAssistantShareScope[keyof typeof AiAssistantShareScope]; +export type AiAssistantAccessScope = typeof AiAssistantAccessScope[keyof typeof AiAssistantAccessScope]; + +export interface AiAssistant { + name: string; + description: string + additionalInstruction: string + pagePathPatterns: string[], + vectorStore: Ref + owner: Ref + grantedGroupsForShareScope?: IGrantedGroup[] + grantedGroupsForAccessScope?: IGrantedGroup[] + shareScope: AiAssistantShareScope + accessScope: AiAssistantAccessScope +} + +export type AiAssistantHasId = AiAssistant & HasObjectId + +export type UpsertAiAssistantData = Omit + +export type AccessibleAiAssistants = { + myAiAssistants: AiAssistant[], + teamAiAssistants: AiAssistant[], +} + +export type AccessibleAiAssistantsHasId = { + myAiAssistants: AiAssistantHasId[], + teamAiAssistants: AiAssistantHasId[], +} diff --git a/apps/app/src/features/openai/interfaces/selected-page.ts b/apps/app/src/features/openai/interfaces/selected-page.ts new file mode 100644 index 00000000000..a11becbd19d --- /dev/null +++ b/apps/app/src/features/openai/interfaces/selected-page.ts @@ -0,0 +1,6 @@ +import type { IPageForItem } from '~/interfaces/page'; + +export type SelectedPage = { + page: IPageForItem, + isIncludeSubPage: boolean, +} diff --git a/apps/app/src/features/openai/server/models/ai-assistant.ts b/apps/app/src/features/openai/server/models/ai-assistant.ts new file mode 100644 index 00000000000..770f6781b25 --- /dev/null +++ b/apps/app/src/features/openai/server/models/ai-assistant.ts @@ -0,0 +1,106 @@ +import { type IGrantedGroup, GroupType } from '@growi/core'; +import { type Model, type Document, Schema } from 'mongoose'; + +import { getOrCreateModel } from '~/server/util/mongoose-utils'; + +import { type AiAssistant, AiAssistantShareScope, AiAssistantAccessScope } from '../../interfaces/ai-assistant'; + +export interface AiAssistantDocument extends AiAssistant, Document {} + +type AiAssistantModel = Model + + +/* + * Schema Definition + */ +const schema = new Schema( + { + name: { + type: String, + required: true, + }, + description: { + type: String, + required: true, + default: '', + }, + additionalInstruction: { + type: String, + required: true, + default: '', + }, + pagePathPatterns: [{ + type: String, + required: true, + }], + vectorStore: { + type: Schema.Types.ObjectId, + ref: 'VectorStore', + required: true, + }, + owner: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + grantedGroupsForShareScope: { + type: [{ + type: { + type: String, + enum: Object.values(GroupType), + required: true, + default: 'UserGroup', + }, + item: { + type: Schema.Types.ObjectId, + refPath: 'grantedGroups.type', + required: true, + index: true, + }, + }], + validate: [function(arr: IGrantedGroup[]): boolean { + if (arr == null) return true; + const uniqueItemValues = new Set(arr.map(e => e.item)); + return arr.length === uniqueItemValues.size; + }, 'grantedGroups contains non unique item'], + default: [], + }, + grantedGroupsForAccessScope: { + type: [{ + type: { + type: String, + enum: Object.values(GroupType), + required: true, + default: 'UserGroup', + }, + item: { + type: Schema.Types.ObjectId, + refPath: 'grantedGroups.type', + required: true, + index: true, + }, + }], + validate: [function(arr: IGrantedGroup[]): boolean { + if (arr == null) return true; + const uniqueItemValues = new Set(arr.map(e => e.item)); + return arr.length === uniqueItemValues.size; + }, 'grantedGroups contains non unique item'], + default: [], + }, + shareScope: { + type: String, + enum: Object.values(AiAssistantShareScope), + required: true, + }, + accessScope: { + type: String, + enum: Object.values(AiAssistantAccessScope), + required: true, + }, + }, + { + timestamps: true, + }, +); + +export default getOrCreateModel('AiAssistant', schema); diff --git a/apps/app/src/features/openai/server/models/vector-store.ts b/apps/app/src/features/openai/server/models/vector-store.ts index 11a1f11be4d..a2229366c98 100644 --- a/apps/app/src/features/openai/server/models/vector-store.ts +++ b/apps/app/src/features/openai/server/models/vector-store.ts @@ -2,16 +2,8 @@ import { type Model, type Document, Schema } from 'mongoose'; import { getOrCreateModel } from '~/server/util/mongoose-utils'; -export const VectorStoreScopeType = { - PUBLIC: 'public', -} as const; - -export type VectorStoreScopeType = typeof VectorStoreScopeType[keyof typeof VectorStoreScopeType]; - -const VectorStoreScopeTypes = Object.values(VectorStoreScopeType); -interface VectorStore { +export interface VectorStore { vectorStoreId: string - scopeType: VectorStoreScopeType isDeleted: boolean } @@ -27,11 +19,6 @@ const schema = new Schema({ required: true, unique: true, }, - scopeType: { - enum: VectorStoreScopeTypes, - type: String, - required: true, - }, isDeleted: { type: Boolean, default: false, diff --git a/apps/app/src/features/openai/server/routes/ai-assistant.ts b/apps/app/src/features/openai/server/routes/ai-assistant.ts new file mode 100644 index 00000000000..90a8dc2a11b --- /dev/null +++ b/apps/app/src/features/openai/server/routes/ai-assistant.ts @@ -0,0 +1,50 @@ +import { type IUserHasId } from '@growi/core'; +import { ErrorV3 } from '@growi/core/dist/models'; +import type { Request, RequestHandler } from 'express'; + +import type Crowi from '~/server/crowi'; +import { accessTokenParser } from '~/server/middlewares/access-token-parser'; +import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator'; +import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response'; +import loggerFactory from '~/utils/logger'; + +import { type UpsertAiAssistantData } from '../../interfaces/ai-assistant'; +import { getOpenaiService } from '../services/openai'; + +import { certifyAiService } from './middlewares/certify-ai-service'; +import { upsertAiAssistantValidator } from './middlewares/upsert-ai-assistant-validator'; + +const logger = loggerFactory('growi:routes:apiv3:openai:create-ai-assistant'); + +type CreateAssistantFactory = (crowi: Crowi) => RequestHandler[]; + +type ReqBody = UpsertAiAssistantData; + +type Req = Request & { + user: IUserHasId, +} + +export const createAiAssistantFactory: CreateAssistantFactory = (crowi) => { + const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi); + + return [ + accessTokenParser, loginRequiredStrictly, certifyAiService, upsertAiAssistantValidator, apiV3FormValidator, + async(req: Req, res: ApiV3Response) => { + const openaiService = getOpenaiService(); + if (openaiService == null) { + return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501); + } + + try { + const aiAssistantData = { ...req.body, owner: req.user._id }; + const aiAssistant = await openaiService.createAiAssistant(aiAssistantData); + + return res.apiv3({ aiAssistant }); + } + catch (err) { + logger.error(err); + return res.apiv3Err(new ErrorV3('AiAssistant creation failed')); + } + }, + ]; +}; diff --git a/apps/app/src/features/openai/server/routes/ai-assistants.ts b/apps/app/src/features/openai/server/routes/ai-assistants.ts new file mode 100644 index 00000000000..d0b35a6d25f --- /dev/null +++ b/apps/app/src/features/openai/server/routes/ai-assistants.ts @@ -0,0 +1,46 @@ +import { type IUserHasId } from '@growi/core'; +import { ErrorV3 } from '@growi/core/dist/models'; +import type { Request, RequestHandler } from 'express'; + +import type Crowi from '~/server/crowi'; +import { accessTokenParser } from '~/server/middlewares/access-token-parser'; +import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response'; +import loggerFactory from '~/utils/logger'; + +import { getOpenaiService } from '../services/openai'; + +import { certifyAiService } from './middlewares/certify-ai-service'; + +const logger = loggerFactory('growi:routes:apiv3:openai:get-ai-assistants'); + + +type GetAiAssistantsFactory = (crowi: Crowi) => RequestHandler[]; + +type Req = Request & { + user: IUserHasId, +} + +export const getAiAssistantsFactory: GetAiAssistantsFactory = (crowi) => { + + const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi); + + return [ + accessTokenParser, loginRequiredStrictly, certifyAiService, + async(req: Req, res: ApiV3Response) => { + const openaiService = getOpenaiService(); + if (openaiService == null) { + return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501); + } + + try { + const accessibleAiAssistants = await openaiService.getAccessibleAiAssistants(req.user); + + return res.apiv3({ accessibleAiAssistants }); + } + catch (err) { + logger.error(err); + return res.apiv3Err(new ErrorV3('Failed to get AiAssistants')); + } + }, + ]; +}; diff --git a/apps/app/src/features/openai/server/routes/delete-ai-assistant.ts b/apps/app/src/features/openai/server/routes/delete-ai-assistant.ts new file mode 100644 index 00000000000..5e96a2c7fda --- /dev/null +++ b/apps/app/src/features/openai/server/routes/delete-ai-assistant.ts @@ -0,0 +1,64 @@ +import { type IUserHasId } from '@growi/core'; +import { ErrorV3 } from '@growi/core/dist/models'; +import type { Request, RequestHandler } from 'express'; +import { type ValidationChain, param } from 'express-validator'; +import { isHttpError } from 'http-errors'; + + +import type Crowi from '~/server/crowi'; +import { accessTokenParser } from '~/server/middlewares/access-token-parser'; +import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator'; +import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response'; +import loggerFactory from '~/utils/logger'; + +import { getOpenaiService } from '../services/openai'; + +import { certifyAiService } from './middlewares/certify-ai-service'; + +const logger = loggerFactory('growi:routes:apiv3:openai:delete-ai-assistants'); + + +type DeleteAiAssistantsFactory = (crowi: Crowi) => RequestHandler[]; + +type ReqParams = { + id: string, +} + +type Req = Request & { + user: IUserHasId, +} + +export const deleteAiAssistantsFactory: DeleteAiAssistantsFactory = (crowi) => { + const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi); + + const validator: ValidationChain[] = [ + param('id').isMongoId().withMessage('aiAssistant id is required'), + ]; + + return [ + accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator, + async(req: Req, res: ApiV3Response) => { + const { id } = req.params; + const { user } = req; + + const openaiService = getOpenaiService(); + if (openaiService == null) { + return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501); + } + + try { + const deletedAiAssistant = await openaiService.deleteAiAssistant(user._id, id); + return res.apiv3({ deletedAiAssistant }); + } + catch (err) { + logger.error(err); + + if (isHttpError(err)) { + return res.apiv3Err(new ErrorV3(err.message), err.status); + } + + return res.apiv3Err(new ErrorV3('Failed to delete AiAssistants')); + } + }, + ]; +}; diff --git a/apps/app/src/features/openai/server/routes/index.ts b/apps/app/src/features/openai/server/routes/index.ts index 670e8fa2198..bbecb5347e6 100644 --- a/apps/app/src/features/openai/server/routes/index.ts +++ b/apps/app/src/features/openai/server/routes/index.ts @@ -30,6 +30,22 @@ export const factory = (crowi: Crowi): express.Router => { import('./message').then(({ postMessageHandlersFactory }) => { router.post('/message', postMessageHandlersFactory(crowi)); }); + + import('./ai-assistant').then(({ createAiAssistantFactory }) => { + router.post('/ai-assistant', createAiAssistantFactory(crowi)); + }); + + import('./ai-assistants').then(({ getAiAssistantsFactory }) => { + router.get('/ai-assistants', getAiAssistantsFactory(crowi)); + }); + + import('./update-ai-assistant').then(({ updateAiAssistantsFactory }) => { + router.put('/ai-assistant/:id', updateAiAssistantsFactory(crowi)); + }); + + import('./delete-ai-assistant').then(({ deleteAiAssistantsFactory }) => { + router.delete('/ai-assistant/:id', deleteAiAssistantsFactory(crowi)); + }); } return router; diff --git a/apps/app/src/features/openai/server/routes/middlewares/upsert-ai-assistant-validator.ts b/apps/app/src/features/openai/server/routes/middlewares/upsert-ai-assistant-validator.ts new file mode 100644 index 00000000000..b09390d3064 --- /dev/null +++ b/apps/app/src/features/openai/server/routes/middlewares/upsert-ai-assistant-validator.ts @@ -0,0 +1,83 @@ +import { GroupType } from '@growi/core'; +import { isGrobPatternPath, isCreatablePage } from '@growi/core/dist/utils/page-path-utils'; +import { type ValidationChain, body } from 'express-validator'; + +import { AiAssistantShareScope, AiAssistantAccessScope } from '../../../interfaces/ai-assistant'; + +export const upsertAiAssistantValidator: ValidationChain[] = [ + body('name') + .isString() + .withMessage('name must be a string') + .not() + .isEmpty() + .withMessage('name is required') + .escape(), + + body('description') + .optional() + .isString() + .withMessage('description must be a string') + .escape(), + + body('additionalInstruction') + .optional() + .isString() + .withMessage('additionalInstruction must be a string') + .escape(), + + body('pagePathPatterns') + .isArray() + .withMessage('pagePathPatterns must be an array of strings') + .not() + .isEmpty() + .withMessage('pagePathPatterns must not be empty'), + + body('pagePathPatterns.*') // each item of pagePathPatterns + .isString() + .withMessage('pagePathPatterns must be an array of strings') + .notEmpty() + .withMessage('pagePathPatterns must not be empty') + .custom((value: string) => { + + // check if the value is a grob pattern path + if (value.includes('*')) { + return isGrobPatternPath(value) && isCreatablePage(value.replace('*', '')); + } + + return isCreatablePage(value); + }), + + body('grantedGroupsForShareScope') + .optional() + .isArray() + .withMessage('grantedGroupsForShareScope must be an array'), + + body('grantedGroupsForShareScope.*.type') // each item of grantedGroupsForShareScope + .isIn(Object.values(GroupType)) + .withMessage('Invalid grantedGroupsForShareScope type value'), + + body('grantedGroupsForShareScope.*.item') // each item of grantedGroupsForShareScope + .isMongoId() + .withMessage('Invalid grantedGroupsForShareScope item value'), + + body('grantedGroupsForAccessScope') + .optional() + .isArray() + .withMessage('grantedGroupsForAccessScope must be an array'), + + body('grantedGroupsForAccessScope.*.type') // each item of grantedGroupsForAccessScope + .isIn(Object.values(GroupType)) + .withMessage('Invalid grantedGroupsForAccessScope type value'), + + body('grantedGroupsForAccessScope.*.item') // each item of grantedGroupsForAccessScope + .isMongoId() + .withMessage('Invalid grantedGroupsForAccessScope item value'), + + body('shareScope') + .isIn(Object.values(AiAssistantShareScope)) + .withMessage('Invalid shareScope value'), + + body('accessScope') + .isIn(Object.values(AiAssistantAccessScope)) + .withMessage('Invalid accessScope value'), +]; diff --git a/apps/app/src/features/openai/server/routes/rebuild-vector-store.ts b/apps/app/src/features/openai/server/routes/rebuild-vector-store.ts index 7357942760c..591e6e66b02 100644 --- a/apps/app/src/features/openai/server/routes/rebuild-vector-store.ts +++ b/apps/app/src/features/openai/server/routes/rebuild-vector-store.ts @@ -28,9 +28,13 @@ export const rebuildVectorStoreHandlersFactory: RebuildVectorStoreFactory = (cro accessTokenParser, loginRequiredStrictly, adminRequired, certifyAiService, validator, apiV3FormValidator, async(req: Request, res: ApiV3Response) => { + const openaiService = getOpenaiService(); + if (openaiService == null) { + return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501); + } + try { - const openaiService = getOpenaiService(); - await openaiService?.rebuildVectorStoreAll(); + // await openaiService?.rebuildVectorStoreAll(); return res.apiv3({}); } diff --git a/apps/app/src/features/openai/server/routes/thread.ts b/apps/app/src/features/openai/server/routes/thread.ts index fcc0e8979f0..1765738ea22 100644 --- a/apps/app/src/features/openai/server/routes/thread.ts +++ b/apps/app/src/features/openai/server/routes/thread.ts @@ -1,4 +1,5 @@ import type { IUserHasId } from '@growi/core/dist/interfaces'; +import { ErrorV3 } from '@growi/core/dist/models'; import type { Request, RequestHandler } from 'express'; import type { ValidationChain } from 'express-validator'; import { body } from 'express-validator'; @@ -30,12 +31,17 @@ export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => { return [ accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator, async(req: CreateThreadReq, res: ApiV3Response) => { + + const openaiService = getOpenaiService(); + if (openaiService == null) { + return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501); + } + try { - const openaiService = getOpenaiService(); const filterdThreadId = req.body.threadId != null ? filterXSS(req.body.threadId) : undefined; - const vectorStore = await openaiService?.getOrCreateVectorStoreForPublicScope(); - const thread = await openaiService?.getOrCreateThread(req.user._id, vectorStore?.vectorStoreId, filterdThreadId); - return res.apiv3({ thread }); + // const vectorStore = await openaiService?.getOrCreateVectorStoreForPublicScope(); + // const thread = await openaiService?.getOrCreateThread(req.user._id, vectorStore?.vectorStoreId, filterdThreadId); + return res.apiv3({ }); } catch (err) { logger.error(err); diff --git a/apps/app/src/features/openai/server/routes/update-ai-assistant.ts b/apps/app/src/features/openai/server/routes/update-ai-assistant.ts new file mode 100644 index 00000000000..1f24fd5a51b --- /dev/null +++ b/apps/app/src/features/openai/server/routes/update-ai-assistant.ts @@ -0,0 +1,69 @@ +import { type IUserHasId } from '@growi/core'; +import { ErrorV3 } from '@growi/core/dist/models'; +import type { Request, RequestHandler } from 'express'; +import { type ValidationChain, param } from 'express-validator'; +import { isHttpError } from 'http-errors'; + +import type Crowi from '~/server/crowi'; +import { accessTokenParser } from '~/server/middlewares/access-token-parser'; +import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator'; +import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response'; +import loggerFactory from '~/utils/logger'; + +import { type UpsertAiAssistantData } from '../../interfaces/ai-assistant'; +import { getOpenaiService } from '../services/openai'; + +import { certifyAiService } from './middlewares/certify-ai-service'; +import { upsertAiAssistantValidator } from './middlewares/upsert-ai-assistant-validator'; + +const logger = loggerFactory('growi:routes:apiv3:openai:update-ai-assistants'); + +type UpdateAiAssistantsFactory = (crowi: Crowi) => RequestHandler[]; + +type ReqParams = { + id: string, +} + +type ReqBody = UpsertAiAssistantData; + +type Req = Request & { + user: IUserHasId, +} + +export const updateAiAssistantsFactory: UpdateAiAssistantsFactory = (crowi) => { + const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi); + + const validator: ValidationChain[] = [ + param('id').isMongoId().withMessage('aiAssistant id is required'), + ...upsertAiAssistantValidator, + ]; + + return [ + accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator, + async(req: Req, res: ApiV3Response) => { + const { id } = req.params; + const { user } = req; + + const openaiService = getOpenaiService(); + if (openaiService == null) { + return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501); + } + + try { + const aiAssistantData = { ...req.body, owner: user._id }; + const updatedAiAssistant = await openaiService.updateAiAssistant(id, aiAssistantData); + + return res.apiv3({ updatedAiAssistant }); + } + catch (err) { + logger.error(err); + + if (isHttpError(err)) { + return res.apiv3Err(new ErrorV3(err.message), err.status); + } + + return res.apiv3Err(new ErrorV3('Failed to update AiAssistants')); + } + }, + ]; +}; diff --git a/apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts b/apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts index 06ccf5bb4c4..c8601d88278 100644 --- a/apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts +++ b/apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts @@ -3,8 +3,6 @@ import type OpenAI from 'openai'; import { AzureOpenAI } from 'openai'; import { type Uploadable } from 'openai/uploads'; -import type { VectorStoreScopeType } from '~/features/openai/server/models/vector-store'; - import type { IOpenaiClientDelegator } from './interfaces'; @@ -40,8 +38,8 @@ export class AzureOpenaiClientDelegator implements IOpenaiClientDelegator { return this.client.beta.threads.del(threadId); } - async createVectorStore(scopeType:VectorStoreScopeType): Promise { - return this.client.beta.vectorStores.create({ name: `growi-vector-store-{${scopeType}` }); + async createVectorStore(name: string): Promise { + return this.client.beta.vectorStores.create({ name: `growi-vector-store-for-${name}` }); } async retrieveVectorStore(vectorStoreId: string): Promise { diff --git a/apps/app/src/features/openai/server/services/client-delegator/interfaces.ts b/apps/app/src/features/openai/server/services/client-delegator/interfaces.ts index cad2790a027..47ba97f68ef 100644 --- a/apps/app/src/features/openai/server/services/client-delegator/interfaces.ts +++ b/apps/app/src/features/openai/server/services/client-delegator/interfaces.ts @@ -1,14 +1,12 @@ import type OpenAI from 'openai'; import type { Uploadable } from 'openai/uploads'; -import type { VectorStoreScopeType } from '~/features/openai/server/models/vector-store'; - export interface IOpenaiClientDelegator { createThread(vectorStoreId: string): Promise retrieveThread(threadId: string): Promise deleteThread(threadId: string): Promise retrieveVectorStore(vectorStoreId: string): Promise - createVectorStore(scopeType:VectorStoreScopeType): Promise + createVectorStore(name: string): Promise deleteVectorStore(vectorStoreId: string): Promise uploadFile(file: Uploadable): Promise createVectorStoreFileBatch(vectorStoreId: string, fileIds: string[]): Promise diff --git a/apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts b/apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts index ee7dab835a3..f2b957972af 100644 --- a/apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts +++ b/apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts @@ -1,7 +1,6 @@ import OpenAI from 'openai'; import { type Uploadable } from 'openai/uploads'; -import type { VectorStoreScopeType } from '~/features/openai/server/models/vector-store'; import { configManager } from '~/server/service/config-manager'; import type { IOpenaiClientDelegator } from './interfaces'; @@ -42,8 +41,8 @@ export class OpenaiClientDelegator implements IOpenaiClientDelegator { return this.client.beta.threads.del(threadId); } - async createVectorStore(scopeType:VectorStoreScopeType): Promise { - return this.client.beta.vectorStores.create({ name: `growi-vector-store-${scopeType}` }); + async createVectorStore(name: string): Promise { + return this.client.beta.vectorStores.create({ name: `growi-vector-store-for-${name}` }); } async retrieveVectorStore(vectorStoreId: string): Promise { diff --git a/apps/app/src/features/openai/server/services/openai-api-error-handler.ts b/apps/app/src/features/openai/server/services/openai-api-error-handler.ts index 064ccc3c56c..0a19c58bf75 100644 --- a/apps/app/src/features/openai/server/services/openai-api-error-handler.ts +++ b/apps/app/src/features/openai/server/services/openai-api-error-handler.ts @@ -14,7 +14,7 @@ type ErrorHandler = { notFoundError?: () => Promise; } -export const oepnaiApiErrorHandler = async(error: unknown, handler: ErrorHandler): Promise => { +export const openaiApiErrorHandler = async(error: unknown, handler: ErrorHandler): Promise => { if (!(error instanceof OpenAI.APIError)) { return; } diff --git a/apps/app/src/features/openai/server/services/openai.ts b/apps/app/src/features/openai/server/services/openai.ts index eb7471a8e55..f1cb1407585 100644 --- a/apps/app/src/features/openai/server/services/openai.ts +++ b/apps/app/src/features/openai/server/services/openai.ts @@ -2,50 +2,78 @@ import assert from 'node:assert'; import { Readable, Transform } from 'stream'; import { pipeline } from 'stream/promises'; -import type { IPagePopulatedToShowRevision } from '@growi/core'; -import { PageGrant, isPopulated } from '@growi/core'; -import type { HydratedDocument, Types } from 'mongoose'; -import mongoose from 'mongoose'; -import type OpenAI from 'openai'; -import { toFile } from 'openai'; - +import { + PageGrant, getIdForRef, getIdStringForRef, isPopulated, type IUserHasId, +} from '@growi/core'; +import { deepEquals } from '@growi/core/dist/utils'; +import { isGrobPatternPath } from '@growi/core/dist/utils/page-path-utils'; +import escapeStringRegexp from 'escape-string-regexp'; +import createError from 'http-errors'; +import mongoose, { type HydratedDocument, type Types } from 'mongoose'; +import { type OpenAI, toFile } from 'openai'; + +import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation'; import ThreadRelationModel from '~/features/openai/server/models/thread-relation'; -import VectorStoreModel, { VectorStoreScopeType, type VectorStoreDocument } from '~/features/openai/server/models/vector-store'; +import VectorStoreModel, { type VectorStoreDocument } from '~/features/openai/server/models/vector-store'; import VectorStoreFileRelationModel, { type VectorStoreFileRelation, prepareVectorStoreFileRelations, } from '~/features/openai/server/models/vector-store-file-relation'; import type { PageDocument, PageModel } from '~/server/models/page'; +import UserGroupRelation from '~/server/models/user-group-relation'; import { configManager } from '~/server/service/config-manager'; import { createBatchStream } from '~/server/util/batch-stream'; import loggerFactory from '~/utils/logger'; import { OpenaiServiceTypes } from '../../interfaces/ai'; +import { + type AccessibleAiAssistants, type AiAssistant, AiAssistantAccessScope, AiAssistantShareScope, +} from '../../interfaces/ai-assistant'; +import AiAssistantModel, { type AiAssistantDocument } from '../models/ai-assistant'; import { convertMarkdownToHtml } from '../utils/convert-markdown-to-html'; import { getClient } from './client-delegator'; // import { splitMarkdownIntoChunks } from './markdown-splitter/markdown-token-splitter'; -import { oepnaiApiErrorHandler } from './openai-api-error-handler'; +import { openaiApiErrorHandler } from './openai-api-error-handler'; +const { isDeepEquals } = deepEquals; const BATCH_SIZE = 100; const logger = loggerFactory('growi:service:openai'); -let isVectorStoreForPublicScopeExist = false; +// const isVectorStoreForPublicScopeExist = false; type VectorStoreFileRelationsMap = Map + +const convertPathPatternsToRegExp = (pagePathPatterns: string[]): Array => { + return pagePathPatterns.map((pagePathPattern) => { + if (isGrobPatternPath(pagePathPattern)) { + const trimedPagePathPattern = pagePathPattern.replace('/*', ''); + const escapedPagePathPattern = escapeStringRegexp(trimedPagePathPattern); + return new RegExp(`^${escapedPagePathPattern}`); + } + + return pagePathPattern; + }); +}; + + export interface IOpenaiService { getOrCreateThread(userId: string, vectorStoreId?: string, threadId?: string): Promise; - getOrCreateVectorStoreForPublicScope(): Promise; + // getOrCreateVectorStoreForPublicScope(): Promise; deleteExpiredThreads(limit: number, apiCallInterval: number): Promise; // for CronJob deleteObsolatedVectorStoreRelations(): Promise // for CronJob - createVectorStoreFile(pages: PageDocument[]): Promise; + createVectorStoreFile(vectorStoreRelation: VectorStoreDocument, pages: PageDocument[]): Promise; deleteVectorStoreFile(vectorStoreRelationId: Types.ObjectId, pageId: Types.ObjectId): Promise; deleteObsoleteVectorStoreFile(limit: number, apiCallInterval: number): Promise; // for CronJob - rebuildVectorStoreAll(): Promise; - rebuildVectorStore(page: HydratedDocument): Promise; + // rebuildVectorStoreAll(): Promise; + // rebuildVectorStore(page: HydratedDocument): Promise; + createAiAssistant(data: Omit): Promise; + updateAiAssistant(aiAssistantId: string, data: Omit): Promise; + getAccessibleAiAssistants(user: IUserHasId): Promise + deleteAiAssistant(ownerId: string, aiAssistantId: string): Promise } class OpenaiService implements IOpenaiService { @@ -82,7 +110,7 @@ class OpenaiService implements IOpenaiService { return thread; } catch (err) { - await oepnaiApiErrorHandler(err, { notFoundError: async() => { await threadRelation.remove() } }); + await openaiApiErrorHandler(err, { notFoundError: async() => { await threadRelation.remove() } }); throw new Error(err); } } @@ -111,38 +139,55 @@ class OpenaiService implements IOpenaiService { await ThreadRelationModel.deleteMany({ threadId: { $in: deletedThreadIds } }); } - public async getOrCreateVectorStoreForPublicScope(): Promise { - const vectorStoreDocument: VectorStoreDocument | null = await VectorStoreModel.findOne({ scopeType: VectorStoreScopeType.PUBLIC, isDeleted: false }); + // TODO: https://redmine.weseek.co.jp/issues/160332 + // public async getOrCreateVectorStoreForPublicScope(): Promise { + // const vectorStoreDocument: VectorStoreDocument | null = await VectorStoreModel.findOne({ scopeType: VectorStoreScopeType.PUBLIC, isDeleted: false }); - if (vectorStoreDocument != null && isVectorStoreForPublicScopeExist) { - return vectorStoreDocument; - } + // if (vectorStoreDocument != null && isVectorStoreForPublicScopeExist) { + // return vectorStoreDocument; + // } - if (vectorStoreDocument != null && !isVectorStoreForPublicScopeExist) { - try { - // Check if vector store entity exists - // If the vector store entity does not exist, the vector store document is deleted - await this.client.retrieveVectorStore(vectorStoreDocument.vectorStoreId); - isVectorStoreForPublicScopeExist = true; - return vectorStoreDocument; - } - catch (err) { - await oepnaiApiErrorHandler(err, { notFoundError: vectorStoreDocument.markAsDeleted }); - throw new Error(err); - } - } + // if (vectorStoreDocument != null && !isVectorStoreForPublicScopeExist) { + // try { + // // Check if vector store entity exists + // // If the vector store entity does not exist, the vector store document is deleted + // await this.client.retrieveVectorStore(vectorStoreDocument.vectorStoreId); + // isVectorStoreForPublicScopeExist = true; + // return vectorStoreDocument; + // } + // catch (err) { + // await oepnaiApiErrorHandler(err, { notFoundError: vectorStoreDocument.markAsDeleted }); + // throw new Error(err); + // } + // } - const newVectorStore = await this.client.createVectorStore(VectorStoreScopeType.PUBLIC); - const newVectorStoreDocument = await VectorStoreModel.create({ - vectorStoreId: newVectorStore.id, - scopeType: VectorStoreScopeType.PUBLIC, - }) as VectorStoreDocument; + // const newVectorStore = await this.client.createVectorStore(VectorStoreScopeType.PUBLIC); + // const newVectorStoreDocument = await VectorStoreModel.create({ + // vectorStoreId: newVectorStore.id, + // scopeType: VectorStoreScopeType.PUBLIC, + // }) as VectorStoreDocument; - isVectorStoreForPublicScopeExist = true; + // isVectorStoreForPublicScopeExist = true; + + // return newVectorStoreDocument; + // } - return newVectorStoreDocument; + private async createVectorStore(name: string): Promise { + try { + const newVectorStore = await this.client.createVectorStore(name); + + const newVectorStoreDocument = await VectorStoreModel.create({ + vectorStoreId: newVectorStore.id, + }) as VectorStoreDocument; + + return newVectorStoreDocument; + } + catch (err) { + throw new Error(err); + } } + // TODO: https://redmine.weseek.co.jp/issues/160332 // TODO: https://redmine.weseek.co.jp/issues/156643 // private async uploadFileByChunks(pageId: Types.ObjectId, body: string, vectorStoreFileRelationsMap: VectorStoreFileRelationsMap) { // const chunks = await splitMarkdownIntoChunks(body, 'gpt-4o'); @@ -165,8 +210,8 @@ class OpenaiService implements IOpenaiService { return uploadedFile; } - private async deleteVectorStore(vectorStoreScopeType: VectorStoreScopeType): Promise { - const vectorStoreDocument: VectorStoreDocument | null = await VectorStoreModel.findOne({ scopeType: vectorStoreScopeType, isDeleted: false }); + private async deleteVectorStore(vectorStoreRelationId: string): Promise { + const vectorStoreDocument: VectorStoreDocument | null = await VectorStoreModel.findOne({ _id: vectorStoreRelationId, isDeleted: false }); if (vectorStoreDocument == null) { return; } @@ -176,26 +221,26 @@ class OpenaiService implements IOpenaiService { await vectorStoreDocument.markAsDeleted(); } catch (err) { - await oepnaiApiErrorHandler(err, { notFoundError: vectorStoreDocument.markAsDeleted }); + await openaiApiErrorHandler(err, { notFoundError: vectorStoreDocument.markAsDeleted }); throw new Error(err); } } - async createVectorStoreFile(pages: Array>): Promise { - const vectorStore = await this.getOrCreateVectorStoreForPublicScope(); + async createVectorStoreFile(vectorStoreRelation: VectorStoreDocument, pages: Array>): Promise { + // const vectorStore = await this.getOrCreateVectorStoreForPublicScope(); const vectorStoreFileRelationsMap: VectorStoreFileRelationsMap = new Map(); const processUploadFile = async(page: HydratedDocument) => { if (page._id != null && page.grant === PageGrant.GRANT_PUBLIC && page.revision != null) { if (isPopulated(page.revision) && page.revision.body.length > 0) { const uploadedFile = await this.uploadFile(page._id, page.path, page.revision.body); - prepareVectorStoreFileRelations(vectorStore._id, page._id, uploadedFile.id, vectorStoreFileRelationsMap); + prepareVectorStoreFileRelations(vectorStoreRelation._id, page._id, uploadedFile.id, vectorStoreFileRelationsMap); return; } const pagePopulatedToShowRevision = await page.populateDataToShowRevision(); if (pagePopulatedToShowRevision.revision != null && pagePopulatedToShowRevision.revision.body.length > 0) { const uploadedFile = await this.uploadFile(page._id, page.path, pagePopulatedToShowRevision.revision.body); - prepareVectorStoreFileRelations(vectorStore._id, page._id, uploadedFile.id, vectorStoreFileRelationsMap); + prepareVectorStoreFileRelations(vectorStoreRelation._id, page._id, uploadedFile.id, vectorStoreFileRelationsMap); } } }; @@ -226,7 +271,7 @@ class OpenaiService implements IOpenaiService { await VectorStoreFileRelationModel.upsertVectorStoreFileRelations(vectorStoreFileRelations); // Create vector store file - const createVectorStoreFileBatchResponse = await this.client.createVectorStoreFileBatch(vectorStore.vectorStoreId, uploadedFileIds); + const createVectorStoreFileBatchResponse = await this.client.createVectorStoreFileBatch(vectorStoreRelation.vectorStoreId, uploadedFileIds); logger.debug('Create vector store file', createVectorStoreFileBatchResponse); // Set isAttachedToVectorStore: true when the uploaded file is attached to VectorStore @@ -237,7 +282,7 @@ class OpenaiService implements IOpenaiService { // Delete all uploaded files if createVectorStoreFileBatch fails for await (const pageId of pageIds) { - await this.deleteVectorStoreFile(vectorStore._id, pageId); + await this.deleteVectorStoreFile(vectorStoreRelation._id, pageId); } } @@ -287,7 +332,7 @@ class OpenaiService implements IOpenaiService { } } catch (err) { - await oepnaiApiErrorHandler(err, { notFoundError: async() => { deletedFileIds.push(fileId) } }); + await openaiApiErrorHandler(err, { notFoundError: async() => { deletedFileIds.push(fileId) } }); logger.error(err); } } @@ -329,31 +374,304 @@ class OpenaiService implements IOpenaiService { } } - async rebuildVectorStoreAll() { - await this.deleteVectorStore(VectorStoreScopeType.PUBLIC); + // TODO: https://redmine.weseek.co.jp/issues/160332 + // async rebuildVectorStoreAll() { + // await this.deleteVectorStore(VectorStoreScopeType.PUBLIC); + + // // Create all public pages VectorStoreFile + // const Page = mongoose.model, PageModel>('Page'); + // const pagesStream = Page.find({ grant: PageGrant.GRANT_PUBLIC }).populate('revision').cursor({ batch_size: BATCH_SIZE }); + // const batchStrem = createBatchStream(BATCH_SIZE); + + // const createVectorStoreFile = this.createVectorStoreFile.bind(this); + // const createVectorStoreFileStream = new Transform({ + // objectMode: true, + // async transform(chunk: HydratedDocument[], encoding, callback) { + // await createVectorStoreFile(chunk); + // this.push(chunk); + // callback(); + // }, + // }); + + // await pipeline(pagesStream, batchStrem, createVectorStoreFileStream); + // } - // Create all public pages VectorStoreFile + // async rebuildVectorStore(page: HydratedDocument) { + // const vectorStore = await this.getOrCreateVectorStoreForPublicScope(); + // await this.deleteVectorStoreFile(vectorStore._id, page._id); + // await this.createVectorStoreFile([page]); + // } + + private async createVectorStoreFileWithStream(vectorStoreRelation: VectorStoreDocument, conditions: mongoose.FilterQuery): Promise { const Page = mongoose.model, PageModel>('Page'); - const pagesStream = Page.find({ grant: PageGrant.GRANT_PUBLIC }).populate('revision').cursor({ batch_size: BATCH_SIZE }); - const batchStrem = createBatchStream(BATCH_SIZE); + + const pagesStream = Page.find({ ...conditions }) + .populate('revision') + .cursor({ batchSize: BATCH_SIZE }); + const batchStream = createBatchStream(BATCH_SIZE); const createVectorStoreFile = this.createVectorStoreFile.bind(this); const createVectorStoreFileStream = new Transform({ objectMode: true, async transform(chunk: HydratedDocument[], encoding, callback) { - await createVectorStoreFile(chunk); - this.push(chunk); - callback(); + try { + logger.debug('Search results of page paths', chunk.map(page => page.path)); + await createVectorStoreFile(vectorStoreRelation, chunk); + this.push(chunk); + callback(); + } + catch (error) { + callback(error); + } }, }); - await pipeline(pagesStream, batchStrem, createVectorStoreFileStream); + await pipeline(pagesStream, batchStream, createVectorStoreFileStream); } - async rebuildVectorStore(page: HydratedDocument) { - const vectorStore = await this.getOrCreateVectorStoreForPublicScope(); - await this.deleteVectorStoreFile(vectorStore._id, page._id); - await this.createVectorStoreFile([page]); + private async createConditionForCreateVectorStoreFile( + owner: AiAssistant['owner'], + accessScope: AiAssistant['accessScope'], + grantedGroupsForAccessScope: AiAssistant['grantedGroupsForAccessScope'], + pagePathPatterns: AiAssistant['pagePathPatterns'], + ): Promise> { + + const converterdPagePathPatterns = convertPathPatternsToRegExp(pagePathPatterns); + + // Include pages in search targets when their paths with 'Anyone with the link' permission are directly specified instead of using glob pattern + const nonGrabPagePathPatterns = pagePathPatterns.filter(pagePathPattern => !isGrobPatternPath(pagePathPattern)); + const baseCondition: mongoose.FilterQuery = { + grant: PageGrant.GRANT_RESTRICTED, + path: { $in: nonGrabPagePathPatterns }, + }; + + if (accessScope === AiAssistantAccessScope.PUBLIC_ONLY) { + return { + $or: [ + baseCondition, + { + grant: PageGrant.GRANT_PUBLIC, + path: { $in: converterdPagePathPatterns }, + }, + ], + }; + } + + if (accessScope === AiAssistantAccessScope.GROUPS) { + if (grantedGroupsForAccessScope == null || grantedGroupsForAccessScope.length === 0) { + throw new Error('grantedGroups is required when accessScope is GROUPS'); + } + + const extractedGrantedGroupIdsForAccessScope = grantedGroupsForAccessScope.map(group => getIdForRef(group.item).toString()); + + return { + $or: [ + baseCondition, + { + grant: { $in: [PageGrant.GRANT_PUBLIC, PageGrant.GRANT_USER_GROUP] }, + path: { $in: converterdPagePathPatterns }, + $or: [ + { 'grantedGroups.item': { $in: extractedGrantedGroupIdsForAccessScope } }, + { grant: PageGrant.GRANT_PUBLIC }, + ], + }, + ], + }; + } + + if (accessScope === AiAssistantAccessScope.OWNER) { + const ownerUserGroups = [ + ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(owner)), + ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(owner)), + ].map(group => group.toString()); + + return { + $or: [ + baseCondition, + { + grant: { $in: [PageGrant.GRANT_PUBLIC, PageGrant.GRANT_USER_GROUP, PageGrant.GRANT_OWNER] }, + path: { $in: converterdPagePathPatterns }, + $or: [ + { 'grantedGroups.item': { $in: ownerUserGroups } }, + { grantedUsers: { $in: [getIdForRef(owner)] } }, + { grant: PageGrant.GRANT_PUBLIC }, + ], + }, + ], + }; + } + + throw new Error('Invalid accessScope value'); + } + + private async validateGrantedUserGroupsForAiAssistant( + owner: AiAssistant['owner'], + shareScope: AiAssistant['shareScope'], + accessScope: AiAssistant['accessScope'], + grantedGroupsForShareScope: AiAssistant['grantedGroupsForShareScope'], + grantedGroupsForAccessScope: AiAssistant['grantedGroupsForAccessScope'], + ) { + + // Check if grantedGroupsForShareScope is not specified when shareScope is not a “group” + if (shareScope !== AiAssistantShareScope.GROUPS && grantedGroupsForShareScope != null) { + throw new Error('grantedGroupsForShareScope is specified when shareScope is not “groups”.'); + } + + // Check if grantedGroupsForAccessScope is not specified when accessScope is not a “group” + if (accessScope !== AiAssistantAccessScope.GROUPS && grantedGroupsForAccessScope != null) { + throw new Error('grantedGroupsForAccessScope is specified when accsessScope is not “groups”.'); + } + + const ownerUserGroupIds = [ + ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(owner)), + ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(owner)), + ].map(group => group.toString()); + + // Check if the owner belongs to the group specified in grantedGroupsForShareScope + if (grantedGroupsForShareScope != null && grantedGroupsForShareScope.length > 0) { + const extractedGrantedGroupIdsForShareScope = grantedGroupsForShareScope.map(group => getIdForRef(group.item).toString()); + const isValid = extractedGrantedGroupIdsForShareScope.every(groupId => ownerUserGroupIds.includes(groupId)); + if (!isValid) { + throw new Error('A userGroup to which the owner does not belong is specified in grantedGroupsForShareScope'); + } + } + + // Check if the owner belongs to the group specified in grantedGroupsForAccessScope + if (grantedGroupsForAccessScope != null && grantedGroupsForAccessScope.length > 0) { + const extractedGrantedGroupIdsForAccessScope = grantedGroupsForAccessScope.map(group => getIdForRef(group.item).toString()); + const isValid = extractedGrantedGroupIdsForAccessScope.every(groupId => ownerUserGroupIds.includes(groupId)); + if (!isValid) { + throw new Error('A userGroup to which the owner does not belong is specified in grantedGroupsForAccessScope'); + } + } + } + + async createAiAssistant(data: Omit): Promise { + await this.validateGrantedUserGroupsForAiAssistant( + data.owner, + data.shareScope, + data.accessScope, + data.grantedGroupsForShareScope, + data.grantedGroupsForAccessScope, + ); + + const conditions = await this.createConditionForCreateVectorStoreFile( + data.owner, + data.accessScope, + data.grantedGroupsForAccessScope, + data.pagePathPatterns, + ); + + const vectorStoreRelation = await this.createVectorStore(data.name); + const aiAssistant = await AiAssistantModel.create({ + ...data, vectorStore: vectorStoreRelation, + }); + + // VectorStore creation process does not await + this.createVectorStoreFileWithStream(vectorStoreRelation, conditions); + + return aiAssistant; + } + + async updateAiAssistant(aiAssistantId: string, data: Omit): Promise { + const aiAssistant = await AiAssistantModel.findOne({ owner: data.owner, _id: aiAssistantId }); + if (aiAssistant == null) { + throw createError(404, 'AiAssistant document does not exist'); + } + + await this.validateGrantedUserGroupsForAiAssistant( + data.owner, + data.shareScope, + data.accessScope, + data.grantedGroupsForShareScope, + data.grantedGroupsForAccessScope, + ); + + const grantedGroupIdsForAccessScopeFromReq = data.grantedGroupsForAccessScope?.map(group => getIdStringForRef(group.item)) ?? []; // ObjectId[] -> string[] + const grantedGroupIdsForAccessScopeFromDb = aiAssistant.grantedGroupsForAccessScope?.map(group => getIdStringForRef(group.item)) ?? []; // ObjectId[] -> string[] + + // If accessScope, pagePathPatterns, grantedGroupsForAccessScope have not changed, do not build VectorStore + const shouldRebuildVectorStore = data.accessScope !== aiAssistant.accessScope + || !isDeepEquals(data.pagePathPatterns, aiAssistant.pagePathPatterns) + || !isDeepEquals(grantedGroupIdsForAccessScopeFromReq, grantedGroupIdsForAccessScopeFromDb); + + let newVectorStoreRelation: VectorStoreDocument | undefined; + if (shouldRebuildVectorStore) { + const conditions = await this.createConditionForCreateVectorStoreFile( + data.owner, + data.accessScope, + data.grantedGroupsForAccessScope, + data.pagePathPatterns, + ); + + // Delete obsoleted VectorStore + const obsoletedVectorStoreRelationId = getIdStringForRef(aiAssistant.vectorStore); + await this.deleteVectorStore(obsoletedVectorStoreRelationId); + + newVectorStoreRelation = await this.createVectorStore(data.name); + + // VectorStore creation process does not await + this.createVectorStoreFileWithStream(newVectorStoreRelation, conditions); + } + + const newData = { + ...data, + vectorStore: newVectorStoreRelation ?? aiAssistant.vectorStore, + }; + + aiAssistant.set({ ...newData }); + const updatedAiAssistant = await aiAssistant.save(); + + return updatedAiAssistant; + } + + async getAccessibleAiAssistants(user: IUserHasId): Promise { + const userGroupIds = [ + ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)), + ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)), + ]; + + const assistants = await AiAssistantModel.find({ + $or: [ + // Case 1: Assistants owned by the user + { owner: user }, + + // Case 2: Public assistants owned by others + { + $and: [ + { owner: { $ne: user } }, + { shareScope: AiAssistantShareScope.PUBLIC_ONLY }, + ], + }, + + // Case 3: Group-restricted assistants where user is in granted groups + { + $and: [ + { owner: { $ne: user } }, + { shareScope: AiAssistantShareScope.GROUPS }, + { 'grantedGroupsForShareScope.item': { $in: userGroupIds } }, + ], + }, + ], + }); + + return { + myAiAssistants: assistants.filter(assistant => assistant.owner.toString() === user._id.toString()) ?? [], + teamAiAssistants: assistants.filter(assistant => assistant.owner.toString() !== user._id.toString()) ?? [], + }; + } + + async deleteAiAssistant(ownerId: string, aiAssistantId: string): Promise { + const aiAssistant = await AiAssistantModel.findOne({ owner: ownerId, _id: aiAssistantId }); + if (aiAssistant == null) { + throw createError(404, 'AiAssistant document does not exist'); + } + + const vectorStoreRelationId = getIdStringForRef(aiAssistant.vectorStore); + await this.deleteVectorStore(vectorStoreRelationId); + + const deletedAiAssistant = await aiAssistant.remove(); + return deletedAiAssistant; } } diff --git a/apps/app/src/interfaces/ui.ts b/apps/app/src/interfaces/ui.ts index 1ed08fa0302..7cc10dd9152 100644 --- a/apps/app/src/interfaces/ui.ts +++ b/apps/app/src/interfaces/ui.ts @@ -1,5 +1,7 @@ import type { Nullable } from '@growi/core'; +import type { IPageForItem } from '~/interfaces/page'; + export const SidebarMode = { DRAWER: 'drawer', @@ -15,6 +17,7 @@ export const SidebarContentsType = { TAG: 'tag', BOOKMARKS: 'bookmarks', NOTIFICATION: 'notification', + AI_ASSISTANT: 'aiAssistant', } as const; export const AllSidebarContentsType = Object.values(SidebarContentsType); export type SidebarContentsType = typeof SidebarContentsType[keyof typeof SidebarContentsType]; @@ -35,4 +38,4 @@ export type OnRenamedFunction = (path: string) => void; export type OnDuplicatedFunction = (fromPath: string, toPath: string) => void; export type OnPutBackedFunction = (path: string) => void; export type onDeletedBookmarkFolderFunction = (bookmarkFolderId: string) => void; -export type OnSelectedFunction = () => void; +export type OnSelectedFunction = (page: IPageForItem, isIncludeSubPage: boolean) => void; diff --git a/apps/app/src/server/routes/apiv3/index.js b/apps/app/src/server/routes/apiv3/index.js index 000536da241..c7ff38bef5a 100644 --- a/apps/app/src/server/routes/apiv3/index.js +++ b/apps/app/src/server/routes/apiv3/index.js @@ -12,6 +12,7 @@ import g2gTransfer from './g2g-transfer'; import importRoute from './import'; import pageListing from './page-listing'; import securitySettings from './security-settings'; +import { factory as userRouteFactory } from './user'; import * as userActivation from './user-activation'; const logger = loggerFactory('growi:routes:apiv3'); // eslint-disable-line no-unused-vars @@ -127,5 +128,7 @@ module.exports = (crowi, app) => { router.use('/openai', openaiRouteFactory(crowi)); + router.use('/user', userRouteFactory(crowi)); + return [router, routerForAdmin, routerForAuth]; }; diff --git a/apps/app/src/server/routes/apiv3/page/create-page.ts b/apps/app/src/server/routes/apiv3/page/create-page.ts index b9fc11d56c5..ee81f79d36d 100644 --- a/apps/app/src/server/routes/apiv3/page/create-page.ts +++ b/apps/app/src/server/routes/apiv3/page/create-page.ts @@ -205,8 +205,9 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => { if (isAiEnabled()) { const { getOpenaiService } = await import('~/features/openai/server/services/openai'); try { + // TODO: https://redmine.weseek.co.jp/issues/160334 const openaiService = getOpenaiService(); - await openaiService?.rebuildVectorStore(createdPage); + // await openaiService?.rebuildVectorStore(createdPage); } catch (err) { logger.error('Rebuild vector store failed', err); diff --git a/apps/app/src/server/routes/apiv3/page/update-page.ts b/apps/app/src/server/routes/apiv3/page/update-page.ts index 463b553f22a..90ed6a20216 100644 --- a/apps/app/src/server/routes/apiv3/page/update-page.ts +++ b/apps/app/src/server/routes/apiv3/page/update-page.ts @@ -121,8 +121,9 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => { if (isAiEnabled()) { const { getOpenaiService } = await import('~/features/openai/server/services/openai'); try { + // TODO: https://redmine.weseek.co.jp/issues/160335 const openaiService = getOpenaiService(); - await openaiService?.rebuildVectorStore(updatedPage); + // await openaiService?.rebuildVectorStore(updatedPage); } catch (err) { logger.error('Rebuild vector store failed', err); diff --git a/apps/app/src/server/routes/apiv3/user/get-related-groups.ts b/apps/app/src/server/routes/apiv3/user/get-related-groups.ts new file mode 100644 index 00000000000..b08faed5276 --- /dev/null +++ b/apps/app/src/server/routes/apiv3/user/get-related-groups.ts @@ -0,0 +1,35 @@ +import type { IUserHasId } from '@growi/core'; +import { ErrorV3 } from '@growi/core/dist/models'; +import type { Request, RequestHandler } from 'express'; + +import type Crowi from '~/server/crowi'; +import { accessTokenParser } from '~/server/middlewares/access-token-parser'; +import loggerFactory from '~/utils/logger'; + +import type { ApiV3Response } from '../interfaces/apiv3-response'; + +const logger = loggerFactory('growi:routes:apiv3:user:get-related-groups'); + +type GetRelatedGroupsHandlerFactory = (crowi: Crowi) => RequestHandler[]; + +interface Req extends Request { + user: IUserHasId, +} + +export const getRelatedGroupsHandlerFactory: GetRelatedGroupsHandlerFactory = (crowi) => { + const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi); + + return [ + accessTokenParser, loginRequiredStrictly, + async(req: Req, res: ApiV3Response) => { + try { + const relatedGroups = await crowi.pageGrantService?.getUserRelatedGroups(req.user); + return res.apiv3({ relatedGroups }); + } + catch (err) { + logger.error(err); + return res.apiv3Err(new ErrorV3('Error occurred while getting user related groups')); + } + }, + ]; +}; diff --git a/apps/app/src/server/routes/apiv3/user/index.ts b/apps/app/src/server/routes/apiv3/user/index.ts new file mode 100644 index 00000000000..1132d8b129b --- /dev/null +++ b/apps/app/src/server/routes/apiv3/user/index.ts @@ -0,0 +1,13 @@ +import express from 'express'; + +import type Crowi from '~/server/crowi'; + +import { getRelatedGroupsHandlerFactory } from './get-related-groups'; + +const router = express.Router(); + +export const factory = (crowi: Crowi): express.Router => { + router.get('/related-groups', getRelatedGroupsHandlerFactory(crowi)); + + return router; +}; diff --git a/apps/app/src/server/service/page/index.ts b/apps/app/src/server/service/page/index.ts index 6a497e8d346..8eb62795649 100644 --- a/apps/app/src/server/service/page/index.ts +++ b/apps/app/src/server/service/page/index.ts @@ -1171,11 +1171,12 @@ class PageService implements IPageService { ); if (isAiEnabled()) { + // TODO: https://redmine.weseek.co.jp/issues/160336 const { getOpenaiService } = await import('~/features/openai/server/services/openai'); // Do not await because communication with OpenAI takes time const openaiService = getOpenaiService(); - openaiService?.createVectorStoreFile([duplicatedTarget]); + // openaiService?.createVectorStoreFile([duplicatedTarget]); } } this.pageEvent.emit('duplicate', page, user); @@ -1412,11 +1413,12 @@ class PageService implements IPageService { .find({ _id: { $in: duplicatedPageIds }, grant: PageGrant.GRANT_PUBLIC }).populate('revision') as PageDocument[]; if (isAiEnabled()) { + // TODO: https://redmine.weseek.co.jp/issues/160336 const { getOpenaiService } = await import('~/features/openai/server/services/openai'); // Do not await because communication with OpenAI takes time const openaiService = getOpenaiService(); - openaiService?.createVectorStoreFile(duplicatedPagesWithPopulatedToShowRevison); + // openaiService?.createVectorStoreFile(duplicatedPagesWithPopulatedToShowRevison); } } @@ -1898,11 +1900,12 @@ class PageService implements IPageService { if (isAiEnabled()) { const { getOpenaiService } = await import('~/features/openai/server/services/openai'); + // TODO: https://redmine.weseek.co.jp/issues/160337 const openaiService = getOpenaiService(); if (openaiService != null) { - const vectorStore = await openaiService.getOrCreateVectorStoreForPublicScope(); - const deleteVectorStoreFilePromises = pageIds.map(pageId => openaiService.deleteVectorStoreFile(vectorStore._id, pageId)); - await Promise.allSettled(deleteVectorStoreFilePromises); + // const vectorStore = await openaiService.getOrCreateVectorStoreForPublicScope(); + // const deleteVectorStoreFilePromises = pageIds.map(pageId => openaiService.deleteVectorStoreFile(vectorStore._id, pageId)); + // await Promise.allSettled(deleteVectorStoreFilePromises); } } } diff --git a/apps/app/src/stores/modal.tsx b/apps/app/src/stores/modal.tsx index 25614db3944..990f42bf2a0 100644 --- a/apps/app/src/stores/modal.tsx +++ b/apps/app/src/stores/modal.tsx @@ -720,7 +720,8 @@ export const useDeleteAttachmentModal = (): SWRResponse - close(): Promise + open(opts?: IPageSelectModalOption): void + close(): void } export const usePageSelectModal = ( diff --git a/apps/app/src/stores/user.tsx b/apps/app/src/stores/user.tsx index 0a8d3f4da91..371fbb3bbe6 100644 --- a/apps/app/src/stores/user.tsx +++ b/apps/app/src/stores/user.tsx @@ -4,6 +4,7 @@ import useSWR from 'swr'; import useSWRImmutable from 'swr/immutable'; import { apiv3Get } from '~/client/util/apiv3-client'; +import type { PopulatedGrantedGroup } from '~/interfaces/page-grant'; import { checkAndUpdateImageUrlCached } from '~/stores/middlewares/user'; export const useSWRxUsersList = (userIds: string[]): SWRResponse => { @@ -49,3 +50,14 @@ export const useSWRxUsernames = (q: string, offset?: number, limit?: number, opt }).then(result => result.data), ); }; + +type RelatedGroupsResponse = { + relatedGroups: PopulatedGrantedGroup[] +} + +export const useSWRxUserRelatedGroups = (): SWRResponse => { + return useSWRImmutable( + ['/user/related-groups'], + ([endpoint]) => apiv3Get(endpoint).then(response => response.data), + ); +}; diff --git a/apps/app/src/utils/is-deep-equal.ts b/apps/app/src/utils/is-deep-equal.ts new file mode 100644 index 00000000000..e391a0ddf10 --- /dev/null +++ b/apps/app/src/utils/is-deep-equal.ts @@ -0,0 +1,30 @@ +export const isDeepEquals = (obj1: T, obj2: T): boolean => { + const typedKeys1 = Object.keys(obj1) as (keyof T)[]; + const typedKeys2 = Object.keys(obj2) as (keyof T)[]; + + if (typedKeys1.length !== typedKeys2.length) { + return false; + } + + return typedKeys1.every((key) => { + const val1 = obj1[key]; + const val2 = obj2[key]; + + if (typeof val1 === 'object' && typeof val2 === 'object') { + if (val1 === null || val2 === null) { + return val1 === val2; + } + + // if array + if (Array.isArray(val1) && Array.isArray(val2)) { + return val1.length === val2.length && val1.every((item, i) => val2[i] === item); + } + + // if object + return isDeepEquals(val1, val2); + } + + // if primitive + return val1 === val2; + }); +}; diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 454855a9ab6..e47e2418121 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -9,6 +9,7 @@ export * as objectIdUtils from './objectid-utils'; export * as pagePathUtils from './page-path-utils'; export * as pathUtils from './path-utils'; export * as pageUtils from './page-utils'; +export * as deepEquals from './is-deep-equals'; export * from './browser-utils'; export * from './growi-theme-metadata'; diff --git a/packages/core/src/utils/is-deep-equals.ts b/packages/core/src/utils/is-deep-equals.ts new file mode 100644 index 00000000000..e391a0ddf10 --- /dev/null +++ b/packages/core/src/utils/is-deep-equals.ts @@ -0,0 +1,30 @@ +export const isDeepEquals = (obj1: T, obj2: T): boolean => { + const typedKeys1 = Object.keys(obj1) as (keyof T)[]; + const typedKeys2 = Object.keys(obj2) as (keyof T)[]; + + if (typedKeys1.length !== typedKeys2.length) { + return false; + } + + return typedKeys1.every((key) => { + const val1 = obj1[key]; + const val2 = obj2[key]; + + if (typeof val1 === 'object' && typeof val2 === 'object') { + if (val1 === null || val2 === null) { + return val1 === val2; + } + + // if array + if (Array.isArray(val1) && Array.isArray(val2)) { + return val1.length === val2.length && val1.every((item, i) => val2[i] === item); + } + + // if object + return isDeepEquals(val1, val2); + } + + // if primitive + return val1 === val2; + }); +}; diff --git a/packages/core/src/utils/page-path-utils/index.ts b/packages/core/src/utils/page-path-utils/index.ts index 94f9de93292..119b41c7b89 100644 --- a/packages/core/src/utils/page-path-utils/index.ts +++ b/packages/core/src/utils/page-path-utils/index.ts @@ -293,5 +293,11 @@ export const getUsernameByPath = (path: string): string | null => { return username; }; +export const isGrobPatternPath = (path: string): boolean => { + // https://regex101.com/r/IBy7HS/1 + const globPattern = /^(?:\/[^/*?[\]{}]+)*\/\*$/; + return globPattern.test(path); +}; + export * from './is-top-page'; diff --git a/packages/custom-icons/svg/knowledge_assistant.svg b/packages/custom-icons/svg/ai_assistant.svg similarity index 100% rename from packages/custom-icons/svg/knowledge_assistant.svg rename to packages/custom-icons/svg/ai_assistant.svg diff --git a/packages/editor/src/client/stores/codemirror-editor.ts b/packages/editor/src/client/stores/codemirror-editor.ts index 571c31bec41..40a3c502be8 100644 --- a/packages/editor/src/client/stores/codemirror-editor.ts +++ b/packages/editor/src/client/stores/codemirror-editor.ts @@ -1,23 +1,20 @@ import { useMemo, useRef } from 'react'; import { useSWRStatic } from '@growi/core/dist/swr'; +import { deepEquals } from '@growi/core/dist/utils'; import type { ReactCodeMirrorProps, UseCodeMirror } from '@uiw/react-codemirror'; import type { SWRResponse } from 'swr'; import deepmerge from 'ts-deepmerge'; import { type UseCodeMirrorEditor, useCodeMirrorEditor } from '../services'; +const { isDeepEquals } = deepEquals; + const isValid = (u: UseCodeMirrorEditor) => { return u.state != null && u.view != null; }; -const isDeepEquals = (obj1: T, obj2: T): boolean => { - const typedKeys = Object.keys(obj1) as (keyof typeof obj1)[]; - return typedKeys.every(key => obj1[key] === obj2[key]); -}; - - export const useCodeMirrorEditorIsolated = ( key: string | null, container?: HTMLDivElement | null, props?: ReactCodeMirrorProps, ): SWRResponse => {