Explorar el Código

Merge branch 'feat/growi-ai-next' into feat/160587-

Shun Miyazawa hace 1 año
padre
commit
2bc0fa58c9
Se han modificado 26 ficheros con 809 adiciones y 267 borrados
  1. 8 1
      apps/app/public/static/locales/en_US/translation.json
  2. 8 0
      apps/app/public/static/locales/fr_FR/translation.json
  3. 8 0
      apps/app/public/static/locales/ja_JP/translation.json
  4. 8 0
      apps/app/public/static/locales/zh_CN/translation.json
  5. 4 4
      apps/app/src/components/Layout/BasicLayout.tsx
  6. 39 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditInstruction.tsx
  7. 52 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditPages.tsx
  8. 26 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHeader.tsx
  9. 101 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHome.tsx
  10. 15 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.module.scss
  11. 228 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx
  12. 0 8
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManegementModal.module.scss
  13. 0 194
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManegementModal.tsx
  14. 2 2
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.tsx
  15. 17 11
      apps/app/src/features/openai/client/components/Common/SelectedPageList.tsx
  16. 21 10
      apps/app/src/features/openai/client/stores/ai-assistant.tsx
  17. 7 1
      apps/app/src/features/openai/interfaces/ai-assistant.ts
  18. 23 1
      apps/app/src/features/openai/server/models/ai-assistant.ts
  19. 21 8
      apps/app/src/features/openai/server/routes/ai-assistant.ts
  20. 42 0
      apps/app/src/features/openai/server/routes/ai-assistants.ts
  21. 4 0
      apps/app/src/features/openai/server/routes/index.ts
  22. 112 27
      apps/app/src/features/openai/server/services/openai.ts
  23. 3 0
      apps/app/src/server/routes/apiv3/index.js
  24. 35 0
      apps/app/src/server/routes/apiv3/user/get-related-groups.ts
  25. 13 0
      apps/app/src/server/routes/apiv3/user/index.ts
  26. 12 0
      apps/app/src/stores/user.tsx

+ 8 - 1
apps/app/public/static/locales/en_US/translation.json

@@ -501,7 +501,14 @@
     "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"
+    }
   },
   "link_edit": {
     "edit_link": "Edit Link",

+ 8 - 0
apps/app/public/static/locales/fr_FR/translation.json

@@ -497,6 +497,14 @@
     "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"
+    }
+  },
   "link_edit": {
     "edit_link": "Modifier lien",
     "set_link_and_label": "Ajouter lien et étiquette",

+ 8 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -535,6 +535,14 @@
     "error_message": "エラーが発生しました",
     "show_error_detail": "詳細を表示"
   },
+  "modal_ai_assistant": {
+    "default_instruction": "あなたはこのWikiの知識アシスタントです。以下の方針で支援を行ってください:\n\n- 文書の関連性分析と情報の関連付け\n- 新しい視点の提案\n- 質問の意図を理解した的確な情報提供 必要に応じて構造化された形式で情報を提供します。",
+    "page_mode_title": {
+      "share": "アシスタントの共有",
+      "pages": "参照ページ",
+      "instruction": "アシスタントへの指示"
+    }
+  },
   "link_edit": {
     "edit_link": "リンク編集",
     "set_link_and_label": "リンク情報",

+ 8 - 0
apps/app/public/static/locales/zh_CN/translation.json

@@ -491,6 +491,14 @@
     "error_message": "错误",
     "show_error_detail": "显示详情"
   },
+  "modal_ai_assistant": {
+    "default_instruction": "您是这个Wiki的知识助手。请按照以下方针提供支持:\n\n- 分析文档相关性并连接信息\n- 提出新的观点\n- 理解问题意图并提供准确信息\n必要时我会以结构化的形式提供信息。",
+    "page_mode_title": {
+      "share": "助理共享",
+      "pages": "参考页面",
+      "instruction": "助理指示"
+    }
+  },
   "link_edit": {
     "edit_link": "Edit Link",
     "set_link_and_label": "Set link and label",

+ 4 - 4
apps/app/src/components/Layout/BasicLayout.tsx

@@ -35,9 +35,9 @@ 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 AiAssistantManegementModal = dynamic(
-  () => import('~/features/openai/client/components/AiAssistant/AiAssistantManegementModal')
-    .then(mod => mod.AiAssistantManegementModal), { 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 });
 
@@ -74,7 +74,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
       <PageSelectModal />
       <SearchModal />
       <AiChatModal />
-      <AiAssistantManegementModal />
+      <AiAssistantManagementModal />
 
       <PagePresentationModal />
       <HotkeysManager />

+ 39 - 0
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 (
+    <>
+      <AiAssistantManagementHeader />
+
+      <ModalBody className="px-4">
+        <p className="text-secondary py-1">
+          アシスタントの振る舞いを決める指示文を設定できます。<br />
+          この指示に従ってにアシスタントの回答や分析を行います。
+        </p>
+
+        <Input
+          autoFocus
+          type="textarea"
+          className="mb-3"
+          rows="8"
+          value={instruction}
+          onChange={e => onChange(e.target.value)}
+        />
+
+        <button type="button" onClick={onReset} className="btn btn-outline-secondary btn-sm">
+          デフォルトに戻す
+        </button>
+      </ModalBody>
+    </>
+  );
+};

+ 52 - 0
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 (
+    <>
+      <AiAssistantManagementHeader />
+
+      <ModalBody className="px-4">
+        <p className="text-secondary py-1">
+          アシスタントが参照するページを編集します。<br />
+          参照できるページは配下ページも含めて200ページまでです。
+        </p>
+
+        <button
+          type="button"
+          onClick={clickOpenPageSelectModalHandler}
+          className="btn btn-outline-primary w-100 mb-3 d-flex align-items-center justify-content-center"
+        >
+          <span className="material-symbols-outlined me-2">add</span>
+          ページを追加する
+        </button>
+
+        <SelectedPageList selectedPages={selectedPages} onRemove={onRemove} />
+      </ModalBody>
+    </>
+  );
+};

+ 26 - 0
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 (
+    <ModalHeader
+      close={(
+        <button type="button" className="btn p-0" onClick={close}>
+          <span className="material-symbols-outlined">close</span>
+        </button>
+      )}
+    >
+      <div className="d-flex align-items-center">
+        <button type="button" className="btn p-0 me-3" onClick={() => changePageMode(AiAssistantManagementModalPageMode.HOME)}>
+          <span className="material-symbols-outlined text-primary">chevron_left</span>
+        </button>
+        <span>{t(`modal_ai_assistant.page_mode_title.${data?.pageMode}`)}</span>
+      </div>
+    </ModalHeader>
+  );
+};

+ 101 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHome.tsx

@@ -0,0 +1,101 @@
+import React from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  ModalHeader, ModalBody, ModalFooter, Input,
+} from 'reactstrap';
+
+import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode } from '../../../stores/ai-assistant';
+
+type Props = {
+  instruction: string;
+}
+
+export const AiAssistantManagementHome = (props: Props): JSX.Element => {
+  const { instruction } = props;
+
+  const { t } = useTranslation();
+
+  const { close: closeAiAssistantManagementModal, changePageMode } = useAiAssistantManagementModal();
+
+  return (
+    <>
+      <ModalHeader tag="h4" toggle={closeAiAssistantManagementModal} className="pe-4">
+        <span className="growi-custom-icons growi-ai-assistant-icon me-3 fs-4">ai_assistant</span>
+        <span className="fw-bold">新規アシスタントの追加</span> {/* TODO i18n */}
+      </ModalHeader>
+
+      <div className="px-4">
+        <ModalBody>
+          <div className="mb-4 growi-ai-assistant-name">
+            <Input
+              type="text"
+              placeholder="アシスタント名を入力"
+              bsSize="lg"
+              className="border-0 border-bottom border-2 px-0 rounded-0"
+            />
+          </div>
+
+          <div className="mb-4">
+            <div className="d-flex align-items-center mb-2">
+              <span className="text-secondary">アシスタントのメモ</span>
+              <span className="badge text-bg-secondary ms-2">任意</span>
+            </div>
+            <Input
+              type="textarea"
+              placeholder="内容や用途のメモを表示させることができます"
+              rows="4"
+            />
+            <small className="text-secondary d-block mt-2">
+              メモの内容はアシスタントの処理に影響しません。
+            </small>
+          </div>
+
+          <div>
+            <button
+              type="button"
+              className="btn w-100 d-flex justify-content-between align-items-center py-3 mb-2 border-0"
+            >
+              <span className="fw-normal">{t('modal_ai_assistant.page_mode_title.share')}</span>
+              <div className="d-flex align-items-center text-secondary">
+                <span>UserNameのみ</span>
+                <span className="material-symbols-outlined ms-2 align-middle">chevron_right</span>
+              </div>
+            </button>
+
+            <button
+              type="button"
+              onClick={() => { changePageMode(AiAssistantManagementModalPageMode.PAGES) }}
+              className="btn w-100 d-flex justify-content-between align-items-center py-3 mb-2 border-0"
+            >
+              <span className="fw-normal">{t('modal_ai_assistant.page_mode_title.pages')}</span>
+              <div className="d-flex align-items-center text-secondary">
+                <span>3ページ</span>
+                <span className="material-symbols-outlined ms-2 align-middle">chevron_right</span>
+              </div>
+            </button>
+
+            <button
+              type="button"
+              onClick={() => { changePageMode(AiAssistantManagementModalPageMode.INSTRUCTION) }}
+              className="btn w-100 d-flex justify-content-between align-items-center py-3 mb-2 border-0"
+            >
+              <span className="fw-normal">{t('modal_ai_assistant.page_mode_title.instruction')}</span>
+              <div className="d-flex align-items-center text-secondary">
+                <span className="text-truncate" style={{ maxWidth: '280px' }}>
+                  {instruction}
+                </span>
+                <span className="material-symbols-outlined ms-2 align-middle">chevron_right</span>
+              </div>
+            </button>
+          </div>
+        </ModalBody>
+
+        <ModalFooter>
+          <button type="button" className="btn btn-outline-secondary" onClick={() => {}}>キャンセル</button>
+          <button type="button" className="btn btn-primary" onClick={() => {}}>アシスタントを作成する</button>
+        </ModalFooter>
+      </div>
+    </>
+  );
+};

+ 15 - 0
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;
+    }
+  }
+}

+ 228 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx

@@ -0,0 +1,228 @@
+import React, { useCallback, useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { Modal, TabContent, TabPane } from 'reactstrap';
+
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import type { IPageForItem } from '~/interfaces/page';
+import loggerFactory from '~/utils/logger';
+
+import type { SelectedPage } from '../../../../interfaces/selected-page';
+import { createAiAssistant } from '../../../services/ai-assistant';
+import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode } from '../../../stores/ai-assistant';
+
+import { AiAssistantManagementEditInstruction } from './AiAssistantManagementEditInstruction';
+import { AiAssistantManagementEditPages } from './AiAssistantManagementEditPages';
+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');
+
+const AiAssistantManagementModalSubstance = (): JSX.Element => {
+  // Hooks
+  const { t } = useTranslation();
+  const { data: aiAssistantManagementModalData } = useAiAssistantManagementModal();
+
+  const pageMode = aiAssistantManagementModalData?.pageMode ?? AiAssistantManagementModalPageMode.HOME;
+
+  // States
+  const [selectedPages, setSelectedPages] = useState<SelectedPage[]>([]);
+  const [instruction, setInstruction] = useState<string>(t('modal_ai_assistant.default_instruction'));
+
+  // Functions
+  const clickCreateAiAssistantHandler = 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);
+
+      await createAiAssistant({
+        name: 'test',
+        description: 'test',
+        additionalInstruction: instruction,
+        pagePathPatterns,
+        shareScope: 'publicOnly',
+        accessScope: 'publicOnly',
+      });
+      toastSuccess('アシスタントを作成しました');
+    }
+    catch (err) {
+      toastError('アシスタントの作成に失敗しました');
+      logger.error(err);
+    }
+  }, [instruction, selectedPages]);
+
+
+  /*
+  *  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 (
+    <>
+      <TabContent activeTab={pageMode}>
+        <TabPane tabId={AiAssistantManagementModalPageMode.HOME}>
+          <AiAssistantManagementHome
+            instruction={instruction}
+          />
+        </TabPane>
+
+        <TabPane tabId={AiAssistantManagementModalPageMode.PAGES}>
+          <AiAssistantManagementEditPages
+            selectedPages={selectedPages}
+            onSelect={selectPageHandler}
+            onRemove={removePageHandler}
+          />
+        </TabPane>
+
+        <TabPane tabId={AiAssistantManagementModalPageMode.INSTRUCTION}>
+          <AiAssistantManagementEditInstruction
+            instruction={instruction}
+            onChange={changeInstructionHandler}
+            onReset={resetInstructionHandler}
+          />
+        </TabPane>
+      </TabContent>
+    </>
+    // <div className="px-4">
+    //   <ModalBody>
+    //     <Form>
+    //       <FormGroup className="mb-4">
+    //         <Label className="mb-2 ">アシスタント名</Label>
+    //         <Input
+    //           type="text"
+    //           placeholder="アシスタント名を入力"
+    //           className="border rounded"
+    //         />
+    //       </FormGroup>
+
+  //       <FormGroup className="mb-4">
+  //         <div className="d-flex align-items-center mb-2">
+  //           <Label className="mb-0">アシスタントの種類</Label>
+  //           <span className="ms-1 fs-5 material-symbols-outlined text-secondary">help</span>
+  //         </div>
+  //         <div className="d-flex gap-4">
+  //           <FormGroup check>
+  //             <Input type="checkbox" defaultChecked />
+  //             <Label check>ナレッジアシスタント</Label>
+  //           </FormGroup>
+  //           <FormGroup check>
+  //             <Input type="checkbox" />
+  //             <Label check>エディタアシスタント</Label>
+  //           </FormGroup>
+  //           <FormGroup check>
+  //             <Input type="checkbox" />
+  //             <Label check>ラーニングアシスタント</Label>
+  //           </FormGroup>
+  //         </div>
+  //       </FormGroup>
+
+  //       <FormGroup className="mb-4">
+  //         <div className="d-flex align-items-center mb-2">
+  //           <Label className="mb-0">共有範囲</Label>
+  //           <span className="ms-1 fs-5 material-symbols-outlined text-secondary">help</span>
+  //         </div>
+  //         <Input type="select" className="border rounded w-50">
+  //           <option>自分のみ</option>
+  //         </Input>
+  //       </FormGroup>
+
+  //       <FormGroup className="mb-4">
+  //         <div className="d-flex align-items-center mb-2">
+  //           <Label className="mb-0">参照するページ</Label>
+  //           <span className="ms-1 fs-5 material-symbols-outlined text-secondary">help</span>
+  //         </div>
+  //         <SelectedPageList selectedPages={selectedPages} onRemove={clickRmoveSelectedPageHandler} />
+  //         <button
+  //           type="button"
+  //           className="btn btn-outline-primary d-flex align-items-center gap-1"
+  //           onClick={clickOpenPageSelectModalHandler}
+  //         >
+  //           <span>+</span>
+  //           追加する
+  //         </button>
+  //       </FormGroup>
+
+  //       <FormGroup>
+  //         <div className="d-flex align-items-center mb-2">
+  //           <Label className="mb-0 me-2">アシスタントへの指示</Label>
+  //           <label className="form-label form-check-label">
+  //             <span className="badge text-bg-danger mt-2">
+  //               必須
+  //             </span>
+  //           </label>
+  //         </div>
+  //         <Input
+  //           type="textarea"
+  //           placeholder="アシスタントに実行して欲しい内容を具体的に記入してください"
+  //           className="border rounded"
+  //           rows={4}
+  //         />
+  //       </FormGroup>
+
+  //       <FormGroup>
+  //         <div className="d-flex align-items-center mb-2">
+  //           <Label className="mb-0 me-2">アシスタントのメモ</Label>
+  //           <label className="form-label form-check-label">
+  //             <span className="badge text-bg-secondary mt-2">
+  //               必須
+  //             </span>
+  //           </label>
+  //         </div>
+  //         <Input
+  //           type="textarea"
+  //           placeholder="内容や用途のメモを表示させることができます"
+  //           className="border rounded"
+  //           rows={4}
+  //         />
+  //         <p className="mt-1 text-muted">メモ内容はアシスタントには影響しません。</p>
+  //       </FormGroup>
+  //     </Form>
+  //   </ModalBody>
+
+  //   <ModalFooter className="border-0 pt-0 mb-3">
+  //     <button type="button" className="btn btn-outline-secondary" onClick={() => {}}>キャンセル</button>
+  //     <button type="button" className="btn btn-primary" onClick={clickCreateAiAssistantHandler}>作成</button>
+  //   </ModalFooter>
+  // </div>
+  );
+};
+
+
+export const AiAssistantManagementModal = (): JSX.Element => {
+  const { data: aiAssistantManagementModalData, close: closeAiAssistantManagementModal } = useAiAssistantManagementModal();
+
+  const isOpened = aiAssistantManagementModalData?.isOpened ?? false;
+
+  return (
+    <Modal size="lg" isOpen={isOpened} toggle={closeAiAssistantManagementModal} className={moduleClass} scrollable>
+      { isOpened && (
+        <AiAssistantManagementModalSubstance />
+      ) }
+    </Modal>
+  );
+};

+ 0 - 8
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManegementModal.module.scss

@@ -1,8 +0,0 @@
-@use '@growi/core-styles/scss/variables/growi-official-colors';
-
-// == Colors
-.grw-ai-assistant-manegement :global {
-  .growi-ai-assistant-icon {
-    color: growi-official-colors.$growi-ai-purple;
-  }
-}

+ 0 - 194
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManegementModal.tsx

@@ -1,194 +0,0 @@
-import React, { useCallback, useState } from 'react';
-
-import { useTranslation } from 'react-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter, Form, FormGroup, Label, Input,
-} from 'reactstrap';
-
-import { toastError, toastSuccess } from '~/client/util/toastr';
-import type { IPageForItem } from '~/interfaces/page';
-import { usePageSelectModal } from '~/stores/modal';
-import loggerFactory from '~/utils/logger';
-
-import type { SelectedPage } from '../../../interfaces/selected-page';
-import { createAiAssistant } from '../../services/ai-assistant';
-import { useAiAssistantManegementModal } from '../../stores/ai-assistant';
-import { SelectedPageList } from '../Common/SelectedPageList';
-
-
-import styles from './AiAssistantManegementModal.module.scss';
-
-const moduleClass = styles['grw-ai-assistant-manegement'] ?? '';
-
-const logger = loggerFactory('growi:openai:client:components:AiAssistantManegementModal');
-
-const AiAssistantManegementModalSubstance = (): JSX.Element => {
-  const { open: openPageSelectModal } = usePageSelectModal();
-  const [selectedPages, setSelectedPages] = useState<SelectedPage[]>([]);
-
-  const clickOpenPageSelectModalHandler = useCallback(() => {
-    const onSelected = (page: IPageForItem, isIncludeSubPage: boolean) => {
-      const selectedPageIds = selectedPages.map(selectedPage => selectedPage.page._id);
-      if (page._id != null && !selectedPageIds.includes(page._id)) {
-        setSelectedPages([...selectedPages, { page, isIncludeSubPage }]);
-      }
-    };
-
-    openPageSelectModal({ onSelected, isHierarchicalSelectionMode: true });
-  }, [openPageSelectModal, selectedPages]);
-
-
-  const clickRmoveSelectedPageHandler = useCallback((pageId: string) => {
-    setSelectedPages(selectedPages.filter(selectedPage => selectedPage.page._id !== pageId));
-  }, [selectedPages]);
-
-  const clickCreateAiAssistantHandler = 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);
-
-      await createAiAssistant({
-        name: 'test',
-        description: 'test',
-        additionalInstruction: 'test',
-        pagePathPatterns,
-        shareScope: 'publicOnly',
-        accessScope: 'publicOnly',
-      });
-      toastSuccess('アシスタントを作成しました');
-    }
-    catch (err) {
-      toastError('アシスタントの作成に失敗しました');
-      logger.error(err);
-    }
-  }, [selectedPages]);
-
-  return (
-    <div className="px-4">
-      <ModalBody>
-        <Form>
-          <FormGroup className="mb-4">
-            <Label className="mb-2 ">アシスタント名</Label>
-            <Input
-              type="text"
-              placeholder="アシスタント名を入力"
-              className="border rounded"
-            />
-          </FormGroup>
-
-          <FormGroup className="mb-4">
-            <div className="d-flex align-items-center mb-2">
-              <Label className="mb-0">アシスタントの種類</Label>
-              <span className="ms-1 fs-5 material-symbols-outlined text-secondary">help</span>
-            </div>
-            <div className="d-flex gap-4">
-              <FormGroup check>
-                <Input type="checkbox" defaultChecked />
-                <Label check>ナレッジアシスタント</Label>
-              </FormGroup>
-              <FormGroup check>
-                <Input type="checkbox" />
-                <Label check>エディタアシスタント</Label>
-              </FormGroup>
-              <FormGroup check>
-                <Input type="checkbox" />
-                <Label check>ラーニングアシスタント</Label>
-              </FormGroup>
-            </div>
-          </FormGroup>
-
-          <FormGroup className="mb-4">
-            <div className="d-flex align-items-center mb-2">
-              <Label className="mb-0">共有範囲</Label>
-              <span className="ms-1 fs-5 material-symbols-outlined text-secondary">help</span>
-            </div>
-            <Input type="select" className="border rounded w-50">
-              <option>自分のみ</option>
-            </Input>
-          </FormGroup>
-
-          <FormGroup className="mb-4">
-            <div className="d-flex align-items-center mb-2">
-              <Label className="mb-0">参照するページ</Label>
-              <span className="ms-1 fs-5 material-symbols-outlined text-secondary">help</span>
-            </div>
-            <SelectedPageList selectedPages={selectedPages} onRemove={clickRmoveSelectedPageHandler} />
-            <button
-              type="button"
-              className="btn btn-outline-primary d-flex align-items-center gap-1"
-              onClick={clickOpenPageSelectModalHandler}
-            >
-              <span>+</span>
-              追加する
-            </button>
-          </FormGroup>
-
-          <FormGroup>
-            <div className="d-flex align-items-center mb-2">
-              <Label className="mb-0 me-2">アシスタントへの指示</Label>
-              <label className="form-label form-check-label">
-                <span className="badge text-bg-danger mt-2">
-                  必須
-                </span>
-              </label>
-            </div>
-            <Input
-              type="textarea"
-              placeholder="アシスタントに実行して欲しい内容を具体的に記入してください"
-              className="border rounded"
-              rows={4}
-            />
-          </FormGroup>
-
-          <FormGroup>
-            <div className="d-flex align-items-center mb-2">
-              <Label className="mb-0 me-2">アシスタントのメモ</Label>
-              <label className="form-label form-check-label">
-                <span className="badge text-bg-secondary mt-2">
-                  必須
-                </span>
-              </label>
-            </div>
-            <Input
-              type="textarea"
-              placeholder="内容や用途のメモを表示させることができます"
-              className="border rounded"
-              rows={4}
-            />
-            <p className="mt-1 text-muted">メモ内容はアシスタントには影響しません。</p>
-          </FormGroup>
-        </Form>
-      </ModalBody>
-
-      <ModalFooter className="border-0 pt-0 mb-3">
-        <button type="button" className="btn btn-outline-secondary" onClick={() => {}}>キャンセル</button>
-        <button type="button" className="btn btn-primary" onClick={clickCreateAiAssistantHandler}>作成</button>
-      </ModalFooter>
-    </div>
-  );
-};
-
-
-export const AiAssistantManegementModal = (): JSX.Element => {
-  const { t } = useTranslation();
-
-  const { data: aiAssistantManegementModalData, close: closeAiAssistantManegementModal } = useAiAssistantManegementModal();
-
-  const isOpened = aiAssistantManegementModalData?.isOpened ?? false;
-
-  return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeAiAssistantManegementModal} className={moduleClass} scrollable>
-
-      <ModalHeader tag="h4" toggle={closeAiAssistantManegementModal} className="pe-4">
-        <span className="growi-custom-icons growi-ai-assistant-icon me-3 fs-4">ai_assistant</span>
-        <span className="fw-bold">新規アシスタントの追加</span> {/* TODO i18n */}
-      </ModalHeader>
-
-      { isOpened && (
-        <AiAssistantManegementModalSubstance />
-      ) }
-
-    </Modal>
-  );
-};

+ 2 - 2
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.tsx

@@ -1,13 +1,13 @@
 import React from 'react';
 
-import { useAiAssistantManegementModal } from '../../../stores/ai-assistant';
+import { useAiAssistantManagementModal } from '../../../stores/ai-assistant';
 
 import styles from './AiAssistantSubstance.module.scss';
 
 const moduleClass = styles['grw-ai-assistant-substance'] ?? '';
 
 export const AiAssistantContent = (): JSX.Element => {
-  const { open } = useAiAssistantManegementModal();
+  const { open } = useAiAssistantManagementModal();
 
   return (
     <div className={moduleClass}>

+ 17 - 11
apps/app/src/features/openai/client/components/Common/SelectedPageList.tsx

@@ -1,8 +1,5 @@
-import type { FC } from 'react';
 import { memo } from 'react';
 
-import { useTranslation } from 'react-i18next';
-
 import type { SelectedPage } from '../../../interfaces/selected-page';
 
 type SelectedPageListProps = {
@@ -10,9 +7,7 @@ type SelectedPageListProps = {
   onRemove?: (pageId?: string) => void;
 };
 
-const SelectedPageListBase: FC<SelectedPageListProps> = ({ selectedPages, onRemove }) => {
-  const { t } = useTranslation();
-
+const SelectedPageListBase: React.FC<SelectedPageListProps> = ({ selectedPages, onRemove }: SelectedPageListProps) => {
   if (selectedPages.length === 0) {
     return <></>;
   }
@@ -20,12 +15,23 @@ const SelectedPageListBase: FC<SelectedPageListProps> = ({ selectedPages, onRemo
   return (
     <div className="mb-3">
       {selectedPages.map(({ page, isIncludeSubPage }) => (
-        <div key={page._id} className="mb-1 d-flex align-items-center">
-          <code>{ page.path }</code>
-          {isIncludeSubPage && <span className="badge rounded-pill text-bg-secondary ms-2">{t('Include Subordinated Page')}</span>}
+        <div
+          key={page._id}
+          className="mb-2 d-flex justify-content-between align-items-center bg-light rounded py-2 px-3"
+        >
+          <div className="d-flex align-items-center overflow-hidden">
+            { isIncludeSubPage
+              ? <>{`${page.path}/*`}</>
+              : <>{page.path}</>
+            }
+          </div>
           {onRemove != null && page._id != null && page._id && (
-            <button className="btn border-0 " type="button" onClick={() => onRemove(page._id)}>
-              <span className="fs-5 material-symbols-outlined text-secondary">delete</span>
+            <button
+              type="button"
+              className="btn p-0 ms-3 text-secondary"
+              onClick={() => onRemove(page._id)}
+            >
+              <span className="material-symbols-outlined fs-4">delete</span>
             </button>
           )}
         </div>

+ 21 - 10
apps/app/src/features/openai/client/stores/ai-assistant.tsx

@@ -3,27 +3,38 @@ import { useCallback } from 'react';
 import { useSWRStatic } from '@growi/core/dist/swr';
 import type { SWRResponse } from 'swr';
 
+export const AiAssistantManagementModalPageMode = {
+  HOME: 'home',
+  SHARE: 'share',
+  PAGES: 'pages',
+  INSTRUCTION: 'instruction',
+} as const;
 
-type AiAssistantManegementModalStatus = {
+type AiAssistantManagementModalPageMode = typeof AiAssistantManagementModalPageMode[keyof typeof AiAssistantManagementModalPageMode];
+
+type AiAssistantManagementModalStatus = {
   isOpened: boolean,
+  pageMode?: AiAssistantManagementModalPageMode,
 }
 
-type AiAssistantManegementModalUtils = {
+type AiAssistantManagementModalUtils = {
   open(): void
   close(): void
+  changePageMode(pageType: AiAssistantManagementModalPageMode): void
 }
 
-export const useAiAssistantManegementModal = (
-    status?: AiAssistantManegementModalStatus,
-): SWRResponse<AiAssistantManegementModalStatus, Error> & AiAssistantManegementModalUtils => {
-  const initialStatus = { isOpened: false };
-  const swrResponse = useSWRStatic<AiAssistantManegementModalStatus, Error>('AiAssistantManegementModal', status, { fallbackData: initialStatus });
+export const useAiAssistantManagementModal = (
+    status?: AiAssistantManagementModalStatus,
+): SWRResponse<AiAssistantManagementModalStatus, Error> & AiAssistantManagementModalUtils => {
+  const initialStatus = { isOpened: false, pageType: AiAssistantManagementModalPageMode.HOME };
+  const swrResponse = useSWRStatic<AiAssistantManagementModalStatus, Error>('AiAssistantManagementModal', status, { fallbackData: initialStatus });
 
   return {
     ...swrResponse,
-    open: useCallback(() => {
-      swrResponse.mutate({ isOpened: true });
-    }, [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]),
   };
 };

+ 7 - 1
apps/app/src/features/openai/interfaces/ai-assistant.ts

@@ -30,9 +30,15 @@ export interface AiAssistant {
   pagePathPatterns: string[],
   vectorStore: Ref<VectorStore>
   owner: Ref<IUser>
-  grantedGroups?: IGrantedGroup[]
+  grantedGroupsForShareScope?: IGrantedGroup[]
+  grantedGroupsForAccessScope?: IGrantedGroup[]
   shareScope: AiAssistantShareScope
   accessScope: AiAssistantAccessScope
 }
 
 export type IApiv3AiAssistantCreateParams = Omit<AiAssistant, 'owner' | 'vectorStore'>
+
+export type AccessibleAiAssistants = {
+  myAiAssistants: AiAssistant[],
+  teamAiAssistants: AiAssistant[],
+}

+ 23 - 1
apps/app/src/features/openai/server/models/ai-assistant.ts

@@ -43,7 +43,29 @@ const schema = new Schema<AiAssistantDocument>(
       ref: 'User',
       required: true,
     },
-    grantedGroups: {
+    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,

+ 21 - 8
apps/app/src/features/openai/server/routes/ai-assistant.ts

@@ -17,6 +17,7 @@ import { certifyAiService } from './middlewares/certify-ai-service';
 
 const logger = loggerFactory('growi:routes:apiv3:openai:create-ai-assistant');
 
+
 type CreateAssistantFactory = (crowi: Crowi) => RequestHandler[];
 
 type Req = Request<undefined, Response, IApiv3AiAssistantCreateParams> & {
@@ -25,7 +26,6 @@ type Req = Request<undefined, Response, IApiv3AiAssistantCreateParams> & {
 
 export const createAiAssistantFactory: CreateAssistantFactory = (crowi) => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
-  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
 
   const validator: ValidationChain[] = [
     body('name')
@@ -70,18 +70,31 @@ export const createAiAssistantFactory: CreateAssistantFactory = (crowi) => {
         return isCreatablePage(value);
       }),
 
-    body('grantedGroups')
+    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('Granted groups must be an array'),
+      .withMessage('grantedGroupsForAccessScope must be an array'),
 
-    body('grantedGroups.*.type') // each item of grantedGroups
+    body('grantedGroupsForAccessScope.*.type') // each item of grantedGroupsForAccessScope
       .isIn(Object.values(GroupType))
-      .withMessage('Invalid grantedGroups type value'),
+      .withMessage('Invalid grantedGroupsForAccessScope type value'),
 
-    body('grantedGroups.*.item') // each item of grantedGroups
+    body('grantedGroupsForAccessScope.*.item') // each item of grantedGroupsForAccessScope
       .isMongoId()
-      .withMessage('Invalid grantedGroups item value'),
+      .withMessage('Invalid grantedGroupsForAccessScope item value'),
 
     body('shareScope')
       .isIn(Object.values(AiAssistantShareScope))
@@ -93,7 +106,7 @@ export const createAiAssistantFactory: CreateAssistantFactory = (crowi) => {
   ];
 
   return [
-    accessTokenParser, loginRequiredStrictly, adminRequired, certifyAiService, validator, apiV3FormValidator,
+    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       try {
         const aiAssistantData = { ...req.body, owner: req.user._id };

+ 42 - 0
apps/app/src/features/openai/server/routes/ai-assistants.ts

@@ -0,0 +1,42 @@
+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<undefined, Response, undefined> & {
+  user: IUserHasId,
+}
+
+export const getAiAssistantsFactory: GetAiAssistantsFactory = (crowi) => {
+
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+
+  return [
+    accessTokenParser, loginRequiredStrictly, certifyAiService,
+    async(req: Req, res: ApiV3Response) => {
+      try {
+        const openaiService = getOpenaiService();
+        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'));
+      }
+    },
+  ];
+};

+ 4 - 0
apps/app/src/features/openai/server/routes/index.ts

@@ -34,6 +34,10 @@ export const factory = (crowi: Crowi): express.Router => {
     import('./ai-assistant').then(({ createAiAssistantFactory }) => {
       router.post('/ai-assistant', createAiAssistantFactory(crowi));
     });
+
+    import('./ai-assistants').then(({ getAiAssistantsFactory }) => {
+      router.get('/ai-assistants', getAiAssistantsFactory(crowi));
+    });
   }
 
   return router;

+ 112 - 27
apps/app/src/features/openai/server/services/openai.ts

@@ -2,13 +2,13 @@ import assert from 'node:assert';
 import { Readable, Transform } from 'stream';
 import { pipeline } from 'stream/promises';
 
-import { PageGrant, getIdForRef, isPopulated } from '@growi/core';
+import {
+  PageGrant, getIdForRef, isPopulated, type IUserHasId,
+} from '@growi/core';
 import { isGrobPatternPath } from '@growi/core/dist/utils/page-path-utils';
 import escapeStringRegexp from 'escape-string-regexp';
-import type { HydratedDocument, Types } from 'mongoose';
-import mongoose from 'mongoose';
-import type OpenAI from 'openai';
-import { toFile } from 'openai';
+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';
@@ -24,7 +24,9 @@ import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
 
 import { OpenaiServiceTypes } from '../../interfaces/ai';
-import { type AiAssistant, AiAssistantAccessScope } from '../../interfaces/ai-assistant';
+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';
 
@@ -66,6 +68,7 @@ export interface IOpenaiService {
   // rebuildVectorStoreAll(): Promise<void>;
   // rebuildVectorStore(page: HydratedDocument<PageDocument>): Promise<void>;
   createAiAssistant(data: Omit<AiAssistant, 'vectorStore'>): Promise<AiAssistantDocument>;
+  getAccessibleAiAssistants(user: IUserHasId): Promise<AccessibleAiAssistants>
 }
 class OpenaiService implements IOpenaiService {
 
@@ -425,10 +428,11 @@ class OpenaiService implements IOpenaiService {
   private async createConditionForCreateAiAssistant(
       owner: AiAssistant['owner'],
       accessScope: AiAssistant['accessScope'],
-      grantedGroups: AiAssistant['grantedGroups'],
+      grantedGroupsForAccessScope: AiAssistant['grantedGroupsForAccessScope'],
       pagePathPatterns: AiAssistant['pagePathPatterns'],
   ): Promise<mongoose.FilterQuery<PageDocument>> {
-    const converterdPagePatgPatterns = convertPathPatternsToRegExp(pagePathPatterns);
+
+    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));
@@ -443,37 +447,27 @@ class OpenaiService implements IOpenaiService {
           baseCondition,
           {
             grant: PageGrant.GRANT_PUBLIC,
-            path: { $in: converterdPagePatgPatterns },
+            path: { $in: converterdPagePathPatterns },
           },
         ],
       };
     }
 
     if (accessScope === AiAssistantAccessScope.GROUPS) {
-      if (grantedGroups == null || grantedGroups.length === 0) {
+      if (grantedGroupsForAccessScope == null || grantedGroupsForAccessScope.length === 0) {
         throw new Error('grantedGroups is required when accessScope is GROUPS');
       }
 
-      const extractedGrantedGroupIds = grantedGroups.map(group => getIdForRef(group.item).toString());
-      const extractedOwnerGroupIds = [
-        ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(owner)),
-        ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(owner)),
-      ].map(group => group.toString());
-
-      // Check if the owner belongs to the group specified in grantedGroups
-      const isValid = extractedGrantedGroupIds.every(groupId => extractedOwnerGroupIds.includes(groupId));
-      if (!isValid) {
-        throw new Error('A group to which the owner does not belong is specified.');
-      }
+      const extractedGrantedGroupIdsForAccessScope = grantedGroupsForAccessScope.map(group => getIdForRef(group.item).toString());
 
       return {
         $or: [
           baseCondition,
           {
             grant: { $in: [PageGrant.GRANT_PUBLIC, PageGrant.GRANT_USER_GROUP] },
-            path: { $in: converterdPagePatgPatterns },
+            path: { $in: converterdPagePathPatterns },
             $or: [
-              { 'grantedGroups.item': { $in: extractedGrantedGroupIds } },
+              { 'grantedGroups.item': { $in: extractedGrantedGroupIdsForAccessScope } },
               { grant: PageGrant.GRANT_PUBLIC },
             ],
           },
@@ -482,7 +476,7 @@ class OpenaiService implements IOpenaiService {
     }
 
     if (accessScope === AiAssistantAccessScope.OWNER) {
-      const ownerUserGroup = [
+      const ownerUserGroups = [
         ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(owner)),
         ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(owner)),
       ].map(group => group.toString());
@@ -492,9 +486,9 @@ class OpenaiService implements IOpenaiService {
           baseCondition,
           {
             grant: { $in: [PageGrant.GRANT_PUBLIC, PageGrant.GRANT_USER_GROUP, PageGrant.GRANT_OWNER] },
-            path: { $in: converterdPagePatgPatterns },
+            path: { $in: converterdPagePathPatterns },
             $or: [
-              { 'grantedGroups.item': { $in: ownerUserGroup } },
+              { 'grantedGroups.item': { $in: ownerUserGroups } },
               { grantedUsers: { $in: [getIdForRef(owner)] } },
               { grant: PageGrant.GRANT_PUBLIC },
             ],
@@ -506,8 +500,63 @@ class OpenaiService implements IOpenaiService {
     throw new Error('Invalid accessScope value');
   }
 
+  private async validateGrantedUserGroupsForCreateAiAssistant(
+      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<AiAssistant, 'vectorStore'>): Promise<AiAssistantDocument> {
-    const conditions = await this.createConditionForCreateAiAssistant(data.owner, data.accessScope, data.grantedGroups, data.pagePathPatterns);
+    await this.validateGrantedUserGroupsForCreateAiAssistant(
+      data.owner,
+      data.shareScope,
+      data.accessScope,
+      data.grantedGroupsForShareScope,
+      data.grantedGroupsForAccessScope,
+    );
+
+    const conditions = await this.createConditionForCreateAiAssistant(
+      data.owner,
+      data.accessScope,
+      data.grantedGroupsForAccessScope,
+      data.pagePathPatterns,
+    );
 
     const vectorStoreRelation = await this.createVectorStore(data.name);
     const aiAssistant = await AiAssistantModel.create({
@@ -520,6 +569,42 @@ class OpenaiService implements IOpenaiService {
     return aiAssistant;
   }
 
+  async getAccessibleAiAssistants(user: IUserHasId): Promise<AccessibleAiAssistants> {
+    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()) ?? [],
+    };
+  }
+
 }
 
 let instance: OpenaiService;

+ 3 - 0
apps/app/src/server/routes/apiv3/index.js

@@ -11,6 +11,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
@@ -122,5 +123,7 @@ module.exports = (crowi, app) => {
 
   router.use('/openai', openaiRouteFactory(crowi));
 
+  router.use('/user', userRouteFactory(crowi));
+
   return [router, routerForAdmin, routerForAuth];
 };

+ 35 - 0
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'));
+      }
+    },
+  ];
+};

+ 13 - 0
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;
+};

+ 12 - 0
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<IUserHasId[], Error> => {
@@ -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<RelatedGroupsResponse, Error> => {
+  return useSWRImmutable<RelatedGroupsResponse>(
+    ['/user/related-groups'],
+    ([endpoint]) => apiv3Get(endpoint).then(response => response.data),
+  );
+};