Просмотр исходного кода

Merge branch 'master' into feat/page-bulk-export-pdf-included

Futa Arai 1 год назад
Родитель
Сommit
b29fcd9222
100 измененных файлов с 5162 добавлено и 1771 удалено
  1. 1 1
      .github/workflows/auto-approve.yml
  2. 1 1
      .github/workflows/auto-labeling.yml
  3. 1 1
      .github/workflows/draft-release.yml
  4. 1 1
      .github/workflows/release-slackbot-proxy.yml
  5. 38 1
      CHANGELOG.md
  6. 2 0
      apps/app/bin/swagger-jsdoc/definition-apiv3.js
  7. 1 0
      apps/app/bin/swagger-jsdoc/generate-spec-apiv3.sh
  8. 1 1
      apps/app/package.json
  9. 1 7
      apps/app/public/static/locales/en_US/admin.json
  10. 93 4
      apps/app/public/static/locales/en_US/translation.json
  11. 1 7
      apps/app/public/static/locales/fr_FR/admin.json
  12. 93 3
      apps/app/public/static/locales/fr_FR/translation.json
  13. 1 7
      apps/app/public/static/locales/ja_JP/admin.json
  14. 93 3
      apps/app/public/static/locales/ja_JP/translation.json
  15. 1 7
      apps/app/public/static/locales/zh_CN/admin.json
  16. 93 3
      apps/app/public/static/locales/zh_CN/translation.json
  17. 1 1
      apps/app/src/client/components/Bookmarks/BookmarkFolderMenuItem.tsx
  18. 2 2
      apps/app/src/client/components/PageControls/PageControls.tsx
  19. 19 6
      apps/app/src/client/components/PageHeader/PagePathHeader.tsx
  20. 52 26
      apps/app/src/client/components/PageSelectModal/PageSelectModal.tsx
  21. 3 0
      apps/app/src/client/components/Sidebar/SidebarContents.tsx
  22. 6 2
      apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItem.tsx
  23. 12 0
      apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItems.tsx
  24. 18 15
      apps/app/src/components/Admin/Common/AdminNavigation.tsx
  25. 15 2
      apps/app/src/components/Layout/BasicLayout.tsx
  26. 498 0
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  27. 2 1
      apps/app/src/features/growi-plugin/client/components/GrowiPluginsActivator.tsx
  28. 38 7
      apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.ts
  29. 0 329
      apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.tsx
  30. 0 1
      apps/app/src/features/openai/chat/components/AiChatModal/index.ts
  31. 8 4
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.module.scss
  32. 455 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.tsx
  33. 0 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/MessageCard.module.scss
  34. 5 5
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/MessageCard.tsx
  35. 0 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/ResizableTextArea.tsx
  36. 66 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AccessScopeDropdown.tsx
  37. 40 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditInstruction.tsx
  38. 58 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditPages.tsx
  39. 149 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditShare.tsx
  40. 26 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHeader.tsx
  41. 221 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHome.tsx
  42. 15 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.module.scss
  43. 293 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx
  44. 74 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectUserGroupModal.tsx
  45. 43 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectedPageList.tsx
  46. 52 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeSwitch.tsx
  47. 81 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeWarningModal.tsx
  48. 2 2
      apps/app/src/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton.module.scss
  49. 55 0
      apps/app/src/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton.tsx
  50. 37 0
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistant.tsx
  51. 5 0
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.module.scss
  52. 57 0
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.tsx
  53. 45 0
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.module.scss
  54. 319 0
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx
  55. 0 31
      apps/app/src/features/openai/client/components/AiIntegration/AiIntegration.tsx
  56. 0 36
      apps/app/src/features/openai/client/components/RagSearchButton.tsx
  57. 19 0
      apps/app/src/features/openai/client/services/ai-assistant.ts
  58. 7 0
      apps/app/src/features/openai/client/services/thread.ts
  59. 87 0
      apps/app/src/features/openai/client/stores/ai-assistant.tsx
  60. 13 0
      apps/app/src/features/openai/client/stores/message.tsx
  61. 0 26
      apps/app/src/features/openai/client/stores/rag-search.ts
  62. 26 0
      apps/app/src/features/openai/client/stores/thread.tsx
  63. 55 0
      apps/app/src/features/openai/interfaces/ai-assistant.ts
  64. 13 0
      apps/app/src/features/openai/interfaces/message.ts
  65. 6 0
      apps/app/src/features/openai/interfaces/selected-page.ts
  66. 18 0
      apps/app/src/features/openai/interfaces/thread-relation.ts
  67. 4 0
      apps/app/src/features/openai/interfaces/vector-store.ts
  68. 145 0
      apps/app/src/features/openai/server/models/ai-assistant.ts
  69. 11 8
      apps/app/src/features/openai/server/models/thread-relation.ts
  70. 3 19
      apps/app/src/features/openai/server/models/vector-store.ts
  71. 56 0
      apps/app/src/features/openai/server/routes/ai-assistant.ts
  72. 46 0
      apps/app/src/features/openai/server/routes/ai-assistants.ts
  73. 59 0
      apps/app/src/features/openai/server/routes/delete-ai-assistant.ts
  74. 68 0
      apps/app/src/features/openai/server/routes/delete-thread.ts
  75. 73 0
      apps/app/src/features/openai/server/routes/get-messages.ts
  76. 61 0
      apps/app/src/features/openai/server/routes/get-threads.ts
  77. 32 4
      apps/app/src/features/openai/server/routes/index.ts
  78. 33 3
      apps/app/src/features/openai/server/routes/message.ts
  79. 86 0
      apps/app/src/features/openai/server/routes/middlewares/upsert-ai-assistant-validator.ts
  80. 0 43
      apps/app/src/features/openai/server/routes/rebuild-vector-store.ts
  81. 66 0
      apps/app/src/features/openai/server/routes/set-default-ai-assistant.ts
  82. 25 8
      apps/app/src/features/openai/server/routes/thread.ts
  83. 73 0
      apps/app/src/features/openai/server/routes/update-ai-assistant.ts
  84. 27 3
      apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts
  85. 5 2
      apps/app/src/features/openai/server/services/client-delegator/interfaces.ts
  86. 27 4
      apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts
  87. 1 1
      apps/app/src/features/openai/server/services/cron/vector-store-file-deletion-cron.ts
  88. 51 0
      apps/app/src/features/openai/server/services/delete-ai-assistant.ts
  89. 0 573
      apps/app/src/features/openai/server/services/markdown-splitter/markdown-splitter.spec.ts
  90. 0 133
      apps/app/src/features/openai/server/services/markdown-splitter/markdown-splitter.ts
  91. 0 134
      apps/app/src/features/openai/server/services/markdown-splitter/markdown-token-splitter.spec.ts
  92. 0 188
      apps/app/src/features/openai/server/services/markdown-splitter/markdown-token-splitter.ts
  93. 3 0
      apps/app/src/features/openai/server/services/normalize-data/normalize-thread-relation-expired-at/normalize-thread-relation-expired-at.integ.ts
  94. 1 1
      apps/app/src/features/openai/server/services/openai-api-error-handler.ts
  95. 673 99
      apps/app/src/features/openai/server/services/openai.ts
  96. 5 5
      apps/app/src/features/openai/server/services/replace-annotation-with-page-link.ts
  97. 48 0
      apps/app/src/features/openai/server/utils/generate-glob-patterns.spec.ts
  98. 28 0
      apps/app/src/features/openai/server/utils/generate-glob-patterns.ts
  99. 6 0
      apps/app/src/features/openai/utils/determine-share-scope.ts
  100. 8 0
      apps/app/src/features/openai/utils/remove-glob-path.ts

+ 1 - 1
.github/workflows/auto-approve.yml

@@ -16,7 +16,7 @@ jobs:
     steps:
       - name: Dependabot metadata
         id: dependabot-metadata
-        uses: dependabot/fetch-metadata@v1
+        uses: dependabot/fetch-metadata@v2
         with:
           github-token: '${{ secrets.GITHUB_TOKEN }}'
       - name: Approve a PR

+ 1 - 1
.github/workflows/auto-labeling.yml

@@ -25,7 +25,7 @@ jobs:
         && !startsWith( github.head_ref, 'mergify/merge-queue/' ))
 
     steps:
-      - uses: release-drafter/release-drafter@v5
+      - uses: release-drafter/release-drafter@v6
         with:
           disable-releaser: true
         env:

+ 1 - 1
.github/workflows/draft-release.yml

@@ -29,7 +29,7 @@ jobs:
         uses: myrotvorets/info-from-package-json-action@v2.0.2
         id: package-json
 
-      - uses: release-drafter/release-drafter@v5
+      - uses: release-drafter/release-drafter@v6
         id: release-drafter
         with:
           config-name: release-drafter.yml

+ 1 - 1
.github/workflows/release-slackbot-proxy.yml

@@ -51,7 +51,7 @@ jobs:
       uses: docker/setup-buildx-action@v3
 
     - name: Build and push
-      uses: docker/build-push-action@v4
+      uses: docker/build-push-action@v6
       with:
         context: .
         file: ./apps/slackbot-proxy/docker/Dockerfile

+ 38 - 1
CHANGELOG.md

@@ -1,9 +1,46 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.1.9...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.2.0...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.2.0](https://github.com/weseek/growi/compare/v7.1.9...v7.2.0) - 2025-03-11
+
+### 💎 Features
+
+* feat: GROWI AI Next Gen (#9492) @miya
+* feat: Support OpenTelemetry (#8810) @yuki-takei
+* feat: Add AuthorInfo display setting to PageSideContents (#9689) @satof3
+* feat: Expose React Insance to window via GrowiFacade (#9729) @NaokiHigashi28
+* feat: Normalize remark growi directives for v6.0.x or above (#9690) @yuki-takei
+
+### 🚀 Improvement
+
+* imprv: Fix RecentChanges dropdown label (#9711) @satof3
+* imprv: Border color for dark mode (#9695) @satof3
+* imprv: Update shortcut key modal (#9651) @satof3
+* imprv: Suppresses unnecessary re-rendering within PageEditor (#9629) @reiji-h
+
+### 🐛 Bug Fixes
+
+* fix: Redirection after login does not work on systems with guest mode enabled (#9653) @reiji-h
+* fix: Data migration script for CSV and TSV (#9641) @miya
+* fix: Authenticate before uploading at /_api/v3/import/upload endpoint (#9647) @NaokiHigashi28
+* fix: Add XSS filter to remark-attachment-refs /refs endpoint (#9631) @NaokiHigashi28
+* fix: PageTree auto-scrolling sometimges not woking (#9544) @reiji-h
+* fix: Middlewares about installation (#9616) @yuki-takei
+* fix: Typo for bookmark API (#9613) @yuki-takei
+
+### 🧰 Maintenance
+
+* support: Upgrade runtime versions (#9655) @yuki-takei
+* support: Display brand logo when editor mode (#9632) @satof3
+* support: Upgrade CodeMirror (#9633) @yuki-takei
+* ci(deps): bump docker/login-action from 2 to 3 (#8208) @dependabot
+* ci(deps): bump google-github-actions/auth from 1 to 2 (#9557) @dependabot
+* ci(deps): bump myrotvorets/info-from-package-json-action from 2.0.1 to 2.0.2 (#9558) @dependabot
+* support: Remove legacy ConfigManager (#9624) @yuki-takei
+
 ## [v7.1.9](https://github.com/weseek/growi/compare/v7.1.8...v7.1.9) - 2025-02-03
 
 ### 💎 Features

+ 2 - 0
apps/app/bin/swagger-jsdoc/definition-apiv3.js

@@ -65,6 +65,7 @@ module.exports = {
         'Home',
         'AdminHome',
         'AppSettings',
+        'ExternalUserGroups',
         'SecuritySetting',
         'MarkDownSetting',
         'CustomizeSetting',
@@ -72,6 +73,7 @@ module.exports = {
         'Export',
         'MongoDB',
         'NotificationSetting',
+        'QuestionnaireSetting',
         'SlackIntegrationSettings',
         'SlackIntegrationSettings (with proxy)',
         'SlackIntegrationSettings (without proxy)',

+ 1 - 0
apps/app/bin/swagger-jsdoc/generate-spec-apiv3.sh

@@ -10,5 +10,6 @@ OUT=${OUT:-"${APP_PATH}/tmp/openapi-spec-apiv3.json"}
 swagger-jsdoc \
   -o "${OUT}" \
   -d "${APP_PATH}/bin/swagger-jsdoc/definition-apiv3.js" \
+  "${APP_PATH}/src/features/external-user-group/server/routes/apiv3/*.ts" \
   "${APP_PATH}/src/server/routes/apiv3/**/*.{js,ts}" \
   "${APP_PATH}/src/server/models/openapi/**/*.{js,ts}"

+ 1 - 1
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.2.0-RC.0",
+  "version": "7.2.1-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

+ 1 - 7
apps/app/public/static/locales/en_US/admin.json

@@ -1148,12 +1148,6 @@
   "ai_integration": {
     "ai_integration": "AI Integration",
     "disable_mode_explanation": "Currently, AI integration is disabled. To enable it, configure the <code>AI_ENABLED</code> environment variable along with the required additional variables.<br><br>For details, please refer to the <a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>documentation</a>.",
-    "ai_search_management": "AI search management",
-    "rebuild_vector_store": "Rebuild Vector Store",
-    "rebuild_vector_store_label": "Rebuild",
-    "rebuild_vector_store_explanation1": "Delete the existing Vector Store and recreate the Vector Store on the public page.",
-    "rebuild_vector_store_explanation2": "This process may take several minutes.",
-    "rebuild_vector_store_requested": "Vector Store rebuild has been requested",
-    "rebuild_vector_store_failed": "Vector Store rebuild failed"
+    "ai_search_management": "AI search management"
   }
 }

+ 93 - 4
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 (Beta)",
   "original_path": "Original path",
   "new_path": "New path",
   "duplicated_path": "Duplicated path",
@@ -491,9 +493,9 @@
     "latest_revision": "theirs",
     "selected_editable_revision": "Selected Page Body (Editable)"
   },
-  "modal_aichat": {
-    "title": "Knowledge Assistant",
-    "title_beta_label": "(Beta)",
+  "sidebar_aichat": {
+    "instruction_label": "Assistant instructions",
+    "reference_pages_label": "Reference pages",
     "placeholder": "Ask me anything.",
     "summary_mode_label": "Summary mode",
     "summary_mode_help": "Concise answer within 2-3 sentences",
@@ -504,7 +506,94 @@
     "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": {
+    "header": {
+      "update_assistant": "Update Assistant",
+      "add_new_assistant": "Add New Assistant"
+    },
+    "assistant_name_placeholder": "Enter assistant name",
+    "page_count": "{{count}} pages",
+    "memo": {
+      "title": "Assistant memo",
+      "optional": "Optional",
+      "placeholder": "You can display notes about content and usage",
+      "description": "The contents of the memo do not affect the assistant's processing."
+    },
+    "submit_button": {
+      "update_assistant": "Update Assistant",
+      "create_assistant": "Create Assistant"
+    },
+    "toaster": {
+      "create_success": "Assistant has been created",
+      "update_success": "Assistant has been updated",
+      "create_failed": "Failed to create assistant",
+      "update_failed": "Failed to update assistant"
+    },
+    "edit_page_description": "Edit pages that the assistant can reference.<br> The assistant can reference up to {{limitLearnablePageCountPerAssistant}} pages including child pages.",
+    "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.",
+    "add_page_button": "Add page",
+    "page_mode_title": {
+      "share": "Assistant Sharing",
+      "pages": "Reference Pages",
+      "instruction": "Assistant Instructions"
+    },
+    "share_assistant": "Share assistant",
+    "page_access_permission": "Page access permission",
+    "access_scope": {
+      "owner": "All pages accessible by {{username}}",
+      "groups": "Specify groups",
+      "publicOnly": "Public pages only"
+    },
+    "share_scope": {
+      "title": "Assistant sharing 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"
+      }
+    },
+    "instructions": {
+      "description": "You can set instructions that determine how the assistant behaves.<br>The assistant will answer and analyze based on these instructions.",
+      "reset_to_default": "Reset to default"
+    }
+  },
+  "share_scope_warning_modal": {
+    "header_title": "Confirm Sharing Scope",
+    "warning_message": "This assistant includes pages with limited access.<br>With the current settings, information from these pages may be shared beyond their original access permissions through the assistant.",
+    "selected_pages_label": "Selected page paths",
+    "confirmation_message": "Please confirm that you understand the content of these pages may be shared within the assistant's public scope if you proceed.",
+    "button": {
+      "review": "Review settings",
+      "proceed": "Understand and proceed"
+    }
+  },
+  "default_ai_assistant": {
+    "not_set": "Default assistant is not set"
+  },
+  "ai_assistant_tree": {
+    "add_assistant": "Add Assistant",
+    "my_assistants": "My Assistants",
+    "team_assistants": "Team Assistants",
+    "thread_does_not_exist": "No threads exist",
+    "toaster": {
+      "ai_assistant_deleted_success": "Assistant deleted",
+      "ai_assistant_deleted_failed": "Failed to delete assistant",
+      "thread_deleted_success": "Thread deleted",
+      "thread_deleted_failed": "Failed to delete thread",
+      "ai_assistant_set_default_success": "Default assistant set successfully",
+      "ai_assistant_set_default_failed": "Failed to set default assistant"
+    }
   },
   "link_edit": {
     "edit_link": "Edit Link",

+ 1 - 7
apps/app/public/static/locales/fr_FR/admin.json

@@ -1147,12 +1147,6 @@
   "ai_integration": {
     "ai_integration": "Intégration de l'IA",
     "disable_mode_explanation": "Actuellement, l'intégration AI est désactivée. Pour l'activer, configurez la variable d'environnement <code>AI_ENABLED</code> ainsi que les autres variables nécessaires.<br><br>Pour plus de détails, veuillez consulter la <a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>documentation</a>.",
-    "ai_search_management": "Gestion de la recherche par l'IA",
-    "rebuild_vector_store": "Reconstruire le magasin Vector",
-    "rebuild_vector_store_label": "Reconstruire",
-    "rebuild_vector_store_explanation1": "Supprimez le Vector Store existant et recréez le Vector Store sur la page publique.",
-    "rebuild_vector_store_explanation2": "Ce processus peut prendre plusieurs minutes.",
-    "rebuild_vector_store_requested": "La reconstruction du magasin Vector a été demandée",
-    "rebuild_vector_store_failed": "Échec de la reconstruction du magasin de vecteurs"
+    "ai_search_management": "Gestion de la recherche par l'IA"
   }
 }

+ 93 - 3
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 Connaissances (Bêta)",
   "original_path": "Chemin originel",
   "new_path": "Nouveau chemin",
   "duplicated_path": "Chemin dupliqué",
@@ -486,9 +488,9 @@
     "latest_revision": "les autres",
     "selected_editable_revision": "Corps de page sélectionné (Modifiable)"
   },
-  "modal_aichat": {
-    "title": "Assistant de Connaissance",
-    "title_beta_label": "(Bêta)",
+  "sidebar_aichat": {
+    "instruction_label": "Instructions pour l'assistant",
+    "reference_pages_label": "Pages de référence",
     "placeholder": "Demandez-moi n'importe quoi.",
     "summary_mode_label": "Mode résumé",
     "summary_mode_help": "Réponse concise en 2-3 phrases",
@@ -500,6 +502,94 @@
     "error_message": "Erreur",
     "show_error_detail": "Détails de l'exposition"
   },
+  "modal_ai_assistant": {
+    "header": {
+      "update_assistant": "Mettre à jour l'assistant",
+      "add_new_assistant": "Ajouter un nouvel assistant"
+    },
+    "assistant_name_placeholder": "Entrer le nom de l'assistant",
+    "page_count": "{{count}} pages",
+    "memo": {
+      "title": "Note sur l'assistant",
+      "optional": "Optionnel",
+      "placeholder": "Vous pouvez afficher des notes sur le contenu et l'utilisation",
+      "description": "Le contenu de la note n'affecte pas le traitement de l'assistant."
+    },
+    "submit_button": {
+      "update_assistant": "Mettre à jour l'assistant",
+      "create_assistant": "Créer l'assistant"
+    },
+    "toaster": {
+      "create_success": "L'assistant a été créé",
+      "update_success": "L'assistant a été mis à jour",
+      "create_failed": "Échec de la création de l'assistant",
+      "update_failed": "Échec de la mise à jour de l'assistant"
+    },
+    "edit_page_description": "Modifier les pages que l'assistant peut référencer.<br> L'assistant peut référencer jusqu'à {{limitLearnablePageCountPerAssistant}} pages, y compris les pages enfants.",
+    "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.",
+    "add_page_button": "Ajouter une page",
+    "page_mode_title": {
+      "share": "Partage de l'assistant",
+      "pages": "Pages de référence",
+      "instruction": "Instructions de l'assistant"
+    },
+    "share_assistant": "Partager l'assistant",
+    "page_access_permission": "Autorisation d'accès à la page",
+    "access_scope": {
+      "owner": "Toutes les pages accessibles par {{username}}",
+      "groups": "Spécifier les groupes",
+      "publicOnly": "Pages publiques uniquement"
+    },
+    "share_scope": {
+      "title": "Portée de partage de l'assistant",
+      "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"
+      }
+    },
+    "instructions": {
+      "description": "Vous pouvez définir des instructions qui déterminent le comportement de l'assistant.<br>L'assistant répondra et analysera en fonction de ces instructions.",
+      "reset_to_default": "Réinitialiser par défaut"
+    }
+  },
+  "share_scope_warning_modal": {
+    "header_title": "Confirmation de la portée de partage",
+    "warning_message": "Cet assistant comprend des pages à accès limité.<br>Avec les paramètres actuels, les informations de ces pages peuvent être partagées au-delà de leurs autorisations d'accès d'origine via l'assistant.",
+    "selected_pages_label": "Chemins de pages sélectionnés",
+    "confirmation_message": "Veuillez confirmer que vous comprenez que le contenu de ces pages peut être partagé dans la portée publique de l'assistant si vous continuez.",
+    "button": {
+      "review": "Réviser les paramètres",
+      "proceed": "Comprendre et continuer"
+    }
+  },
+  "default_ai_assistant": {
+    "not_set": "L'assistant par défaut n'est pas configuré"
+  },
+ "ai_assistant_tree": {
+    "add_assistant": "Ajouter un assistant",
+    "my_assistants": "Mes assistants",
+    "team_assistants": "Assistants d'équipe",
+    "thread_does_not_exist": "Aucune discussion",
+    "toaster": {
+      "ai_assistant_deleted_success": "Assistant supprimé",
+      "ai_assistant_deleted_failed": "Échec de la suppression de l'assistant",
+      "thread_deleted_success": "Discussion supprimée",
+      "thread_deleted_failed": "Échec de la suppression de la discussion",
+      "ai_assistant_set_default_success": "Assistant par défaut défini avec succès",
+      "ai_assistant_set_default_failed": "Échec de la définition de l'assistant par défaut"
+    }
+  },
   "link_edit": {
     "edit_link": "Modifier lien",
     "set_link_and_label": "Ajouter lien et étiquette",

+ 1 - 7
apps/app/public/static/locales/ja_JP/admin.json

@@ -1157,12 +1157,6 @@
   "ai_integration": {
     "ai_integration": "AI 連携",
     "disable_mode_explanation": "現在、AI 連携は無効になっています。有効にする場合は環境変数 <code>AI_ENABLED</code> の他、必要な環境変数を設定してください。<br><br>詳細は<a target='blank' rel='noopener noreferrer' href={{documentationUrl}}ja/guide/features/ai-knowledge-assistant.html>ドキュメント</a>を参照してください。",
-    "ai_search_management": "AI 検索管理",
-    "rebuild_vector_store": "Vector Store のリビルド",
-    "rebuild_vector_store_label": "リビルド",
-    "rebuild_vector_store_explanation1": "既存の Vector Store を削除し、公開ページの Vector Store を再作成します。",
-    "rebuild_vector_store_explanation2": "この作業には数分かかる可能性があります。",
-    "rebuild_vector_store_requested": "Vector Store のリビルドを受け付けました",
-    "rebuild_vector_store_failed": "Vector Store のリビルドに失敗しました"
+    "ai_search_management": "AI 検索管理"
   }
 }

+ 93 - 3
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": "重複したパス",
@@ -524,9 +526,9 @@
     "latest_revision": "最新の本文",
     "selected_editable_revision": "保存するページ本文(編集可能)"
   },
-  "modal_aichat": {
-    "title": "ナレッジアシスタント",
-    "title_beta_label": "(ベータ)",
+  "sidebar_aichat": {
+    "instruction_label": "アシスタントへの指示",
+    "reference_pages_label": "参照するページ",
     "placeholder": "ききたいことを入力してください",
     "summary_mode_label": "要約モード",
     "summary_mode_help": "2~3文以内の簡潔な回答",
@@ -538,6 +540,94 @@
     "error_message": "エラーが発生しました",
     "show_error_detail": "詳細を表示"
   },
+  "modal_ai_assistant": {
+    "header": {
+      "update_assistant": "アシスタントの更新",
+      "add_new_assistant": "新規アシスタントの追加"
+    },
+    "assistant_name_placeholder": "アシスタント名を入力",
+    "page_count": "{{count}} ページ",
+    "memo": {
+      "title": "アシスタントのメモ",
+      "optional": "任意",
+      "placeholder": "内容や用途のメモを表示させることができます",
+      "description": "メモの内容はアシスタントの処理に影響しません。"
+    },
+    "submit_button": {
+      "update_assistant": "アシスタントを更新する",
+      "create_assistant": "アシスタントを作成する"
+    },
+    "toaster": {
+      "create_success": "アシスタントが作成されました",
+      "update_success": "アシスタントが更新されました",
+      "create_failed": "アシスタントの作成に失敗しました",
+      "update_failed": "アシスタントの更新に失敗しました"
+    },
+    "default_instruction": "あなたはこのWikiの知識アシスタントです。以下の方針で支援を行ってください:\n\n- 文書の関連性分析と情報の関連付け\n- 新しい視点の提案\n- 質問の意図を理解した的確な情報提供 必要に応じて構造化された形式で情報を提供します。",
+    "edit_page_description": " アシスタントが参照するページを編集します。<br> 参照できるページは配下ページも含めて {{limitLearnablePageCountPerAssistant}} ページまでです。",
+    "add_page_button": "ページを追加する",
+    "page_mode_title": {
+      "share": "アシスタントの共有",
+      "pages": "参照ページ",
+      "instruction": "アシスタントへの指示"
+    },
+    "share_assistant": "アシスタントを共有する",
+    "page_access_permission": "ページのアクセス権限",
+    "access_scope": {
+      "owner": "{{username}} がアクセス可能な全てのページ",
+      "groups": "グループを指定",
+      "publicOnly": "公開ページのみ"
+    },
+    "share_scope": {
+      "title": "アシスタントの共有範囲",
+      "owner": {
+        "label": "{{username}} のみ"
+      },
+      "publicOnly": {
+        "label": "全体公開",
+        "desc": "すべてのユーザーに共有されます"
+      },
+      "groups": {
+        "label": "グループを指定",
+        "desc": "選択したグループのメンバーにのみ共有されます"
+      },
+      "sameAsAccessScope": {
+        "label": "ページのアクセス権限と同じ範囲",
+        "desc": "ページのアクセス権限と同じ範囲で共有されます"
+      }
+    },
+    "instructions": {
+      "description": "アシスタントの振る舞いを決める指示文を設定できます。<br>この指示に従ってにアシスタントの回答や分析を行います。",
+      "reset_to_default": "デフォルトに戻す"
+    }
+  },
+  "share_scope_warning_modal": {
+    "header_title": "共有範囲の確認",
+    "warning_message": "このアシスタントには限定公開されているページが含まれています。<br />現在の設定では、アシスタントを通じてこれらのページの情報が、本来のアクセス権限を超えて共有される可能性があります。",
+    "selected_pages_label": "選択されているページパス",
+    "confirmation_message": "続行する場合、これらのページの内容がアシスタントの公開範囲内で共有される可能性があることを確認してください。",
+    "button": {
+      "review": "設定を見直す",
+      "proceed": "理解して続行する"
+    }
+  },
+  "default_ai_assistant": {
+    "not_set": "デフォルトアシスタントが設定されていません"
+  },
+  "ai_assistant_tree": {
+    "add_assistant": "アシスタントを追加する",
+    "my_assistants": "マイアシスタント",
+    "team_assistants": "チームアシスタント",
+    "thread_does_not_exist": "スレッドが存在しません",
+    "toaster": {
+      "ai_assistant_deleted_success": "アシスタントを削除しました",
+      "ai_assistant_deleted_failed": "アシスタントの削除に失敗しました",
+      "thread_deleted_success": "スレッドを削除しました",
+      "thread_deleted_failed": "スレッドの削除に失敗しました",
+      "ai_assistant_set_default_success": "デフォルトアシスタントを設定しました",
+      "ai_assistant_set_default_failed": "デフォルトアシスタントの設定に失敗しました"
+    }
+  },
   "link_edit": {
     "edit_link": "リンク編集",
     "set_link_and_label": "リンク情報",

+ 1 - 7
apps/app/public/static/locales/zh_CN/admin.json

@@ -1157,12 +1157,6 @@
   "ai_integration": {
     "ai_integration": "AI 集成",
     "disable_mode_explanation": "目前,AI 集成已被禁用。若要启用,请配置 <code>AI_ENABLED</code> 环境变量以及其他必要的变量。<br><br>详细信息请参考<a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>文档</a>。",
-    "ai_search_management": "AI 搜索管理",
-    "rebuild_vector_store": "重建矢量商店",
-    "rebuild_vector_store_label": "重建",
-    "rebuild_vector_store_explanation1": "删除现有的矢量存储,在公共页面上重新创建矢量存储。",
-    "rebuild_vector_store_explanation2": "这个过程可能需要几分钟。",
-    "rebuild_vector_store_requested": "已要求重建矢量存储库",
-    "rebuild_vector_store_failed": "向量存储区重建失败"
+    "ai_search_management": "AI 搜索管理"
   }
 }

+ 93 - 3
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",
@@ -481,9 +483,9 @@
     "latest_revision": "最新页面正文",
     "selected_editable_revision": "选定的可编辑页面正文"
   },
-  "modal_aichat": {
-    "title": "知识助手",
-    "title_beta_label": "(测试版)",
+  "sidebar_aichat": {
+    "instruction_label": "助手指令",
+    "reference_pages_label": "参考页面",
     "placeholder": "问我任何问题。",
     "summary_mode_label": "摘要模式",
     "summary_mode_help": "简洁回答在2-3句话内",
@@ -495,6 +497,94 @@
     "error_message": "错误",
     "show_error_detail": "显示详情"
   },
+  "modal_ai_assistant": {
+    "header": {
+      "update_assistant": "更新助手",
+      "add_new_assistant": "添加新助手"
+    },
+    "assistant_name_placeholder": "输入助手名称",
+    "page_count": "{{count}} 页",
+    "memo": {
+      "title": "助手备忘录",
+      "optional": "可选",
+      "placeholder": "您可以显示关于内容和用途的备注",
+      "description": "备忘录的内容不会影响助手的处理。"
+    },
+    "submit_button": {
+      "update_assistant": "更新助手",
+      "create_assistant": "创建助手"
+    },
+    "toaster": {
+      "create_success": "助手已创建",
+      "update_success": "助手已更新",
+      "create_failed": "创建助手失败",
+      "update_failed": "更新助手失败"
+    },
+    "edit_page_description": "编辑助手可以参考的页面。<br> 助手可以参考最多 {{limitLearnablePageCountPerAssistant}} 个页面,包括子页面。",
+    "default_instruction": "您是这个Wiki的知识助手。请按照以下方针提供支持:\n\n- 分析文档相关性并连接信息\n- 提出新的观点\n- 理解问题意图并提供准确信息\n必要时我会以结构化的形式提供信息。",
+    "add_page_button": "添加页面",
+    "page_mode_title": {
+      "share": "助理共享",
+      "pages": "参考页面",
+      "instruction": "助理指示"
+    },
+    "share_assistant": "共享助手",
+    "page_access_permission": "页面访问权限",
+    "access_scope": {
+      "owner": "{{username}} 可访问的所有页面",
+      "groups": "指定群组",
+      "publicOnly": "仅公开页面"
+    },
+    "share_scope": {
+      "title": "助手共享范围",
+      "owner": {
+        "label": "仅 {{ username }}"
+      },
+      "publicOnly": {
+        "label": "公开",
+        "desc": "与所有用户共享"
+      },
+      "groups": {
+        "label": "指定群组",
+        "desc": "仅与选定组的成员共享"
+      },
+      "sameAsAccessScope": {
+        "label": "与页面访问范围相同",
+        "desc": "与页面访问范围相同的范围共享"
+      }
+    },
+    "instructions": {
+      "description": "您可以设置决定助手行为的指令。<br>助手将根据这些指令进行回答和分析。",
+      "reset_to_default": "恢复默认设置"
+    }
+  },
+  "share_scope_warning_modal": {
+    "header_title": "确认共享范围",
+    "warning_message": "此助手包含访问受限的页面。<br>使用当前设置,这些页面的信息可能通过助手超出其原始访问权限范围进行共享。",
+    "selected_pages_label": "已选择的页面路径",
+    "confirmation_message": "如果继续,请确认您了解这些页面的内容可能会在助手的公开范围内共享。",
+    "button": {
+      "review": "重新检查设置",
+      "proceed": "了解并继续"
+    }
+  },
+  "default_ai_assistant": {
+    "not_set": "未设置默认助手"
+  },
+  "ai_assistant_tree": {
+    "add_assistant": "添加助手",
+    "my_assistants": "我的助手",
+    "team_assistants": "团队助手",
+    "thread_does_not_exist": "暂无会话",
+    "toaster": {
+      "ai_assistant_deleted_success": "已删除助手",
+      "ai_assistant_deleted_failed": "删除助手失败",
+      "thread_deleted_success": "已删除会话",
+      "thread_deleted_failed": "删除会话失败",
+      "ai_assistant_set_default_success": "已成功设置默认助手",
+      "ai_assistant_set_default_failed": "设置默认助手失败"
+    }
+  },
   "link_edit": {
     "edit_link": "Edit Link",
     "set_link_and_label": "Set link and label",

+ 1 - 1
apps/app/src/client/components/Bookmarks/BookmarkFolderMenuItem.tsx

@@ -10,7 +10,7 @@ export const BookmarkFolderMenuItem: React.FC<{
   isSelected,
 }) => {
   return (
-    <div className="d-flex justify-content-start grw-bookmark-folder-menu-item-title">
+    <div className="d-flex align-items-center grw-bookmark-folder-menu-item-title">
       <input
         type="radio"
         checked={isSelected}

+ 2 - 2
apps/app/src/client/components/PageControls/PageControls.tsx

@@ -16,7 +16,7 @@ import {
   toggleLike, toggleSubscribe,
 } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
-import RagSearchButton from '~/features/openai/client/components/RagSearchButton';
+import OpenDefaultAiAssistantButton from '~/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton';
 import { useIsGuestUser, useIsReadOnlyUser, useIsSearchPage } from '~/stores-universal/context';
 import {
   EditorMode, useEditorMode,
@@ -285,7 +285,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
       { isViewMode && isDeviceLargerThanMd && !isSearchPage && !isSearchPage && (
         <>
           <SearchButton />
-          <RagSearchButton />
+          <OpenDefaultAiAssistantButton />
         </>
       )}
 

+ 19 - 6
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<InputValidationResult>();
 
@@ -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 => {
         <button
           type="button"
           className="btn btn-outline-neutral-secondary d-flex align-items-center justify-content-center"
-          onClick={openPageSelectModal}
+          onClick={onClickOpenPageSelectModalButton}
         >
           <span className="material-symbols-outlined fs-6">account_tree</span>
         </button>
       </div>
-
-      {isOpened && <PageSelectModal />}
     </div>
   );
 });

+ 52 - 26
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<string | null>(null);
+  const [clickedParentPage, setClickedParentPage] = useState<IPageForItem | null>(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 (
-    <Modal
-      isOpen={isOpened}
-      toggle={closeModal}
-      centered
-    >
+    <>
       <ModalHeader toggle={closeModal}>{t('page_select_modal.select_page_location')}</ModalHeader>
       <ModalBody className="p-0">
         <Suspense fallback={<ItemsTreeContentSkeleton />}>
@@ -101,10 +92,45 @@ export const PageSelectModal: FC = () => {
           </SimpleBar>
         </Suspense>
       </ModalBody>
-      <ModalFooter>
-        <Button color="secondary" onClick={onClickCancel}>{t('Cancel')}</Button>
-        <Button color="primary" onClick={onClickDone}>{t('Done')}</Button>
+      <ModalFooter className="border-top d-flex flex-column">
+        { isHierarchicalSelectionMode && (
+          <div className="form-check form-check-info align-self-start ms-4">
+            <input
+              type="checkbox"
+              id="includeSubPages"
+              className="form-check-input"
+              name="fileUpload"
+              checked={isIncludeSubPage}
+              onChange={() => setIsIncludeSubPage(!isIncludeSubPage)}
+            />
+            <label
+              className="form-label form-check-label"
+              htmlFor="includeSubPages"
+            >
+              {t('Include Subordinated Page')}
+            </label>
+          </div>
+        )}
+        <div className="d-flex gap-2 align-self-end">
+          <Button color="secondary" onClick={onClickCancel}>{t('Cancel')}</Button>
+          <Button color="primary" onClick={onClickDone}>{t('Done')}</Button>
+        </div>
       </ModalFooter>
+    </>
+  );
+};
+
+export const PageSelectModal = (): JSX.Element => {
+  const { data: pageSelectModalData, close: closePageSelectModal } = usePageSelectModal();
+  const isOpen = pageSelectModalData?.isOpened ?? false;
+
+  if (!isOpen) {
+    return <></>;
+  }
+
+  return (
+    <Modal isOpen={isOpen} toggle={closePageSelectModal} centered>
+      <PageSelectModalSubstance />
     </Modal>
   );
 };

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

+ 6 - 2
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 && (
             <span className="position-absolute badge rounded-pill bg-primary">{badgeContents}</span>
           )}
-          <span className="material-symbols-outlined">{iconName}</span>
+          { isCustomIcon
+            ? (<span className="growi-custom-icons fs-4 align-middle">{iconName}</span>)
+            : (<span className="material-symbols-outlined">{iconName}</span>)
+          }
         </div>
       </button>
       {

+ 12 - 0
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) => {
       <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.BOOKMARKS} label="Bookmarks" iconName="bookmarks" onHover={onItemHover} />
       <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.TAG} label="Tags" iconName="local_offer" onHover={onItemHover} />
       <PrimaryItemForNotification sidebarMode={sidebarMode} onHover={onItemHover} />
+      {isAiEnabled && (
+        <PrimaryItem
+          sidebarMode={sidebarMode}
+          contents={SidebarContentsType.AI_ASSISTANT}
+          label="AI Assistant"
+          iconName="growi_ai"
+          isCustomIcon
+          onHover={onItemHover}
+        />
+      )}
     </div>
   );
 });

+ 18 - 15
apps/app/src/components/Admin/Common/AdminNavigation.tsx

@@ -32,19 +32,20 @@ const MenuLabel = ({ menu }: { menu: string }) => {
     case 'user-groups':              return <><span className="material-symbols-outlined me-1">group</span>{            t('user_group_management.user_group_management') }</>;
     case 'audit-log':                return <><span className="material-symbols-outlined me-1">feed</span>{             t('audit_log_management.audit_log')}</>;
     case 'plugins':                  return <><span className="material-symbols-outlined me-1">extension</span>{        t('plugins.plugins')}</>;
-    case 'ai-integration':           return (
-      <>{/* TODO: unify sizing of growi-custom-icons so that simplify code -- 2024.10.09 Yuki Takei */}
-        <span
-          className="growi-custom-icons d-inline-block me-1"
-          style={{
-            fontSize: '18px', width: '24px', height: '24px', lineHeight: '24px', verticalAlign: 'bottom', paddingLeft: '2px',
-          }}
-        >
-          growi_ai
-        </span>
-        {t('ai_integration.ai_integration')}
-      </>
-    );
+    // Temporarily hiding
+    // case 'ai-integration':           return (
+    //   <>{/* TODO: unify sizing of growi-custom-icons so that simplify code -- 2024.10.09 Yuki Takei */}
+    //     <span
+    //       className="growi-custom-icons d-inline-block me-1"
+    //       style={{
+    //         fontSize: '18px', width: '24px', height: '24px', lineHeight: '24px', verticalAlign: 'bottom', paddingLeft: '2px',
+    //       }}
+    //     >
+    //       growi_ai
+    //     </span>
+    //     {t('ai_integration.ai_integration')}
+    //   </>
+    // );
     case 'search':                   return <><span className="material-symbols-outlined me-1">search</span>{           t('full_text_search_management.full_text_search_management') }</>;
     case 'cloud':                    return <><span className="material-symbols-outlined me-1">share</span>{            t('cloud_setting_management.to_cloud_settings')} </>;
     default:                         return <><span className="material-symbols-outlined me-1">home</span>{             t('wiki_management_homepage') }</>;
@@ -119,7 +120,8 @@ export const AdminNavigation = (): JSX.Element => {
         <MenuLink menu="user-groups" isListGroupItems={isListGroupItems} isActive={isActiveMenu(['/user-groups', 'user-group-detail'])} />
         <MenuLink menu="audit-log" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/audit-log')} />
         <MenuLink menu="plugins" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/plugins')} />
-        <MenuLink menu="ai-integration" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/ai-integration')} />
+        {/* Temporarily hiding */}
+        {/* <MenuLink menu="ai-integration" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/aai-integration')} /> */}
         <MenuLink menu="search" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/search')} />
         {growiCloudUri != null && growiAppIdForGrowiCloud != null
           && (
@@ -173,7 +175,8 @@ export const AdminNavigation = (): JSX.Element => {
             {isActiveMenu('/audit-log')             && <MenuLabel menu="audit-log" />}
             {isActiveMenu('/plugins')               && <MenuLabel menu="plugins" />}
             {isActiveMenu('/data-transfer')         && <MenuLabel menu="data-transfer" />}
-            {isActiveMenu('/ai-integration')                && <MenuLabel menu="ai-integration" />}
+            {/* Temporarily hiding */}
+            {/* {isActiveMenu('/ai-integration')                && <MenuLabel menu="ai-integration" />} */}
             {/* eslint-enable no-multi-spaces */}
           </span>
         </button>

+ 15 - 2
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'] ?? '';
 
 
@@ -36,7 +42,11 @@ const DeleteBookmarkFolderModal = dynamic(
 const SearchModal = dynamic(() => import('../../features/search/client/components/SearchModal'), { ssr: false });
 const PageBulkExportSelectModal = dynamic(() => import('../../features/page-bulk-export/client/components/PageBulkExportSelectModal'), { 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
@@ -56,6 +66,8 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
           <AlertSiteUrlUndefined />
           {children}
         </div>
+
+        <AiAssistantChatSidebar />
       </div>
 
       <GrowiNavbarBottom />
@@ -68,8 +80,9 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
       <DeleteAttachmentModal />
       <DeleteBookmarkFolderModal />
       <PutbackPageModal />
+      <PageSelectModal />
       <SearchModal />
-      <AiChatModal />
+      <AiAssistantManagementModal />
 
       <PagePresentationModal />
       <HotkeysManager />

+ 498 - 0
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts

@@ -27,6 +27,21 @@ interface AuthorizedRequest extends Request {
   user?: any
 }
 
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      SyncStatus:
+ *        type: object
+ *        properties:
+ *          isExecutingSync:
+ *            type: boolean
+ *          totalCount:
+ *            type: number
+ *          count:
+ *            type: number
+ */
 module.exports = (crowi: Crowi): Router => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
   const adminRequired = require('~/server/middlewares/admin-required')(crowi);
@@ -79,6 +94,54 @@ module.exports = (crowi: Crowi): Router => {
     ],
   };
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups:
+   *       get:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: listExternalUserGroups
+   *         summary: /external-user-groups
+   *         parameters:
+   *           - name: page
+   *             in: query
+   *             schema:
+   *               type: integer
+   *             description: Page number for pagination
+   *           - name: limit
+   *             in: query
+   *             schema:
+   *               type: integer
+   *             description: Number of items per page
+   *           - name: offset
+   *             in: query
+   *             schema:
+   *               type: integer
+   *             description: Offset for pagination
+   *           - name: pagination
+   *             in: query
+   *             schema:
+   *               type: boolean
+   *             description: Whether to enable pagination
+   *         responses:
+   *           200:
+   *             description: Successfully retrieved external user groups
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   *                   properties:
+   *                     userGroups:
+   *                       type: array
+   *                       items:
+   *                         type: object
+   *                     totalUserGroups:
+   *                       type: integer
+   *                     pagingLimit:
+   *                       type: integer
+   */
   router.get('/', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const { query } = req;
 
@@ -101,6 +164,36 @@ module.exports = (crowi: Crowi): Router => {
     }
   });
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/ancestors:
+   *       get:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: getAncestors
+   *         summary: /external-user-groups/ancestors
+   *         parameters:
+   *           - name: groupId
+   *             in: query
+   *             required: true
+   *             schema:
+   *               type: string
+   *             description: The ID of the user group to get ancestors for
+   *         responses:
+   *           200:
+   *             description: Successfully retrieved ancestor user groups
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   *                   properties:
+   *                     ancestorUserGroups:
+   *                       type: array
+   *                       items:
+   *                         type: object
+   */
   router.get('/ancestors', loginRequiredStrictly, adminRequired, validators.ancestorGroup, apiV3FormValidator, async(req, res: ApiV3Response) => {
     const { groupId } = req.query;
 
@@ -116,6 +209,46 @@ module.exports = (crowi: Crowi): Router => {
     }
   });
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/children:
+   *       get:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: listChildren
+   *         summary: /external-user-groups/children
+   *         parameters:
+   *           - name: parentIds
+   *             in: query
+   *             schema:
+   *               type: array
+   *               items:
+   *                 type: string
+   *             description: The IDs of the parent user groups
+   *           - name: includeGrandChildren
+   *             in: query
+   *             schema:
+   *               type: boolean
+   *             description: Whether to include grandchild user groups
+   *         responses:
+   *           200:
+   *             description: Successfully retrieved child user groups
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   *                   properties:
+   *                     childUserGroups:
+   *                       type: array
+   *                       items:
+   *                         type: object
+   *                     grandChildUserGroups:
+   *                       type: array
+   *                       items:
+   *                         type: object
+   */
   router.get('/children', loginRequiredStrictly, adminRequired, validators.listChildren, async(req, res) => {
     try {
       const { parentIds, includeGrandChildren = false } = req.query;
@@ -133,6 +266,34 @@ module.exports = (crowi: Crowi): Router => {
     }
   });
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/{id}:
+   *       get:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: getExternalUserGroup
+   *         summary: /external-user-groups/{id}
+   *         parameters:
+   *           - name: id
+   *             in: path
+   *             required: true
+   *             schema:
+   *               type: string
+   *             description: The ID of the external user group
+   *         responses:
+   *           200:
+   *             description: Successfully retrieved external user group details
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   *                   properties:
+   *                     userGroup:
+   *                       type: object
+   */
   router.get('/:id', loginRequiredStrictly, adminRequired, validators.detail, async(req, res: ApiV3Response) => {
     const { id } = req.params;
 
@@ -147,6 +308,52 @@ module.exports = (crowi: Crowi): Router => {
     }
   });
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/{id}:
+   *       delete:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: deleteExternalUserGroup
+   *         summary: /external-user-groups/{id}
+   *         parameters:
+   *           - name: id
+   *             in: path
+   *             required: true
+   *             schema:
+   *               type: string
+   *             description: The ID of the external user group
+   *           - name: actionName
+   *             in: query
+   *             required: true
+   *             schema:
+   *               type: string
+   *             description: The action to perform on group delete
+   *           - name: transferToUserGroupId
+   *             in: query
+   *             schema:
+   *               type: string
+   *             description: The ID of the user group to transfer to
+   *           - name: transferToUserGroupType
+   *             in: query
+   *             schema:
+   *               type: string
+   *             description: The type of the user group to transfer to
+   *         responses:
+   *           200:
+   *             description: Successfully deleted the external user group
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   *                   properties:
+   *                     userGroups:
+   *                       type: array
+   *                       items:
+   *                         type: object
+   */
   router.delete('/:id', loginRequiredStrictly, adminRequired, validators.delete, apiV3FormValidator, addActivity,
     async(req: AuthorizedRequest, res: ApiV3Response) => {
       const { id: deleteGroupId } = req.params;
@@ -176,6 +383,43 @@ module.exports = (crowi: Crowi): Router => {
       }
     });
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/{id}:
+   *       put:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: updateExternalUserGroup
+   *         summary: /external-user-groups/{id}
+   *         parameters:
+   *           - name: id
+   *             in: path
+   *             required: true
+   *             schema:
+   *               type: string
+   *             description: The ID of the external user group
+   *         requestBody:
+   *           required: true
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 type: object
+   *                 properties:
+   *                   description:
+   *                     type: string
+   *         responses:
+   *           200:
+   *             description: Successfully updated the external user group
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   *                   properties:
+   *                     userGroup:
+   *                       type: object
+   */
   router.put('/:id', loginRequiredStrictly, adminRequired, validators.update, apiV3FormValidator, addActivity, async(req, res: ApiV3Response) => {
     const { id } = req.params;
     const {
@@ -197,6 +441,36 @@ module.exports = (crowi: Crowi): Router => {
     }
   });
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/:id/external-user-group-relations:
+   *       get:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: getExternalUserGroupRelations
+   *         summary: /external-user-groups/:id/external-user-group-relations
+   *         parameters:
+   *           - name: id
+   *             in: path
+   *             required: true
+   *             schema:
+   *               type: string
+   *             description: The ID of the external user group
+   *         responses:
+   *           200:
+   *             description: Successfully retrieved external user group relations
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   *                   properties:
+   *                     userGroupRelations:
+   *                       type: array
+   *                       items:
+   *                         type: object
+   */
   router.get('/:id/external-user-group-relations', loginRequiredStrictly, adminRequired, async(req, res: ApiV3Response) => {
     const { id } = req.params;
 
@@ -214,6 +488,41 @@ module.exports = (crowi: Crowi): Router => {
     }
   });
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/ldap/sync-settings:
+   *       get:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: getLdapSyncSettings
+   *         summary: Get LDAP sync settings
+   *         responses:
+   *           200:
+   *             description: Successfully retrieved LDAP sync settings
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   *                   properties:
+   *                     ldapGroupSearchBase:
+   *                       type: string
+   *                     ldapGroupMembershipAttribute:
+   *                       type: string
+   *                     ldapGroupMembershipAttributeType:
+   *                       type: string
+   *                     ldapGroupChildGroupAttribute:
+   *                       type: string
+   *                     autoGenerateUserOnLdapGroupSync:
+   *                       type: boolean
+   *                     preserveDeletedLdapGroups:
+   *                       type: boolean
+   *                     ldapGroupNameAttribute:
+   *                       type: string
+   *                     ldapGroupDescriptionAttribute:
+   *                       type: string
+   */
   router.get('/ldap/sync-settings', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
     const settings = {
       ldapGroupSearchBase: configManager.getConfig('external-user-group:ldap:groupSearchBase'),
@@ -229,6 +538,41 @@ module.exports = (crowi: Crowi): Router => {
     return res.apiv3(settings);
   });
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/keycloak/sync-settings:
+   *       get:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: getKeycloakSyncSettings
+   *         summary: Get Keycloak sync settings
+   *         responses:
+   *           200:
+   *             description: Successfully retrieved Keycloak sync settings
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   *                   properties:
+   *                     keycloakHost:
+   *                       type: string
+   *                     keycloakGroupRealm:
+   *                       type: string
+   *                     keycloakGroupSyncClientRealm:
+   *                       type: string
+   *                     keycloakGroupSyncClientID:
+   *                       type: string
+   *                     keycloakGroupSyncClientSecret:
+   *                       type: string
+   *                     autoGenerateUserOnKeycloakGroupSync:
+   *                       type: boolean
+   *                     preserveDeletedKeycloakGroups:
+   *                       type: boolean
+   *                     keycloakGroupDescriptionAttribute:
+   *                       type: string
+   */
   router.get('/keycloak/sync-settings', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
     const settings = {
       keycloakHost: configManager.getConfig('external-user-group:keycloak:host'),
@@ -244,6 +588,47 @@ module.exports = (crowi: Crowi): Router => {
     return res.apiv3(settings);
   });
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/ldap/sync-settings:
+   *       put:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: updateLdapSyncSettings
+   *         summary: Update LDAP sync settings
+   *         requestBody:
+   *           required: true
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 type: object
+   *                 properties:
+   *                   ldapGroupSearchBase:
+   *                     type: string
+   *                   ldapGroupMembershipAttribute:
+   *                     type: string
+   *                   ldapGroupMembershipAttributeType:
+   *                     type: string
+   *                   ldapGroupChildGroupAttribute:
+   *                     type: string
+   *                   autoGenerateUserOnLdapGroupSync:
+   *                     type: boolean
+   *                   preserveDeletedLdapGroups:
+   *                     type: boolean
+   *                   ldapGroupNameAttribute:
+   *                     type: string
+   *                   ldapGroupDescriptionAttribute:
+   *                     type: string
+   *         responses:
+   *           204:
+   *             description: Sync settings updated successfully
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   */
   router.put('/ldap/sync-settings', loginRequiredStrictly, adminRequired, validators.ldapSyncSettings, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const errors = validationResult(req);
     if (!errors.isEmpty()) {
@@ -280,6 +665,47 @@ module.exports = (crowi: Crowi): Router => {
     }
   });
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/keycloak/sync-settings:
+   *       put:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: updateKeycloakSyncSettings
+   *         summary: /external-user-groups/keycloak/sync-settings
+   *         requestBody:
+   *           required: true
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 type: object
+   *                 properties:
+   *                   keycloakHost:
+   *                     type: string
+   *                   keycloakGroupRealm:
+   *                     type: string
+   *                   keycloakGroupSyncClientRealm:
+   *                     type: string
+   *                   keycloakGroupSyncClientID:
+   *                     type: string
+   *                   keycloakGroupSyncClientSecret:
+   *                     type: string
+   *                   autoGenerateUserOnKeycloakGroupSync:
+   *                     type: boolean
+   *                   preserveDeletedKeycloakGroups:
+   *                     type: boolean
+   *                   keycloakGroupDescriptionAttribute:
+   *                     type: string
+   *         responses:
+   *           204:
+   *             description: Sync settings updated successfully
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   */
   router.put('/keycloak/sync-settings', loginRequiredStrictly, adminRequired, validators.keycloakSyncSettings,
     async(req: AuthorizedRequest, res: ApiV3Response) => {
       const errors = validationResult(req);
@@ -312,6 +738,24 @@ module.exports = (crowi: Crowi): Router => {
       }
     });
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/ldap/sync:
+   *       put:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: syncExternalUserGroupsLdap
+   *         summary: Start LDAP sync process
+   *         responses:
+   *           202:
+   *             description: Sync process started
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   type: object
+   */
   router.put('/ldap/sync', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     if (isExecutingSync()) {
       return res.apiv3Err(
@@ -341,6 +785,24 @@ module.exports = (crowi: Crowi): Router => {
     return res.apiv3({}, 202);
   });
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/keycloak/sync:
+   *       put:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: syncExternalUserGroupsKeycloak
+   *         summary: /external-user-groups/keycloak/sync
+   *         responses:
+   *           202:
+   *             description: Sync process started
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                 type: object
+   */
   router.put('/keycloak/sync', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     if (isExecutingSync()) {
       return res.apiv3Err(
@@ -386,11 +848,47 @@ module.exports = (crowi: Crowi): Router => {
     return res.apiv3({}, 202);
   });
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/ldap/sync-status:
+   *       get:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: getExternalUserGroupsLdapSyncStatus
+   *         summary: Get LDAP sync status
+   *         responses:
+   *           200:
+   *             description: Successfully retrieved LDAP sync status
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   $ref: '#/components/schemas/SyncStatus'
+   */
   router.get('/ldap/sync-status', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
     const syncStatus = crowi.ldapUserGroupSyncService?.syncStatus;
     return res.apiv3({ ...syncStatus });
   });
 
+  /**
+   * @swagger
+   *   paths:
+   *     /external-user-groups/ldap/sync-status:
+   *       get:
+   *         tags: [ExternalUserGroups]
+   *         security:
+   *           - cookieAuth: []
+   *         operationId: getExternalUserGroupsLdapSyncStatus
+   *         summary: /external-user-groups/ldap/sync-status
+   *         responses:
+   *           200:
+   *             description: Successfully retrieved LDAP sync status
+   *             content:
+   *               application/json:
+   *                 schema:
+   *                   $ref: '#/components/schemas/SyncStatus'
+   */
   router.get('/keycloak/sync-status', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
     const syncStatus = crowi.keycloakUserGroupSyncService?.syncStatus;
     return res.apiv3({ ...syncStatus });

+ 2 - 1
apps/app/src/features/growi-plugin/client/components/GrowiPluginsActivator.tsx

@@ -1,4 +1,4 @@
-import { useEffect } from 'react';
+import React, { useEffect } from 'react';
 
 import { initializeGrowiFacade, registerGrowiFacade } from '../utils/growi-facade-utils';
 
@@ -24,6 +24,7 @@ async function activateAll(): Promise<void> {
         generatePreviewOptions,
       },
     },
+    react: React,
   });
 
   if (!('pluginActivators' in window)) {

+ 38 - 7
apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.ts

@@ -72,8 +72,16 @@ export class GrowiPluginService implements IGrowiPluginService {
 
       // if not exists repository in file system, download latest plugin repository
       for await (const growiPlugin of growiPlugins) {
-        const pluginPath = path.join(PLUGIN_STORING_PATH, growiPlugin.installedPath);
-        const organizationName = path.join(PLUGIN_STORING_PATH, growiPlugin.organizationName);
+        let pluginPath :fs.PathLike|undefined;
+        let organizationName :fs.PathLike|undefined;
+        try {
+          pluginPath = this.joinAndValidatePath(PLUGIN_STORING_PATH, growiPlugin.installedPath);
+          organizationName = this.joinAndValidatePath(PLUGIN_STORING_PATH, growiPlugin.organizationName);
+        }
+        catch (err) {
+          logger.error(err);
+          continue;
+        }
         if (fs.existsSync(pluginPath)) {
           continue;
         }
@@ -301,22 +309,34 @@ export class GrowiPluginService implements IGrowiPluginService {
     }
 
     try {
-      const growiPluginsPath = path.join(PLUGIN_STORING_PATH, growiPlugins.installedPath);
-      await deleteFolder(growiPluginsPath);
+      await GrowiPlugin.deleteOne({ _id: pluginId });
     }
     catch (err) {
       logger.error(err);
-      throw new Error('Failed to delete plugin repository.');
+      throw new Error('Failed to delete plugin from GrowiPlugin documents.');
     }
 
+    let growiPluginsPath: fs.PathLike | undefined;
     try {
-      await GrowiPlugin.deleteOne({ _id: pluginId });
+      growiPluginsPath = this.joinAndValidatePath(PLUGIN_STORING_PATH, growiPlugins.installedPath);
     }
     catch (err) {
       logger.error(err);
-      throw new Error('Failed to delete plugin from GrowiPlugin documents.');
+      throw new Error('The installedPath for the plugin is invalid, and the plugin has already been removed.');
     }
 
+    if (growiPluginsPath && fs.existsSync(growiPluginsPath)) {
+      try {
+        await deleteFolder(growiPluginsPath);
+      }
+      catch (err) {
+        logger.error(err);
+        throw new Error('Failed to delete plugin repository.');
+      }
+    }
+    else {
+      logger.warn(`Plugin path does not exist : ${growiPluginsPath}`);
+    }
     return growiPlugins.meta.name;
   }
 
@@ -402,6 +422,17 @@ export class GrowiPluginService implements IGrowiPluginService {
     return entries;
   }
 
+  private joinAndValidatePath(baseDir: string, ...paths: string[]):fs.PathLike {
+    const joinedPath = path.join(baseDir, ...paths);
+    if (!joinedPath.startsWith(baseDir)) {
+      throw new Error(
+        'Invalid plugin path detected! Access outside of the allowed directory is not permitted.'
+        + `\nAttempted Path: ${joinedPath}`,
+      );
+    }
+    return joinedPath;
+  }
+
 }
 
 

+ 0 - 329
apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.tsx

@@ -1,329 +0,0 @@
-import type { KeyboardEvent } from 'react';
-import React, { useCallback, useEffect, useState } from 'react';
-
-import { useForm, Controller } from 'react-hook-form';
-import { useTranslation } from 'react-i18next';
-import {
-  Collapse,
-  Modal, ModalBody, ModalFooter, ModalHeader,
-  UncontrolledTooltip,
-} from 'reactstrap';
-
-import { apiv3Post } from '~/client/util/apiv3-client';
-import { toastError } from '~/client/util/toastr';
-import { useGrowiCloudUri } from '~/stores-universal/context';
-import loggerFactory from '~/utils/logger';
-
-import { useRagSearchModal } from '../../../client/stores/rag-search';
-import { MessageErrorCode, StreamErrorCode } from '../../../interfaces/message-error';
-
-import { MessageCard } from './MessageCard';
-import { ResizableTextarea } from './ResizableTextArea';
-
-import styles from './AiChatModal.module.scss';
-
-const moduleClass = styles['grw-aichat-modal'] ?? '';
-
-const logger = loggerFactory('growi:clinet:components:RagSearchModal');
-
-
-type Message = {
-  id: string,
-  content: string,
-  isUserMessage?: boolean,
-}
-
-type FormData = {
-  input: string;
-  summaryMode?: boolean;
-};
-
-const AiChatModalSubstance = (): JSX.Element => {
-
-  const { t } = useTranslation();
-
-  const form = useForm<FormData>({
-    defaultValues: {
-      input: '',
-      summaryMode: true,
-    },
-  });
-
-  const [threadId, setThreadId] = useState<string | undefined>();
-  const [messageLogs, setMessageLogs] = useState<Message[]>([]);
-  const [generatingAnswerMessage, setGeneratingAnswerMessage] = useState<Message>();
-  const [errorMessage, setErrorMessage] = useState<string | undefined>();
-  const [isErrorDetailCollapsed, setIsErrorDetailCollapsed] = useState<boolean>(false);
-
-  const { data: growiCloudUri } = useGrowiCloudUri();
-
-  const isGenerating = generatingAnswerMessage != null;
-
-  const submit = useCallback(async(data: FormData) => {
-    // do nothing when the assistant is generating an answer
-    if (isGenerating) {
-      return;
-    }
-
-    // do nothing when the input is empty
-    if (data.input.trim().length === 0) {
-      return;
-    }
-
-    const { length: logLength } = messageLogs;
-
-    // add user message to the logs
-    const newUserMessage = { id: logLength.toString(), content: data.input, isUserMessage: true };
-    setMessageLogs(msgs => [...msgs, newUserMessage]);
-
-    // reset form
-    form.reset({ input: '', summaryMode: data.summaryMode });
-    setErrorMessage(undefined);
-
-    // add an empty assistant message
-    const newAnswerMessage = { id: (logLength + 1).toString(), content: '' };
-    setGeneratingAnswerMessage(newAnswerMessage);
-
-    // create thread
-    let currentThreadId = threadId;
-    if (threadId == null) {
-      try {
-        const res = await apiv3Post('/openai/thread');
-        const thread = res.data.thread;
-
-        setThreadId(thread.id);
-        currentThreadId = thread.id;
-      }
-      catch (err) {
-        logger.error(err.toString());
-        toastError(t('modal_aichat.failed_to_create_or_retrieve_thread'));
-      }
-    }
-
-    // post message
-    try {
-      const response = await fetch('/_api/v3/openai/message', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ userMessage: data.input, threadId: currentThreadId, summaryMode: data.summaryMode }),
-      });
-
-      if (!response.ok) {
-        const resJson = await response.json();
-        if ('errors' in resJson) {
-          // eslint-disable-next-line @typescript-eslint/no-unused-vars
-          const errors = resJson.errors.map(({ message }) => message).join(', ');
-          form.setError('input', { type: 'manual', message: `[${response.status}] ${errors}` });
-
-          const hasThreadIdNotSetError = resJson.errors.some(err => err.code === MessageErrorCode.THREAD_ID_IS_NOT_SET);
-          if (hasThreadIdNotSetError) {
-            toastError(t('modal_aichat.failed_to_create_or_retrieve_thread'));
-          }
-        }
-        setGeneratingAnswerMessage(undefined);
-        return;
-      }
-
-      const reader = response.body?.getReader();
-      const decoder = new TextDecoder('utf-8');
-
-      const read = async() => {
-        if (reader == null) return;
-
-        const { done, value } = await reader.read();
-
-        // add assistant message to the logs
-        if (done) {
-          setGeneratingAnswerMessage((generatingAnswerMessage) => {
-            if (generatingAnswerMessage == null) return;
-            setMessageLogs(msgs => [...msgs, generatingAnswerMessage]);
-            return undefined;
-          });
-          return;
-        }
-
-        const chunk = decoder.decode(value);
-
-        const textValues: string[] = [];
-        const lines = chunk.split('\n\n');
-        lines.forEach((line) => {
-          const trimedLine = line.trim();
-          if (trimedLine.startsWith('data:')) {
-            const data = JSON.parse(line.replace('data: ', ''));
-            textValues.push(data.content[0].text.value);
-          }
-          else if (trimedLine.startsWith('error:')) {
-            const error = JSON.parse(line.replace('error: ', ''));
-            logger.error(error.errorMessage);
-            form.setError('input', { type: 'manual', message: error.message });
-
-            if (error.code === StreamErrorCode.BUDGET_EXCEEDED) {
-              setErrorMessage(growiCloudUri != null ? 'modal_aichat.budget_exceeded_for_growi_cloud' : 'modal_aichat.budget_exceeded');
-            }
-          }
-        });
-
-
-        // append text values to the assistant message
-        setGeneratingAnswerMessage((prevMessage) => {
-          if (prevMessage == null) return;
-          return {
-            ...prevMessage,
-            content: prevMessage.content + textValues.join(''),
-          };
-        });
-
-        read();
-      };
-      read();
-    }
-    catch (err) {
-      logger.error(err.toString());
-      form.setError('input', { type: 'manual', message: err.toString() });
-    }
-
-  }, [form, growiCloudUri, isGenerating, messageLogs, t, threadId]);
-
-  const keyDownHandler = (event: KeyboardEvent<HTMLTextAreaElement>) => {
-    if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
-      form.handleSubmit(submit)();
-    }
-  };
-
-  return (
-    <>
-      <ModalBody className="pb-0 pt-3 pt-lg-4 px-3 px-lg-4">
-        <div className="vstack gap-4 pb-4">
-          { messageLogs.map(message => (
-            <MessageCard key={message.id} role={message.isUserMessage ? 'user' : 'assistant'}>{message.content}</MessageCard>
-          )) }
-          { generatingAnswerMessage != null && (
-            <MessageCard role="assistant">{generatingAnswerMessage.content}</MessageCard>
-          )}
-          { messageLogs.length > 0 && (
-            <div className="d-flex justify-content-center">
-              <span className="bg-body-tertiary text-body-secondary rounded-pill px-3 py-1" style={{ fontSize: 'smaller' }}>
-                {t('modal_aichat.caution_against_hallucination')}
-              </span>
-            </div>
-          )}
-        </div>
-      </ModalBody>
-
-      <ModalFooter className="flex-column align-items-start pt-0 pb-3 pb-lg-4 px-3 px-lg-4">
-        <form onSubmit={form.handleSubmit(submit)} className="flex-fill vstack gap-3">
-          <div className="flex-fill hstack gap-2 align-items-end m-0">
-            <Controller
-              name="input"
-              control={form.control}
-              render={({ field }) => (
-                <ResizableTextarea
-                  {...field}
-                  required
-                  className="form-control textarea-ask"
-                  style={{ resize: 'none' }}
-                  rows={1}
-                  placeholder={!form.formState.isSubmitting ? t('modal_aichat.placeholder') : ''}
-                  onKeyDown={keyDownHandler}
-                  disabled={form.formState.isSubmitting}
-                />
-              )}
-            />
-            <button
-              type="submit"
-              className="btn btn-submit no-border"
-              disabled={form.formState.isSubmitting || isGenerating}
-            >
-              <span className="material-symbols-outlined">send</span>
-            </button>
-          </div>
-          <div className="form-check form-switch">
-            <input
-              id="swSummaryMode"
-              type="checkbox"
-              role="switch"
-              className="form-check-input"
-              {...form.register('summaryMode')}
-              disabled={form.formState.isSubmitting || isGenerating}
-            />
-            <label className="form-check-label" htmlFor="swSummaryMode">
-              {t('modal_aichat.summary_mode_label')}
-            </label>
-
-            {/* Help */}
-            <a
-              id="tooltipForHelpOfSummaryMode"
-              role="button"
-              className="ms-1"
-            >
-              <span className="material-symbols-outlined fs-6" style={{ lineHeight: 'unset' }}>help</span>
-            </a>
-            <UncontrolledTooltip
-              target="tooltipForHelpOfSummaryMode"
-            >
-              {t('modal_aichat.summary_mode_help')}
-            </UncontrolledTooltip>
-          </div>
-        </form>
-
-
-        {form.formState.errors.input != null && (
-          <div className="mt-4 bg-danger bg-opacity-10 rounded-3 p-2 w-100">
-            <div>
-              <span className="material-symbols-outlined text-danger me-2">error</span>
-              <span className="text-danger">{ errorMessage != null ? t(errorMessage) : t('modal_aichat.error_message') }</span>
-            </div>
-
-            <button
-              type="button"
-              className="btn btn-link text-secondary p-0"
-              aria-expanded={isErrorDetailCollapsed}
-              onClick={() => setIsErrorDetailCollapsed(!isErrorDetailCollapsed)}
-            >
-              <span className={`material-symbols-outlined mt-2 me-1 ${isErrorDetailCollapsed ? 'rotate-90' : ''}`}>
-                chevron_right
-              </span>
-              <span className="small">{t('modal_aichat.show_error_detail')}</span>
-            </button>
-
-            <Collapse isOpen={isErrorDetailCollapsed}>
-              <div className="ms-2">
-                <div className="">
-                  <div className="text-secondary small">
-                    {form.formState.errors.input?.message}
-                  </div>
-                </div>
-              </div>
-            </Collapse>
-          </div>
-        )}
-      </ModalFooter>
-    </>
-  );
-};
-
-
-export const AiChatModal = (): JSX.Element => {
-
-  const { t } = useTranslation();
-
-  const { data: ragSearchModalData, close: closeRagSearchModal } = useRagSearchModal();
-
-  const isOpened = ragSearchModalData?.isOpened ?? false;
-
-  return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeRagSearchModal} className={moduleClass} scrollable>
-
-      <ModalHeader tag="h4" toggle={closeRagSearchModal} className="pe-4">
-        <span className="growi-custom-icons growi-ai-chat-icon me-3 fs-4">knowledge_assistant</span>
-        <span className="fw-bold">{t('modal_aichat.title')}</span>
-        <span className="fs-5 text-body-secondary ms-3">{t('modal_aichat.title_beta_label')}</span>
-      </ModalHeader>
-
-      { isOpened && (
-        <AiChatModalSubstance />
-      ) }
-
-    </Modal>
-  );
-};

+ 0 - 1
apps/app/src/features/openai/chat/components/AiChatModal/index.ts

@@ -1 +0,0 @@
-export * from './AiChatModal';

+ 8 - 4
apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.module.scss → apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.module.scss

@@ -2,7 +2,13 @@
 @use '@growi/core-styles/scss/variables/growi-official-colors';
 @use '@growi/ui/scss/atoms/btn-muted';
 
-.grw-aichat-modal :global {
+.grw-ai-assistant-chat-sidebar :global {
+  z-index: bs.$zindex-fixed + 2;
+  width: 100%;
+
+  @include bs.media-breakpoint-up(sm) {
+    width: 500px;
+  }
 
   .textarea-ask {
     max-height: 30vh;
@@ -13,9 +19,8 @@
   }
 }
 
-
 // == Colors
-.grw-aichat-modal :global {
+.grw-ai-assistant-chat-sidebar :global {
   .growi-ai-chat-icon {
     color: growi-official-colors.$growi-ai-purple;
   }
@@ -24,4 +29,3 @@
     @include btn-muted.colorize(bs.$purple, bs.$purple);
   }
 }
-

+ 455 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.tsx

@@ -0,0 +1,455 @@
+import type { KeyboardEvent } from 'react';
+import {
+  type FC, memo, useRef, useEffect, useState, useCallback,
+} from 'react';
+
+import { useForm, Controller } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { Collapse, UncontrolledTooltip } from 'reactstrap';
+import SimpleBar from 'simplebar-react';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { toastError } from '~/client/util/toastr';
+import { MessageErrorCode, StreamErrorCode } from '~/features/openai/interfaces/message-error';
+import type { IThreadRelationHasId } from '~/features/openai/interfaces/thread-relation';
+import { useGrowiCloudUri } from '~/stores-universal/context';
+import loggerFactory from '~/utils/logger';
+
+import type { AiAssistantHasId } from '../../../../interfaces/ai-assistant';
+import { useAiAssistantChatSidebar } from '../../../stores/ai-assistant';
+import { useSWRMUTxMessages } from '../../../stores/message';
+import { useSWRMUTxThreads } from '../../../stores/thread';
+
+import { MessageCard } from './MessageCard';
+import { ResizableTextarea } from './ResizableTextArea';
+
+import styles from './AiAssistantChatSidebar.module.scss';
+
+const logger = loggerFactory('growi:openai:client:components:AiAssistantChatSidebar');
+
+const moduleClass = styles['grw-ai-assistant-chat-sidebar'] ?? '';
+
+type Message = {
+  id: string,
+  content: string,
+  isUserMessage?: boolean,
+}
+
+type FormData = {
+  input: string;
+  summaryMode?: boolean;
+};
+
+type AiAssistantChatSidebarSubstanceProps = {
+  aiAssistantData: AiAssistantHasId;
+  threadData?: IThreadRelationHasId;
+  closeAiAssistantChatSidebar: () => void
+}
+
+const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceProps> = (props: AiAssistantChatSidebarSubstanceProps) => {
+  const {
+    aiAssistantData, threadData, closeAiAssistantChatSidebar,
+  } = props;
+
+  const [currentThreadTitle, setCurrentThreadTitle] = useState<string | undefined>(threadData?.title);
+  const [currentThreadId, setCurrentThreadId] = useState<string | undefined>(threadData?.threadId);
+  const [messageLogs, setMessageLogs] = useState<Message[]>([]);
+  const [generatingAnswerMessage, setGeneratingAnswerMessage] = useState<Message>();
+  const [errorMessage, setErrorMessage] = useState<string | undefined>();
+  const [isErrorDetailCollapsed, setIsErrorDetailCollapsed] = useState<boolean>(false);
+
+  const { t } = useTranslation();
+  const { data: growiCloudUri } = useGrowiCloudUri();
+  const { trigger: mutateThreadData } = useSWRMUTxThreads(aiAssistantData._id);
+  const { trigger: mutateMessageData } = useSWRMUTxMessages(aiAssistantData._id, threadData?.threadId);
+
+  const form = useForm<FormData>({
+    defaultValues: {
+      input: '',
+      summaryMode: true,
+    },
+  });
+
+  useEffect(() => {
+    const fetchAndSetMessageData = async() => {
+      const messageData = await mutateMessageData();
+      if (messageData != null) {
+        const normalizedMessageData = messageData.data
+          .reverse()
+          .filter(message => message.metadata?.shouldHideMessage !== 'true');
+
+        setMessageLogs(() => {
+          return normalizedMessageData.map((message, index) => (
+            {
+              id: index.toString(),
+              content: message.content[0].type === 'text' ? message.content[0].text.value : '',
+              isUserMessage: message.role === 'user',
+            }
+          ));
+        });
+      }
+    };
+
+    if (threadData != null) {
+      fetchAndSetMessageData();
+    }
+  }, [mutateMessageData, threadData]);
+
+  const isGenerating = generatingAnswerMessage != null;
+  const submit = useCallback(async(data: FormData) => {
+    // do nothing when the assistant is generating an answer
+    if (isGenerating) {
+      return;
+    }
+
+    // do nothing when the input is empty
+    if (data.input.trim().length === 0) {
+      return;
+    }
+
+    const { length: logLength } = messageLogs;
+
+    // add user message to the logs
+    const newUserMessage = { id: logLength.toString(), content: data.input, isUserMessage: true };
+    setMessageLogs(msgs => [...msgs, newUserMessage]);
+
+    // reset form
+    form.reset({ input: '', summaryMode: data.summaryMode });
+    setErrorMessage(undefined);
+
+    // add an empty assistant message
+    const newAnswerMessage = { id: (logLength + 1).toString(), content: '' };
+    setGeneratingAnswerMessage(newAnswerMessage);
+
+    // create thread
+    let currentThreadId_ = currentThreadId;
+    if (currentThreadId_ == null) {
+      try {
+        const res = await apiv3Post<IThreadRelationHasId>('/openai/thread', {
+          aiAssistantId: aiAssistantData._id,
+          initialUserMessage: newUserMessage.content,
+        });
+
+        const thread = res.data;
+
+        setCurrentThreadId(thread.threadId);
+        setCurrentThreadTitle(thread.title);
+
+        currentThreadId_ = thread.threadId;
+
+        // No need to await because data is not used
+        mutateThreadData();
+      }
+      catch (err) {
+        logger.error(err.toString());
+        toastError(t('sidebar_aichat.failed_to_create_or_retrieve_thread'));
+      }
+    }
+
+    // post message
+    try {
+      const response = await fetch('/_api/v3/openai/message', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          userMessage: data.input, threadId: currentThreadId_, summaryMode: data.summaryMode, aiAssistantId: aiAssistantData._id,
+        }),
+      });
+
+      if (!response.ok) {
+        const resJson = await response.json();
+        if ('errors' in resJson) {
+          // eslint-disable-next-line @typescript-eslint/no-unused-vars
+          const errors = resJson.errors.map(({ message }) => message).join(', ');
+          form.setError('input', { type: 'manual', message: `[${response.status}] ${errors}` });
+
+          const hasThreadIdNotSetError = resJson.errors.some(err => err.code === MessageErrorCode.THREAD_ID_IS_NOT_SET);
+          if (hasThreadIdNotSetError) {
+            toastError(t('sidebar_aichat.failed_to_create_or_retrieve_thread'));
+          }
+        }
+        setGeneratingAnswerMessage(undefined);
+        return;
+      }
+
+      const reader = response.body?.getReader();
+      const decoder = new TextDecoder('utf-8');
+
+      const read = async() => {
+        if (reader == null) return;
+
+        const { done, value } = await reader.read();
+
+        // add assistant message to the logs
+        if (done) {
+          setGeneratingAnswerMessage((generatingAnswerMessage) => {
+            if (generatingAnswerMessage == null) return;
+            setMessageLogs(msgs => [...msgs, generatingAnswerMessage]);
+            return undefined;
+          });
+          return;
+        }
+
+        const chunk = decoder.decode(value);
+
+        const textValues: string[] = [];
+        const lines = chunk.split('\n\n');
+        lines.forEach((line) => {
+          const trimedLine = line.trim();
+          if (trimedLine.startsWith('data:')) {
+            const data = JSON.parse(line.replace('data: ', ''));
+            textValues.push(data.content[0].text.value);
+          }
+          else if (trimedLine.startsWith('error:')) {
+            const error = JSON.parse(line.replace('error: ', ''));
+            logger.error(error.errorMessage);
+            form.setError('input', { type: 'manual', message: error.message });
+
+            if (error.code === StreamErrorCode.BUDGET_EXCEEDED) {
+              setErrorMessage(growiCloudUri != null ? 'sidebar_aichat.budget_exceeded_for_growi_cloud' : 'sidebar_aichat.budget_exceeded');
+            }
+          }
+        });
+
+
+        // append text values to the assistant message
+        setGeneratingAnswerMessage((prevMessage) => {
+          if (prevMessage == null) return;
+          return {
+            ...prevMessage,
+            content: prevMessage.content + textValues.join(''),
+          };
+        });
+
+        read();
+      };
+      read();
+    }
+    catch (err) {
+      logger.error(err.toString());
+      form.setError('input', { type: 'manual', message: err.toString() });
+    }
+
+  }, [isGenerating, messageLogs, form, currentThreadId, aiAssistantData._id, mutateThreadData, t, growiCloudUri]);
+
+  const keyDownHandler = (event: KeyboardEvent<HTMLTextAreaElement>) => {
+    if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
+      form.handleSubmit(submit)();
+    }
+  };
+
+  return (
+    <>
+      <div className="d-flex flex-column vh-100">
+        <div className="d-flex align-items-center p-3 border-bottom position-sticky top-0 bg-body z-1">
+          <span className="growi-custom-icons growi-ai-chat-icon me-3 fs-4">ai_assistant</span>
+          <h5 className="mb-0 fw-bold flex-grow-1 text-truncate">{currentThreadTitle ?? aiAssistantData.name}</h5>
+          <button
+            type="button"
+            className="btn btn-link p-0 border-0"
+            onClick={closeAiAssistantChatSidebar}
+          >
+            <span className="material-symbols-outlined">close</span>
+          </button>
+        </div>
+        <div className="p-4 d-flex flex-column gap-4 vh-100">
+
+
+          { currentThreadId != null
+            ? (
+              <div className="vstack gap-4 pb-2">
+                { messageLogs.map(message => (
+                  <MessageCard key={message.id} role={message.isUserMessage ? 'user' : 'assistant'}>{message.content}</MessageCard>
+                )) }
+                { generatingAnswerMessage != null && (
+                  <MessageCard role="assistant">{generatingAnswerMessage.content}</MessageCard>
+                )}
+                { messageLogs.length > 0 && (
+                  <div className="d-flex justify-content-center">
+                    <span className="bg-body-tertiary text-body-secondary rounded-pill px-3 py-1" style={{ fontSize: 'smaller' }}>
+                      {t('sidebar_aichat.caution_against_hallucination')}
+                    </span>
+                  </div>
+                )}
+              </div>
+            )
+            : (
+              <>
+                <p className="fs-6 text-body-secondary mb-0">
+                  {aiAssistantData.description}
+                </p>
+
+                <div>
+                  <p className="text-body-secondary">{t('sidebar_aichat.instruction_label')}</p>
+                  <div className="card bg-body-tertiary border-0">
+                    <div className="card-body p-3">
+                      <p className="fs-6 text-body-secondary mb-0">
+                        {aiAssistantData.additionalInstruction}
+                      </p>
+                    </div>
+                  </div>
+                </div>
+
+                <div>
+                  <div className="d-flex align-items-center">
+                    <p className="text-body-secondary mb-0">{t('sidebar_aichat.reference_pages_label')}</p>
+                  </div>
+                  <div className="d-flex flex-column gap-1">
+                    { aiAssistantData.pagePathPatterns.map(pagePathPattern => (
+                      <a
+                        key={pagePathPattern}
+                        href="#"
+                        className="fs-6 text-body-secondary text-decoration-none"
+                      >
+                        {pagePathPattern}
+                      </a>
+                    ))}
+                  </div>
+                </div>
+
+              </>
+            )
+          }
+
+          <div className="mt-auto">
+            <form onSubmit={form.handleSubmit(submit)} className="flex-fill vstack gap-3">
+              <div className="flex-fill hstack gap-2 align-items-end m-0">
+                <Controller
+                  name="input"
+                  control={form.control}
+                  render={({ field }) => (
+                    <ResizableTextarea
+                      {...field}
+                      required
+                      className="form-control textarea-ask"
+                      style={{ resize: 'none' }}
+                      rows={1}
+                      placeholder={!form.formState.isSubmitting ? t('sidebar_aichat.placeholder') : ''}
+                      onKeyDown={keyDownHandler}
+                      disabled={form.formState.isSubmitting}
+                    />
+                  )}
+                />
+                <button
+                  type="submit"
+                  className="btn btn-submit no-border"
+                  disabled={form.formState.isSubmitting || isGenerating}
+                >
+                  <span className="material-symbols-outlined">send</span>
+                </button>
+              </div>
+              <div className="form-check form-switch">
+                <input
+                  id="swSummaryMode"
+                  type="checkbox"
+                  role="switch"
+                  className="form-check-input"
+                  {...form.register('summaryMode')}
+                  disabled={form.formState.isSubmitting || isGenerating}
+                />
+                <label className="form-check-label" htmlFor="swSummaryMode">
+                  {t('sidebar_aichat.summary_mode_label')}
+                </label>
+
+                {/* Help */}
+                <a
+                  id="tooltipForHelpOfSummaryMode"
+                  role="button"
+                  className="ms-1"
+                >
+                  <span className="material-symbols-outlined fs-6" style={{ lineHeight: 'unset' }}>help</span>
+                </a>
+                <UncontrolledTooltip
+                  target="tooltipForHelpOfSummaryMode"
+                >
+                  {t('sidebar_aichat.summary_mode_help')}
+                </UncontrolledTooltip>
+              </div>
+            </form>
+
+            {form.formState.errors.input != null && (
+              <div className="mt-4 bg-danger bg-opacity-10 rounded-3 p-2 w-100">
+                <div>
+                  <span className="material-symbols-outlined text-danger me-2">error</span>
+                  <span className="text-danger">{ errorMessage != null ? t(errorMessage) : t('sidebar_aichat.error_message') }</span>
+                </div>
+
+                <button
+                  type="button"
+                  className="btn btn-link text-body-secondary p-0"
+                  aria-expanded={isErrorDetailCollapsed}
+                  onClick={() => setIsErrorDetailCollapsed(!isErrorDetailCollapsed)}
+                >
+                  <span className={`material-symbols-outlined mt-2 me-1 ${isErrorDetailCollapsed ? 'rotate-90' : ''}`}>
+                    chevron_right
+                  </span>
+                  <span className="small">{t('sidebar_aichat.show_error_detail')}</span>
+                </button>
+
+                <Collapse isOpen={isErrorDetailCollapsed}>
+                  <div className="ms-2">
+                    <div className="">
+                      <div className="text-body-secondary small">
+                        {form.formState.errors.input?.message}
+                      </div>
+                    </div>
+                  </div>
+                </Collapse>
+              </div>
+            )}
+
+          </div>
+        </div>
+      </div>
+    </>
+  );
+};
+
+
+export const AiAssistantChatSidebar: FC = memo((): JSX.Element => {
+  const sidebarRef = useRef<HTMLDivElement>(null);
+  const sidebarScrollerRef = useRef<HTMLDivElement>(null);
+
+  const { data: aiAssistantChatSidebarData, close: closeAiAssistantChatSidebar } = useAiAssistantChatSidebar();
+
+  const aiAssistantData = aiAssistantChatSidebarData?.aiAssistantData;
+  const threadData = aiAssistantChatSidebarData?.threadData;
+  const isOpened = aiAssistantChatSidebarData?.isOpened && aiAssistantData != null;
+
+  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]);
+
+  if (!isOpened) {
+    return <></>;
+  }
+
+  return (
+    <div
+      ref={sidebarRef}
+      className={`position-fixed top-0 end-0 h-100 border-start bg-body shadow-sm overflow-hidden ${moduleClass}`}
+      data-testid="grw-right-sidebar"
+    >
+      <SimpleBar
+        scrollableNodeProps={{ ref: sidebarScrollerRef }}
+        className="h-100 position-relative"
+        autoHide
+      >
+        <AiAssistantChatSidebarSubstance
+          threadData={threadData}
+          aiAssistantData={aiAssistantData}
+          closeAiAssistantChatSidebar={closeAiAssistantChatSidebar}
+        />
+      </SimpleBar>
+    </div>
+  );
+});

+ 0 - 0
apps/app/src/features/openai/chat/components/AiChatModal/MessageCard.module.scss → apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/MessageCard.module.scss


+ 5 - 5
apps/app/src/features/openai/chat/components/AiChatModal/MessageCard.tsx → apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/MessageCard.tsx

@@ -6,7 +6,7 @@ import ReactMarkdown from 'react-markdown';
 
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 
-import { useRagSearchModal } from '../../../client/stores/rag-search';
+import { useAiAssistantChatSidebar } from '../../../stores/ai-assistant';
 
 import styles from './MessageCard.module.scss';
 
@@ -27,11 +27,11 @@ const UserMessageCard = ({ children }: { children: string }): JSX.Element => (
 const assistantMessageCardModuleClass = styles['assistant-message-card'] ?? '';
 
 const NextLinkWrapper = (props: LinkProps & {children: string, href: string}): JSX.Element => {
-  const { close: closeRagSearchModal } = useRagSearchModal();
+  const { close: closeAiAssistantChatSidebar } = useAiAssistantChatSidebar();
 
   const onClick = useCallback(() => {
-    closeRagSearchModal();
-  }, [closeRagSearchModal]);
+    closeAiAssistantChatSidebar();
+  }, [closeAiAssistantChatSidebar]);
 
   return (
     <NextLink href={props.href} onClick={onClick} className="link-primary">
@@ -55,7 +55,7 @@ const AssistantMessageCard = ({ children }: { children: string }): JSX.Element =
             )
             : (
               <span className="text-thinking">
-                {t('modal_aichat.progress_label')} <span className="material-symbols-outlined">more_horiz</span>
+                {t('sidebar_aichat.progress_label')} <span className="material-symbols-outlined">more_horiz</span>
               </span>
             )
           }

+ 0 - 0
apps/app/src/features/openai/chat/components/AiChatModal/ResizableTextArea.tsx → apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/ResizableTextArea.tsx


+ 66 - 0
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: 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 (
+    <div className="mb-4">
+      <Label className="text-secondary mb-2">{t('modal_ai_assistant.page_access_permission')}</Label>
+      <UncontrolledDropdown>
+        <DropdownToggle
+          disabled={isDisabled}
+          caret
+          className="btn-outline-secondary bg-transparent"
+        >
+          {getAccessScopeLabel(selectedAccessScope)}
+        </DropdownToggle>
+        <DropdownMenu>
+          { [AiAssistantAccessScope.OWNER, AiAssistantAccessScope.GROUPS, AiAssistantAccessScope.PUBLIC_ONLY].map(accessScope => (
+            <DropdownItem
+              disabled={isDisabledGroups && accessScope === AiAssistantAccessScope.GROUPS}
+              onClick={() => selectAccessScopeHandler(accessScope)}
+              key={accessScope}
+            >
+              {getAccessScopeLabel(accessScope)}
+            </DropdownItem>
+          ))}
+        </DropdownMenu>
+      </UncontrolledDropdown>
+    </div>
+  );
+};

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

@@ -0,0 +1,40 @@
+import { useTranslation } from 'react-i18next';
+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;
+  const { t } = useTranslation();
+
+  return (
+    <>
+      <AiAssistantManagementHeader />
+
+      <ModalBody className="px-4">
+        <p className="text-secondary py-1">
+          {t('modal_ai_assistant.instructions.description')}
+        </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">
+          {t('modal_ai_assistant.instructions.reset_to_default')}
+        </button>
+      </ModalBody>
+    </>
+  );
+};

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

@@ -0,0 +1,58 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { ModalBody } from 'reactstrap';
+
+import type { IPageForItem } from '~/interfaces/page';
+import { useLimitLearnablePageCountPerAssistant } from '~/stores-universal/context';
+import { usePageSelectModal } from '~/stores/modal';
+
+import type { SelectedPage } from '../../../../interfaces/selected-page';
+
+import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
+import { SelectedPageList } from './SelectedPageList';
+
+
+type Props = {
+  selectedPages: SelectedPage[];
+  onSelect: (page: IPageForItem, isIncludeSubPage: boolean) => void;
+  onRemove: (pageId: string) => void;
+}
+
+export const AiAssistantManagementEditPages = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: limitLearnablePageCountPerAssistant } = useLimitLearnablePageCountPerAssistant();
+
+  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"
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{ __html: t('modal_ai_assistant.edit_page_description', { limitLearnablePageCountPerAssistant }) }}
+        />
+
+        <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>
+          {t('modal_ai_assistant.add_page_button')}
+        </button>
+
+        <SelectedPageList selectedPages={selectedPages} onRemove={onRemove} />
+      </ModalBody>
+    </>
+  );
+};

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

@@ -0,0 +1,149 @@
+import React, {
+  useCallback, useState, useEffect,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+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 { t } = useTranslation();
+  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>(ScopeType.ACCESS);
+
+  useEffect(() => {
+    setIsShared(() => {
+      if (selectedShareScope !== AiAssistantShareScope.SAME_AS_ACCESS_SCOPE) {
+        return true;
+      }
+      return selectedShareScope === AiAssistantShareScope.SAME_AS_ACCESS_SCOPE && selectedAccessScope !== AiAssistantAccessScope.OWNER;
+    });
+  }, [isShared, selectedAccessScope, selectedShareScope]);
+
+  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 (
+    <>
+      <AiAssistantManagementHeader />
+
+      <ModalBody className="px-4">
+        <div className="form-check form-switch mb-4">
+          <Input
+            type="switch"
+            role="switch"
+            id="shareAssistantSwitch"
+            className="form-check-input"
+            checked={isShared}
+            onChange={changeShareToggleHandler}
+          />
+          <Label className="form-check-label" for="shareAssistantSwitch">
+            {t('modal_ai_assistant.share_assistant')}
+          </Label>
+        </div>
+
+        <AccessScopeDropdown
+          isDisabled={!isShared}
+          isDisabledGroups={hasNoRelatedGroups}
+          selectedAccessScope={selectedAccessScope}
+          onSelect={selectAccessScopeHandler}
+        />
+
+        <ShareScopeSwitch
+          isDisabled={!isShared}
+          isDisabledGroups={hasNoRelatedGroups}
+          selectedShareScope={selectedShareScope}
+          onSelect={selectShareScopeHandler}
+        />
+
+        <SelectUserGroupModal
+          isOpen={isSelectUserGroupModalOpen}
+          userRelatedGroups={userRelatedGroups?.relatedGroups}
+          closeModal={() => setIsSelectUserGroupModalOpen(false)}
+          selectedUserGroups={selectedUserGroupType === ScopeType.ACCESS ? selectedUserGroupsForAccessScope : selectedUserGroupsForShareScope}
+          onSelect={(userGroup) => {
+            if (selectedUserGroupType === ScopeType.ACCESS) {
+              onSelectAccessScopeUserGroups(userGroup);
+            }
+            else {
+              onSelectShareScopeUserGroups(userGroup);
+            }
+          }}
+        />
+      </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>
+  );
+};

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

@@ -0,0 +1,221 @@
+import React, { useCallback, useState, useMemo } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  ModalHeader, ModalBody, ModalFooter, Input,
+} from 'reactstrap';
+
+import { AiAssistantShareScope, AiAssistantAccessScope } from '~/features/openai/interfaces/ai-assistant';
+import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
+import { useCurrentUser, useLimitLearnablePageCountPerAssistant } from '~/stores-universal/context';
+
+import type { SelectedPage } from '../../../../interfaces/selected-page';
+import { determineShareScope } from '../../../../utils/determine-share-scope';
+import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode } from '../../../stores/ai-assistant';
+
+import { ShareScopeWarningModal } from './ShareScopeWarningModal';
+
+type Props = {
+  shouldEdit: boolean;
+  name: string;
+  description: string;
+  instruction: string;
+  shareScope: AiAssistantShareScope,
+  accessScope: AiAssistantAccessScope,
+  selectedPages: SelectedPage[];
+  selectedUserGroupsForAccessScope: PopulatedGrantedGroup[],
+  selectedUserGroupsForShareScope: PopulatedGrantedGroup[],
+  onNameChange: (value: string) => void;
+  onDescriptionChange: (value: string) => void;
+  onUpsertAiAssistant: () => Promise<void>
+}
+
+export const AiAssistantManagementHome = (props: Props): JSX.Element => {
+  const {
+    shouldEdit,
+    name,
+    description,
+    instruction,
+    shareScope,
+    accessScope,
+    selectedPages,
+    selectedUserGroupsForAccessScope,
+    selectedUserGroupsForShareScope,
+    onNameChange,
+    onDescriptionChange,
+    onUpsertAiAssistant,
+  } = props;
+
+  const { t } = useTranslation();
+  const { data: currentUser } = useCurrentUser();
+  const { data: limitLearnablePageCountPerAssistant } = useLimitLearnablePageCountPerAssistant();
+  const { close: closeAiAssistantManagementModal, changePageMode } = useAiAssistantManagementModal();
+
+  const [isShareScopeWarningModalOpen, setIsShareScopeWarningModalOpen] = useState(false);
+
+  const totalSelectedPageCount = useMemo(() => {
+    return selectedPages.reduce((total, selectedPage) => {
+      const descendantCount = selectedPage.isIncludeSubPage
+        ? selectedPage.page.descendantCount ?? 0
+        : 0;
+      const pageCountWithDescendants = descendantCount + 1;
+      return total + pageCountWithDescendants;
+    }, 0);
+  }, [selectedPages]);
+
+  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 canUpsert = name !== '' && selectedPages.length !== 0 && (limitLearnablePageCountPerAssistant ?? 3000) >= totalSelectedPageCount;
+
+  const upsertAiAssistantHandler = useCallback(async() => {
+    const shouldWarning = () => {
+      const isDifferentUserGroup = () => {
+        const selectedShareScopeUserGroupIds = selectedUserGroupsForShareScope.map(userGroup => userGroup.item._id);
+        const selectedAccessScopeUserGroupIds = selectedUserGroupsForAccessScope.map(userGroup => userGroup.item._id);
+        if (selectedShareScopeUserGroupIds.length !== selectedAccessScopeUserGroupIds.length) {
+          return false;
+        }
+        return selectedShareScopeUserGroupIds.every((val, index) => val === selectedAccessScopeUserGroupIds[index]);
+      };
+
+      const determinedShareScope = determineShareScope(shareScope, accessScope);
+
+      if (determinedShareScope === AiAssistantShareScope.PUBLIC_ONLY && accessScope !== AiAssistantAccessScope.PUBLIC_ONLY) {
+        return true;
+      }
+
+      if (determinedShareScope === AiAssistantShareScope.OWNER && accessScope !== AiAssistantAccessScope.OWNER) {
+        return true;
+      }
+
+      if (determinedShareScope === AiAssistantShareScope.GROUPS && accessScope === AiAssistantAccessScope.OWNER) {
+        return true;
+      }
+
+      if (determinedShareScope === AiAssistantShareScope.GROUPS && accessScope === AiAssistantAccessScope.GROUPS && !isDifferentUserGroup()) {
+        return true;
+      }
+
+      return false;
+    };
+
+    if (shouldWarning()) {
+      setIsShareScopeWarningModalOpen(true);
+      return;
+    }
+
+    await onUpsertAiAssistant();
+  }, [accessScope, onUpsertAiAssistant, selectedUserGroupsForAccessScope, selectedUserGroupsForShareScope, shareScope]);
+
+  return (
+    <>
+      <ModalHeader tag="h4" toggle={closeAiAssistantManagementModal} className="pe-4">
+        <span className="growi-custom-icons growi-ai-assistant-icon me-3 fs-4">growi_ai</span>
+        <span className="fw-bold">{t(shouldEdit ? 'modal_ai_assistant.header.update_assistant' : 'modal_ai_assistant.header.add_new_assistant')}</span>
+      </ModalHeader>
+
+      <div className="px-4">
+        <ModalBody>
+          <div className="mb-4 growi-ai-assistant-name">
+            <Input
+              type="text"
+              placeholder={t('modal_ai_assistant.assistant_name_placeholder')}
+              bsSize="lg"
+              className="border-0 border-bottom border-2 px-0 rounded-0"
+              value={name}
+              onChange={e => onNameChange(e.target.value)}
+            />
+          </div>
+
+          <div className="mb-4">
+            <div className="d-flex align-items-center mb-2">
+              <span className="text-secondary">{t('modal_ai_assistant.memo.title')}</span>
+              <span className="badge text-bg-secondary ms-2">{t('modal_ai_assistant.memo.optional')}</span>
+            </div>
+            <Input
+              type="textarea"
+              placeholder={t('modal_ai_assistant.memo.placeholder')}
+              rows="4"
+              value={description}
+              onChange={e => onDescriptionChange(e.target.value)}
+            />
+            <small className="text-secondary d-block mt-2">
+              {t('modal_ai_assistant.memo.description')}
+            </small>
+          </div>
+
+          <div>
+            <button
+              type="button"
+              onClick={() => { changePageMode(AiAssistantManagementModalPageMode.SHARE) }}
+              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>{getShareScopeLabel(shareScope)}</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>{t('modal_ai_assistant.page_count', { count: totalSelectedPageCount })}</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={closeAiAssistantManagementModal}
+          >
+            {t('Cancel')}
+          </button>
+
+          <button
+            type="button"
+            disabled={!canUpsert}
+            className="btn btn-primary"
+            onClick={upsertAiAssistantHandler}
+          >
+            {t(shouldEdit ? 'modal_ai_assistant.submit_button.update_assistant' : 'modal_ai_assistant.submit_button.create_assistant')}
+          </button>
+        </ModalFooter>
+      </div>
+
+      <ShareScopeWarningModal
+        isOpen={isShareScopeWarningModalOpen}
+        selectedPages={selectedPages}
+        closeModal={() => setIsShareScopeWarningModalOpen(false)}
+        onSubmit={onUpsertAiAssistant}
+      />
+    </>
+  );
+};

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

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

@@ -0,0 +1,293 @@
+import React, {
+  useCallback, useState, useEffect,
+} from 'react';
+
+import {
+  type IGrantedGroup, isPopulated,
+} from '@growi/core';
+import { useTranslation } from 'react-i18next';
+import { Modal, TabContent, TabPane } from 'reactstrap';
+
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import type { UpsertAiAssistantData } from '~/features/openai/interfaces/ai-assistant';
+import { AiAssistantAccessScope, AiAssistantShareScope } from '~/features/openai/interfaces/ai-assistant';
+import type { IPagePathWithDescendantCount, IPageForItem } from '~/interfaces/page';
+import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
+import { useSWRxPagePathsWithDescendantCount } from '~/stores/page';
+import loggerFactory from '~/utils/logger';
+
+import type { SelectedPage } from '../../../../interfaces/selected-page';
+import { removeGlobPath } from '../../../../utils/remove-glob-path';
+import { createAiAssistant, updateAiAssistant } 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,
+  }));
+};
+
+// IGrantedGroup[] -> PopulatedGrantedGroup[]
+const convertToPopulatedGrantedGroups = (selectedGroups: IGrantedGroup[]): PopulatedGrantedGroup[] => {
+  const populatedGrantedGroups = selectedGroups.filter(group => isPopulated(group.item)) as PopulatedGrantedGroup[];
+  return populatedGrantedGroups;
+};
+
+const convertToSelectedPages = (pagePathPatterns: string[], pagePathsWithDescendantCount: IPagePathWithDescendantCount[]): SelectedPage[] => {
+  return pagePathPatterns.map((pagePathPattern) => {
+    const isIncludeSubPage = pagePathPattern.endsWith('/*');
+    const path = isIncludeSubPage ? pagePathPattern.slice(0, -2) : pagePathPattern;
+    const page = pagePathsWithDescendantCount.find(page => page.path === path);
+    return {
+      page: page ?? { path },
+      isIncludeSubPage,
+    };
+  });
+};
+
+const AiAssistantManagementModalSubstance = (): JSX.Element => {
+  // Hooks
+  const { t } = useTranslation();
+  const { mutate: mutateAiAssistants } = useSWRxAiAssistants();
+  const { data: aiAssistantManagementModalData, close: closeAiAssistantManagementModal } = useAiAssistantManagementModal();
+  const { data: pagePathsWithDescendantCount } = useSWRxPagePathsWithDescendantCount(
+    removeGlobPath(aiAssistantManagementModalData?.aiAssistantData?.pagePathPatterns) ?? null,
+    undefined,
+    true,
+    true,
+  );
+
+  const aiAssistant = aiAssistantManagementModalData?.aiAssistantData;
+  const shouldEdit = aiAssistant != null;
+  const pageMode = aiAssistantManagementModalData?.pageMode ?? AiAssistantManagementModalPageMode.HOME;
+
+
+  // States
+  const [name, setName] = useState<string>('');
+  const [description, setDescription] = useState<string>('');
+  const [selectedShareScope, setSelectedShareScope] = useState<AiAssistantShareScope>(AiAssistantShareScope.SAME_AS_ACCESS_SCOPE);
+  const [selectedAccessScope, setSelectedAccessScope] = useState<AiAssistantAccessScope>(AiAssistantAccessScope.OWNER);
+  const [selectedUserGroupsForAccessScope, setSelectedUserGroupsForAccessScope] = useState<PopulatedGrantedGroup[]>([]);
+  const [selectedUserGroupsForShareScope, setSelectedUserGroupsForShareScope] = useState<PopulatedGrantedGroup[]>([]);
+  const [selectedPages, setSelectedPages] = useState<SelectedPage[]>([]);
+  const [instruction, setInstruction] = useState<string>(t('modal_ai_assistant.default_instruction'));
+
+
+  // Effects
+  useEffect(() => {
+    if (shouldEdit) {
+      setName(aiAssistant.name);
+      setDescription(aiAssistant.description);
+      setInstruction(aiAssistant.additionalInstruction);
+      setSelectedShareScope(aiAssistant.shareScope);
+      setSelectedAccessScope(aiAssistant.accessScope);
+      setSelectedUserGroupsForShareScope(convertToPopulatedGrantedGroups(aiAssistant.grantedGroupsForShareScope ?? []));
+      setSelectedUserGroupsForAccessScope(convertToPopulatedGrantedGroups(aiAssistant.grantedGroupsForAccessScope ?? []));
+    }
+  // eslint-disable-next-line max-len
+  }, [aiAssistant?.accessScope, aiAssistant?.additionalInstruction, aiAssistant?.description, aiAssistant?.grantedGroupsForAccessScope, aiAssistant?.grantedGroupsForShareScope, aiAssistant?.name, aiAssistant?.pagePathPatterns, aiAssistant?.shareScope, shouldEdit]);
+
+  useEffect(() => {
+    if (shouldEdit && pagePathsWithDescendantCount != null) {
+      setSelectedPages(convertToSelectedPages(aiAssistant.pagePathPatterns, pagePathsWithDescendantCount));
+    }
+  }, [aiAssistant?.pagePathPatterns, pagePathsWithDescendantCount, shouldEdit]);
+
+
+  /*
+  *  For AiAssistantManagementHome methods
+  */
+  const changeNameHandler = useCallback((value: string) => {
+    setName(value);
+  }, []);
+
+  const changeDescriptionHandler = useCallback((value: string) => {
+    setDescription(value);
+  }, []);
+
+  const upsertAiAssistantHandler = 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 = selectedShareScope === AiAssistantShareScope.GROUPS
+        ? convertToGrantedGroups(selectedUserGroupsForShareScope)
+        : undefined;
+
+      const grantedGroupsForAccessScope = selectedAccessScope === AiAssistantAccessScope.GROUPS
+        ? convertToGrantedGroups(selectedUserGroupsForAccessScope)
+        : undefined;
+
+      const reqBody: UpsertAiAssistantData = {
+        name,
+        description,
+        additionalInstruction: instruction,
+        pagePathPatterns,
+        shareScope: selectedShareScope,
+        accessScope: selectedAccessScope,
+        grantedGroupsForShareScope,
+        grantedGroupsForAccessScope,
+      };
+
+      if (shouldEdit) {
+        await updateAiAssistant(aiAssistant._id, reqBody);
+      }
+      else {
+        await createAiAssistant(reqBody);
+      }
+
+      toastSuccess(shouldEdit ? t('modal_ai_assistant.toaster.update_success') : t('modal_ai_assistant.toaster.create_success'));
+      mutateAiAssistants();
+      closeAiAssistantManagementModal();
+    }
+    catch (err) {
+      toastError(shouldEdit ? t('modal_ai_assistant.toaster.update_failed') : t('modal_ai_assistant.toaster.create_failed'));
+      logger.error(err);
+    }
+  // eslint-disable-next-line max-len
+  }, [t, selectedPages, selectedShareScope, selectedUserGroupsForShareScope, selectedAccessScope, selectedUserGroupsForAccessScope, name, description, instruction, shouldEdit, aiAssistant?._id, mutateAiAssistants, closeAiAssistantManagementModal]);
+
+
+  /*
+  *  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.path);
+    if (page.path != null && !selectedPageIds.includes(page.path)) {
+      setSelectedPages([...selectedPages, { page, isIncludeSubPage }]);
+    }
+  }, [selectedPages]);
+
+  const removePageHandler = useCallback((pagePath: string) => {
+    setSelectedPages(selectedPages.filter(selectedPage => selectedPage.page.path !== pagePath));
+  }, [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
+            shouldEdit={shouldEdit}
+            name={name}
+            description={description}
+            shareScope={selectedShareScope}
+            accessScope={selectedAccessScope}
+            instruction={instruction}
+            selectedPages={selectedPages}
+            selectedUserGroupsForShareScope={selectedUserGroupsForShareScope}
+            selectedUserGroupsForAccessScope={selectedUserGroupsForAccessScope}
+            onNameChange={changeNameHandler}
+            onDescriptionChange={changeDescriptionHandler}
+            onUpsertAiAssistant={upsertAiAssistantHandler}
+          />
+        </TabPane>
+
+        <TabPane tabId={AiAssistantManagementModalPageMode.SHARE}>
+          <AiAssistantManagementEditShare
+            selectedShareScope={selectedShareScope}
+            selectedAccessScope={selectedAccessScope}
+            selectedUserGroupsForShareScope={selectedUserGroupsForShareScope}
+            selectedUserGroupsForAccessScope={selectedUserGroupsForAccessScope}
+            onSelectShareScope={selectShareScopeHandler}
+            onSelectAccessScope={selectAccessScopeHandler}
+            onSelectAccessScopeUserGroups={selectAccessScopeUserGroups}
+            onSelectShareScopeUserGroups={selectShareScopeUserGroups}
+          />
+        </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>
+    </>
+  );
+};
+
+
+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>
+  );
+};

+ 74 - 0
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: 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 (
+    <ModalBody className="d-flex flex-column">
+      {userRelatedGroups != null && userRelatedGroups.map(userGroup => (
+        <button
+          className="btn btn-outline-primary d-flex justify-content-start mb-3 mx-4 align-items-center p-3"
+          type="button"
+          key={userGroup.item._id}
+          onClick={() => onSelect(userGroup)}
+        >
+          <input type="checkbox" checked={checked(userGroup)} onChange={() => {}} />
+          <p className="ms-3 mb-0">{userGroup.item.name}</p>
+          {userGroup.type === GroupType.externalUserGroup && <span className="ms-2 badge badge-pill badge-info">{userGroup.item.provider}</span>}
+          {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
+        </button>
+      ))}
+      <button
+        type="button"
+        className="btn btn-primary mt-2 mx-auto"
+        onClick={closeModal}
+      >
+        {t('Done')}
+      </button>
+
+    </ModalBody>
+  );
+};
+
+export const SelectUserGroupModal: React.FC<Props> = (props) => {
+  const { t } = useTranslation();
+
+  const { isOpen, closeModal } = props;
+
+  return (
+    <Modal isOpen={isOpen} toggle={closeModal}>
+      <ModalHeader toggle={closeModal}>
+        {t('user_group.select_group')}
+      </ModalHeader>
+      <SelectUserGroupModalSubstance {...props} />
+    </Modal>
+  );
+};

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

@@ -0,0 +1,43 @@
+import { memo } from 'react';
+
+import type { SelectedPage } from '../../../../interfaces/selected-page';
+
+type SelectedPageListProps = {
+  selectedPages: SelectedPage[];
+  onRemove?: (pagePath?: string) => void;
+};
+
+const SelectedPageListBase: React.FC<SelectedPageListProps> = ({ selectedPages, onRemove }: SelectedPageListProps) => {
+  if (selectedPages.length === 0) {
+    return <></>;
+  }
+
+  return (
+    <div className="mb-3">
+      {selectedPages.map(({ page, isIncludeSubPage }) => (
+        <div
+          key={page.path}
+          className="mb-2 d-flex justify-content-between align-items-center bg-body-tertiary rounded py-2 px-3"
+        >
+          <div className="d-flex align-items-center overflow-hidden text-body">
+            { isIncludeSubPage
+              ? <>{`${page.path}/*`}</>
+              : <>{page.path}</>
+            }
+          </div>
+          {onRemove != null && page.path != null && (
+            <button
+              type="button"
+              className="btn p-0 ms-3 text-body-secondary"
+              onClick={() => onRemove(page.path)}
+            >
+              <span className="material-symbols-outlined fs-4">delete</span>
+            </button>
+          )}
+        </div>
+      ))}
+    </div>
+  );
+};
+
+export const SelectedPageList = memo(SelectedPageListBase);

+ 52 - 0
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: Props) => {
+  const {
+    isDisabled,
+    isDisabledGroups,
+    selectedShareScope,
+    onSelect,
+  } = props;
+
+  const { t } = useTranslation();
+
+  return (
+    <div className="mb-4">
+      <Label className="text-secondary mb-3">{t('modal_ai_assistant.share_scope.title')}</Label>
+      <div className="d-flex flex-column gap-3">
+
+        {[AiAssistantShareScope.PUBLIC_ONLY, AiAssistantShareScope.GROUPS, AiAssistantShareScope.SAME_AS_ACCESS_SCOPE].map(shareScope => (
+          <FormGroup check key={shareScope}>
+            <Input
+              type="radio"
+              name="shareScope"
+              id="shareGroup"
+              className="form-check-input"
+              disabled={isDisabled || (isDisabledGroups && shareScope === AiAssistantShareScope.GROUPS)}
+              onChange={() => onSelect(shareScope)}
+              checked={selectedShareScope === shareScope}
+            />
+            <Label check for="shareGroup" className="d-flex flex-column">
+              <span>{t(`modal_ai_assistant.share_scope.${shareScope}.label`)}</span>
+              <small className="text-secondary">{t(`modal_ai_assistant.share_scope.${shareScope}.desc`)}</small>
+            </Label>
+          </FormGroup>
+        ))}
+      </div>
+    </div>
+  );
+};

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

@@ -0,0 +1,81 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import type { SelectedPage } from '../../../../interfaces/selected-page';
+
+type Props = {
+  isOpen: boolean,
+  selectedPages: SelectedPage[],
+  closeModal: () => void,
+  onSubmit: () => Promise<void>,
+}
+
+export const ShareScopeWarningModal = (props: Props): JSX.Element => {
+  const {
+    isOpen,
+    selectedPages,
+    closeModal,
+    onSubmit,
+  } = props;
+
+  const { t } = useTranslation();
+
+  const upsertAiAssistantHandler = useCallback(() => {
+    closeModal();
+    onSubmit();
+  }, [closeModal, onSubmit]);
+
+  return (
+    <Modal size="lg" isOpen={isOpen} toggle={closeModal}>
+      <ModalHeader toggle={closeModal}>
+        <div className="d-flex align-items-center">
+          <span className="material-symbols-outlined text-warning me-2 fs-4">warning</span>
+          <span className="text-warning fw-bold">{t('share_scope_warning_modal.header_title')}</span>
+        </div>
+      </ModalHeader>
+
+      <ModalBody className="py-4 px-4">
+        <p
+          className="mb-4"
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{ __html: t('share_scope_warning_modal.warning_message') }}
+        />
+
+        <div className="mb-4">
+          <p className="mb-2 text-secondary">{t('share_scope_warning_modal.selected_pages_label')}</p>
+          {selectedPages.map(selectedPage => (
+            <code key={selectedPage.page.path}>
+              {selectedPage.page.path}
+            </code>
+          ))}
+        </div>
+
+        <p>
+          {t('share_scope_warning_modal.confirmation_message')}
+        </p>
+      </ModalBody>
+
+      <ModalFooter>
+        <button
+          type="button"
+          className="btn btn-outline-secondary"
+          onClick={closeModal}
+        >
+          {t('share_scope_warning_modal.button.review')}
+        </button>
+
+        <button
+          type="button"
+          className="btn btn-warning"
+          onClick={upsertAiAssistantHandler}
+        >
+          {t('share_scope_warning_modal.button.proceed')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};

+ 2 - 2
apps/app/src/features/openai/client/components/RagSearchButton.module.scss → apps/app/src/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton.module.scss

@@ -3,11 +3,11 @@
 @use '@growi/ui/scss/atoms/btn-muted';
 @use '~/client/components/PageControls/button-styles';
 
-.btn-rag-search :global {
+.btn-open-default-ai-assistant :global {
   @extend %btn-basis;
 }
 
 // == Colors
-.btn-rag-search {
+.btn-open-default-ai-assistant {
   @include btn-muted.colorize(bs.$purple);
 }

+ 55 - 0
apps/app/src/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton.tsx

@@ -0,0 +1,55 @@
+import React, { useCallback, useMemo } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { NotAvailable } from '~/client/components/NotAvailable';
+import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
+import { useIsAiEnabled } from '~/stores-universal/context';
+
+import { useAiAssistantChatSidebar, useSWRxAiAssistants } from '../../stores/ai-assistant';
+
+import styles from './OpenDefaultAiAssistantButton.module.scss';
+
+const OpenDefaultAiAssistantButton = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: isAiEnabled } = useIsAiEnabled();
+  const { data: aiAssistantData } = useSWRxAiAssistants();
+  const { open: openAiAssistantChatSidebar } = useAiAssistantChatSidebar();
+
+  const defaultAiAssistant = useMemo(() => {
+    if (aiAssistantData == null) {
+      return null;
+    }
+
+    const allAiAssistants = [...aiAssistantData.myAiAssistants, ...aiAssistantData.teamAiAssistants];
+    return allAiAssistants.find(aiAssistant => aiAssistant.isDefault);
+  }, [aiAssistantData]);
+
+  const openDefaultAiAssistantButtonClickHandler = useCallback(() => {
+    if (defaultAiAssistant == null) {
+      return;
+    }
+
+    openAiAssistantChatSidebar(defaultAiAssistant);
+  }, [defaultAiAssistant, openAiAssistantChatSidebar]);
+
+  if (!isAiEnabled) {
+    return <></>;
+  }
+
+  return (
+    <NotAvailableForGuest>
+      <NotAvailable isDisabled={defaultAiAssistant == null} title={t('default_ai_assistant.not_set')}>
+        <button
+          type="button"
+          className={`btn btn-search ${styles['btn-open-default-ai-assistant']}`}
+          onClick={openDefaultAiAssistantButtonClickHandler}
+        >
+          <span className="growi-custom-icons fs-4 align-middle lh-1">ai_assistant</span>
+        </button>
+      </NotAvailable>
+    </NotAvailableForGuest>
+  );
+};
+
+export default OpenDefaultAiAssistantButton;

+ 37 - 0
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 (
+    <div className="px-3">
+      <div className="grw-sidebar-content-header py-4 d-flex">
+        <h3 className="fs-6 fw-bold mb-0">
+          {t('Knowledge Assistant')}
+        </h3>
+      </div>
+
+      { isGuestUser
+        ? (
+          <h4 className="fs-6">
+            { t('Not available for guest') }
+          </h4>
+        )
+        : (
+          <Suspense fallback={<ItemsTreeContentSkeleton />}>
+            <AiAssistantContent />
+          </Suspense>
+        )
+      }
+    </div>
+  );
+};

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

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

@@ -0,0 +1,57 @@
+import React from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+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 { t } = useTranslation();
+  const { open } = useAiAssistantManagementModal();
+  const { data: aiAssistants, mutate: mutateAiAssistants } = useSWRxAiAssistants();
+
+  return (
+    <div className={moduleClass}>
+      <button
+        type="button"
+        className="btn btn-outline-secondary px-3 d-flex align-items-center mb-4"
+        onClick={() => open()}
+      >
+        <span className="material-symbols-outlined fs-5 me-2">add</span>
+        <span className="fw-normal">{t('ai_assistant_tree.add_assistant')}</span>
+      </button>
+
+      <div className="d-flex flex-column gap-4">
+        <div>
+          <h3 className="fw-bold grw-ai-assistant-substance-header">
+            {t('ai_assistant_tree.my_assistants')}
+          </h3>
+          {aiAssistants?.myAiAssistants != null && aiAssistants.myAiAssistants.length !== 0 && (
+            <AiAssistantTree
+              onUpdated={mutateAiAssistants}
+              onDeleted={mutateAiAssistants}
+              aiAssistants={aiAssistants.myAiAssistants}
+            />
+          )}
+        </div>
+
+        <div>
+          <h3 className="fw-bold grw-ai-assistant-substance-header">
+            {t('ai_assistant_tree.team_assistants')}
+          </h3>
+          {aiAssistants?.teamAiAssistants != null && aiAssistants.teamAiAssistants.length !== 0 && (
+            <AiAssistantTree
+              onUpdated={mutateAiAssistants}
+              aiAssistants={aiAssistants.teamAiAssistants}
+            />
+          )}
+        </div>
+      </div>
+    </div>
+  );
+};

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

+ 319 - 0
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx

@@ -0,0 +1,319 @@
+import React, { useCallback, useState } from 'react';
+
+import type { IUserHasId } from '@growi/core';
+import { getIdStringForRef } from '@growi/core';
+import { useTranslation } from 'react-i18next';
+
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import type { IThreadRelationHasId } from '~/features/openai/interfaces/thread-relation';
+import { useCurrentUser } from '~/stores-universal/context';
+import loggerFactory from '~/utils/logger';
+
+import type { AiAssistantAccessScope } from '../../../../interfaces/ai-assistant';
+import { AiAssistantShareScope, type AiAssistantHasId } from '../../../../interfaces/ai-assistant';
+import { determineShareScope } from '../../../../utils/determine-share-scope';
+import { deleteAiAssistant, setDefaultAiAssistant } from '../../../services/ai-assistant';
+import { deleteThread } from '../../../services/thread';
+import { useAiAssistantChatSidebar, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
+import { useSWRMUTxThreads, useSWRxThreads } from '../../../stores/thread';
+
+import styles from './AiAssistantTree.module.scss';
+
+const logger = loggerFactory('growi:openai:client:components:AiAssistantTree');
+
+const moduleClass = styles['ai-assistant-tree-item'] ?? '';
+
+
+/*
+*  ThreadItem
+*/
+type ThreadItemProps = {
+  threadData: IThreadRelationHasId
+  aiAssistantData: AiAssistantHasId;
+  onThreadClick: (aiAssistantData: AiAssistantHasId, threadData?: IThreadRelationHasId) => void;
+  onThreadDelete: () => void;
+};
+
+const ThreadItem: React.FC<ThreadItemProps> = ({
+  threadData, aiAssistantData, onThreadClick, onThreadDelete,
+}) => {
+  const { t } = useTranslation();
+
+  const deleteThreadHandler = useCallback(async() => {
+    try {
+      await deleteThread({ aiAssistantId: aiAssistantData._id, threadRelationId: threadData._id });
+      toastSuccess(t('ai_assistant_tree.toaster.thread_deleted_success'));
+      onThreadDelete();
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(t('ai_assistant_tree.toaster.thread_deleted_failed'));
+    }
+  }, [aiAssistantData._id, onThreadDelete, t, threadData._id]);
+
+  const openChatHandler = useCallback(() => {
+    onThreadClick(aiAssistantData, threadData);
+  }, [aiAssistantData, onThreadClick, threadData]);
+
+  return (
+    <li
+      role="button"
+      className="list-group-item list-group-item-action border-0 d-flex align-items-center rounded-1 ps-5"
+      onClick={(e) => {
+        e.stopPropagation();
+        openChatHandler();
+      }}
+    >
+      <div>
+        <span className="material-symbols-outlined fs-5">chat</span>
+      </div>
+
+      <div className="grw-ai-assistant-title-anchor ps-1">
+        <p className="text-truncate m-auto">{threadData?.title ?? 'Untitled thread'}</p>
+      </div>
+
+      <div className="grw-ai-assistant-actions opacity-0 d-flex justify-content-center ">
+        <button
+          type="button"
+          className="btn btn-link text-secondary p-0"
+          onClick={(e) => {
+            e.stopPropagation();
+            deleteThreadHandler();
+          }}
+        >
+          <span className="material-symbols-outlined fs-5">delete</span>
+        </button>
+      </div>
+    </li>
+  );
+};
+
+
+/*
+*  ThreadItems
+*/
+type ThreadItemsProps = {
+  aiAssistantData: AiAssistantHasId;
+  onThreadClick: (aiAssistantData: AiAssistantHasId, threadData?: IThreadRelationHasId) => void;
+  onThreadDelete: () => void;
+};
+
+const ThreadItems: React.FC<ThreadItemsProps> = ({ aiAssistantData, onThreadClick, onThreadDelete }) => {
+  const { t } = useTranslation();
+  const { data: threads } = useSWRxThreads(aiAssistantData._id);
+
+  if (threads == null || threads.length === 0) {
+    return <p className="text-secondary ms-5">{t('ai_assistant_tree.thread_does_not_exist')}</p>;
+  }
+
+  return (
+    <div className="grw-ai-assistant-item-children">
+      {threads.map(thread => (
+        <ThreadItem
+          key={thread._id}
+          threadData={thread}
+          aiAssistantData={aiAssistantData}
+          onThreadClick={onThreadClick}
+          onThreadDelete={onThreadDelete}
+        />
+      ))}
+    </div>
+  );
+};
+
+
+/*
+*  AiAssistantItem
+*/
+const getShareScopeIcon = (shareScope: AiAssistantShareScope, accessScope: AiAssistantAccessScope): string => {
+  const determinedSharedScope = determineShareScope(shareScope, accessScope);
+  switch (determinedSharedScope) {
+    case AiAssistantShareScope.OWNER:
+      return 'lock';
+    case AiAssistantShareScope.GROUPS:
+      return 'account_tree';
+    case AiAssistantShareScope.PUBLIC_ONLY:
+      return 'group';
+    case AiAssistantShareScope.SAME_AS_ACCESS_SCOPE:
+      return '';
+  }
+};
+
+type AiAssistantItemProps = {
+  currentUser?: IUserHasId | null;
+  aiAssistant: AiAssistantHasId;
+  onEditClick: (aiAssistantData: AiAssistantHasId) => void;
+  onItemClick: (aiAssistantData: AiAssistantHasId, threadData?: IThreadRelationHasId) => void;
+  onUpdated?: () => void;
+  onDeleted?: () => void;
+};
+
+const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
+  currentUser,
+  aiAssistant,
+  onEditClick,
+  onItemClick,
+  onUpdated,
+  onDeleted,
+}) => {
+  const [isThreadsOpened, setIsThreadsOpened] = useState(false);
+
+  const { t } = useTranslation();
+  const { trigger: mutateThreadData } = useSWRMUTxThreads(aiAssistant._id);
+
+  const openManagementModalHandler = useCallback((aiAssistantData: AiAssistantHasId) => {
+    onEditClick(aiAssistantData);
+  }, [onEditClick]);
+
+  const openChatHandler = useCallback((aiAssistantData: AiAssistantHasId) => {
+    onItemClick(aiAssistantData);
+  }, [onItemClick]);
+
+  const openThreadsHandler = useCallback(async() => {
+    mutateThreadData();
+    setIsThreadsOpened(toggle => !toggle);
+  }, [mutateThreadData]);
+
+  const setDefaultAiAssistantHandler = useCallback(async() => {
+    try {
+      await setDefaultAiAssistant(aiAssistant._id, !aiAssistant.isDefault);
+      onUpdated?.();
+      toastSuccess(t('ai_assistant_tree.toaster.ai_assistant_set_default_success'));
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(t('ai_assistant_tree.toaster.ai_assistant_set_default_failed'));
+    }
+  }, [aiAssistant._id, aiAssistant.isDefault, onUpdated, t]);
+
+  const deleteAiAssistantHandler = useCallback(async() => {
+    try {
+      await deleteAiAssistant(aiAssistant._id);
+      onDeleted?.();
+      toastSuccess('ai_assistant_tree.toaster.assistant_deleted_success');
+    }
+    catch (err) {
+      logger.error(err);
+      toastError('ai_assistant_tree.toaster.assistant_deleted');
+    }
+  }, [aiAssistant._id, onDeleted]);
+
+  const isOperable = currentUser?._id != null && getIdStringForRef(aiAssistant.owner) === currentUser._id;
+  const isPublicAiAssistantOperable = currentUser?.admin
+    && determineShareScope(aiAssistant.shareScope, aiAssistant.accessScope) === AiAssistantShareScope.PUBLIC_ONLY;
+
+  return (
+    <>
+      <li
+        onClick={(e) => {
+          e.stopPropagation();
+          openChatHandler(aiAssistant);
+        }}
+        role="button"
+        className="list-group-item list-group-item-action border-0 d-flex align-items-center rounded-1"
+      >
+        <div className="d-flex justify-content-center">
+          <button
+            type="button"
+            onClick={(e) => {
+              e.stopPropagation();
+              openThreadsHandler();
+            }}
+            className={`grw-ai-assistant-triangle-btn btn px-0 ${isThreadsOpened ? 'grw-ai-assistant-open' : ''}`}
+          >
+            <div className="d-flex justify-content-center">
+              <span className="material-symbols-outlined fs-5">arrow_right</span>
+            </div>
+          </button>
+        </div>
+
+        <div className="d-flex justify-content-center">
+          <span className="material-symbols-outlined fs-5">{getShareScopeIcon(aiAssistant.shareScope, aiAssistant.accessScope)}</span>
+        </div>
+
+        <div className="grw-ai-assistant-title-anchor ps-1">
+          <p className="text-truncate m-auto">{aiAssistant.name}</p>
+        </div>
+
+        <div className="grw-ai-assistant-actions opacity-0 d-flex justify-content-center ">
+          {isPublicAiAssistantOperable && (
+            <button
+              type="button"
+              className="btn btn-link text-secondary p-0"
+              onClick={(e) => {
+                e.stopPropagation();
+                setDefaultAiAssistantHandler();
+              }}
+            >
+              <span className={`material-symbols-outlined fs-5 ${aiAssistant.isDefault ? 'fill' : ''}`}>star</span>
+            </button>
+          )}
+          {isOperable && (
+            <>
+              <button
+                type="button"
+                className="btn btn-link text-secondary p-0"
+                onClick={(e) => {
+                  e.stopPropagation();
+                  openManagementModalHandler(aiAssistant);
+                }}
+              >
+                <span className="material-symbols-outlined fs-5">edit</span>
+              </button>
+              <button
+                type="button"
+                className="btn btn-link text-secondary p-0"
+                onClick={(e) => {
+                  e.stopPropagation();
+                  deleteAiAssistantHandler();
+                }}
+              >
+                <span className="material-symbols-outlined fs-5">delete</span>
+              </button>
+            </>
+          )}
+        </div>
+      </li>
+
+      { isThreadsOpened && (
+        <ThreadItems
+          aiAssistantData={aiAssistant}
+          onThreadClick={onItemClick}
+          onThreadDelete={mutateThreadData}
+        />
+      ) }
+    </>
+  );
+};
+
+
+/*
+*  AiAssistantTree
+*/
+type AiAssistantTreeProps = {
+  aiAssistants: AiAssistantHasId[];
+  onUpdated?: () => void;
+  onDeleted?: () => void;
+};
+
+export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants, onUpdated, onDeleted }) => {
+  const { data: currentUser } = useCurrentUser();
+  const { open: openAiAssistantChatSidebar } = useAiAssistantChatSidebar();
+  const { open: openAiAssistantManagementModal } = useAiAssistantManagementModal();
+
+  return (
+    <ul className={`list-group ${moduleClass}`}>
+      {aiAssistants.map(assistant => (
+        <AiAssistantItem
+          key={assistant._id}
+          currentUser={currentUser}
+          aiAssistant={assistant}
+          onEditClick={openAiAssistantManagementModal}
+          onItemClick={openAiAssistantChatSidebar}
+          onUpdated={onUpdated}
+          onDeleted={onDeleted}
+        />
+      ))}
+    </ul>
+  );
+};

+ 0 - 31
apps/app/src/features/openai/client/components/AiIntegration/AiIntegration.tsx

@@ -1,45 +1,14 @@
-import { useCallback } from 'react';
-
 import { useTranslation } from 'react-i18next';
 
-import { apiv3Post } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
-
 
 export const AiIntegration = (): JSX.Element => {
   const { t } = useTranslation('admin');
 
-  const clickRebuildVectorStoreButtonHandler = useCallback(async() => {
-    try {
-      toastSuccess(t('ai_integration.rebuild_vector_store_requested'));
-      await apiv3Post('/openai/rebuild-vector-store');
-    }
-    catch {
-      toastError(t('ai_integration.rebuild_vector_store_failed'));
-    }
-  }, [t]);
-
   return (
     <div data-testid="admin-ai-integration">
       <h2 className="admin-setting-header">{ t('ai_integration.ai_search_management') }</h2>
 
       <div className="row">
-        <label className="col-md-3 col-form-label text-start text-md-end">{ t('ai_integration.rebuild_vector_store_label') }</label>
-        <div className="col-md-8">
-          {/* TODO: https://redmine.weseek.co.jp/issues/153978 */}
-          <button
-            type="submit"
-            className="btn btn-primary"
-            onClick={clickRebuildVectorStoreButtonHandler}
-          >
-            {t('ai_integration.rebuild_vector_store')}
-          </button>
-
-          <p className="form-text text-muted">
-            {t('ai_integration.rebuild_vector_store_explanation1')}<br />
-            {t('ai_integration.rebuild_vector_store_explanation2')}<br />
-          </p>
-        </div>
       </div>
     </div>
   );

+ 0 - 36
apps/app/src/features/openai/client/components/RagSearchButton.tsx

@@ -1,36 +0,0 @@
-import React, { useCallback } from 'react';
-
-import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
-import { useIsAiEnabled } from '~/stores-universal/context';
-
-import { useRagSearchModal } from '../stores/rag-search';
-
-import styles from './RagSearchButton.module.scss';
-
-const RagSearchButton = (): JSX.Element => {
-  const { data: isAiEnabled } = useIsAiEnabled();
-  const { open: openRagSearchModal } = useRagSearchModal();
-
-  const ragSearchButtonClickHandler = useCallback(() => {
-    openRagSearchModal();
-  }, [openRagSearchModal]);
-
-  if (!isAiEnabled) {
-    return <></>;
-  }
-
-  return (
-    <NotAvailableForGuest>
-      <button
-        type="button"
-        className={`btn btn-search ${styles['btn-rag-search']}`}
-        onClick={ragSearchButtonClickHandler}
-        data-testid="open-search-modal-button"
-      >
-        <span className="growi-custom-icons fs-4 align-middle lh-1">knowledge_assistant</span>
-      </button>
-    </NotAvailableForGuest>
-  );
-};
-
-export default RagSearchButton;

+ 19 - 0
apps/app/src/features/openai/client/services/ai-assistant.ts

@@ -0,0 +1,19 @@
+import { apiv3Post, apiv3Put, apiv3Delete } from '~/client/util/apiv3-client';
+
+import type { UpsertAiAssistantData } from '../../interfaces/ai-assistant';
+
+export const createAiAssistant = async(body: UpsertAiAssistantData): Promise<void> => {
+  await apiv3Post('/openai/ai-assistant', body);
+};
+
+export const updateAiAssistant = async(id: string, body: UpsertAiAssistantData): Promise<void> => {
+  await apiv3Put(`/openai/ai-assistant/${id}`, body);
+};
+
+export const setDefaultAiAssistant = async(id: string, isDefault: boolean): Promise<void> => {
+  await apiv3Put(`/openai/ai-assistant/${id}/set-default`, { isDefault });
+};
+
+export const deleteAiAssistant = async(id: string): Promise<void> => {
+  await apiv3Delete(`/openai/ai-assistant/${id}`);
+};

+ 7 - 0
apps/app/src/features/openai/client/services/thread.ts

@@ -0,0 +1,7 @@
+import { apiv3Delete } from '~/client/util/apiv3-client';
+
+import type { IApiv3DeleteThreadParams } from '../../interfaces/thread-relation';
+
+export const deleteThread = async(params: IApiv3DeleteThreadParams): Promise<void> => {
+  await apiv3Delete(`/openai/thread/${params.aiAssistantId}/${params.threadRelationId}`);
+};

+ 87 - 0
apps/app/src/features/openai/client/stores/ai-assistant.tsx

@@ -0,0 +1,87 @@
+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';
+import type { IThreadRelationHasId } from '../../interfaces/thread-relation';
+
+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,
+  aiAssistantData?: AiAssistantHasId;
+}
+
+type AiAssistantManagementModalUtils = {
+  open(aiAssistantData?: AiAssistantHasId): void
+  close(): void
+  changePageMode(pageType: AiAssistantManagementModalPageMode): void
+}
+
+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((aiAssistantData) => { swrResponse.mutate({ isOpened: true, aiAssistantData }) }, [swrResponse]),
+    close: useCallback(() => swrResponse.mutate({ isOpened: false, aiAssistantData: undefined }), [swrResponse]),
+    changePageMode: useCallback((pageMode: AiAssistantManagementModalPageMode) => {
+      swrResponse.mutate({ isOpened: swrResponse.data?.isOpened ?? false, pageMode, aiAssistantData: swrResponse.data?.aiAssistantData });
+    }, [swrResponse]),
+  };
+};
+
+
+export const useSWRxAiAssistants = (): SWRResponse<AccessibleAiAssistantsHasId, Error> => {
+  return useSWRImmutable<AccessibleAiAssistantsHasId>(
+    ['/openai/ai-assistants'],
+    ([endpoint]) => apiv3Get(endpoint).then(response => response.data.accessibleAiAssistants),
+  );
+};
+
+
+type AiAssistantChatSidebarStatus = {
+  isOpened: boolean,
+  aiAssistantData?: AiAssistantHasId,
+  threadData?: IThreadRelationHasId,
+}
+
+type AiAssistantChatSidebarUtils = {
+  open(
+    aiAssistantData: AiAssistantHasId,
+    threadData?: IThreadRelationHasId,
+  ): void
+  close(): void
+}
+
+export const useAiAssistantChatSidebar = (
+    status?: AiAssistantChatSidebarStatus,
+): SWRResponse<AiAssistantChatSidebarStatus, Error> & AiAssistantChatSidebarUtils => {
+  const initialStatus = { isOpened: false };
+  const swrResponse = useSWRStatic<AiAssistantChatSidebarStatus, Error>('AiAssistantChatSidebar', status, { fallbackData: initialStatus });
+
+  return {
+    ...swrResponse,
+    open: useCallback(
+      (aiAssistantData: AiAssistantHasId, threadData: IThreadRelationHasId) => {
+        swrResponse.mutate({ isOpened: true, aiAssistantData, threadData });
+      }, [swrResponse],
+    ),
+    close: useCallback(() => swrResponse.mutate({ isOpened: false }), [swrResponse]),
+  };
+};

+ 13 - 0
apps/app/src/features/openai/client/stores/message.tsx

@@ -0,0 +1,13 @@
+import useSWRMutation, { type SWRMutationResponse } from 'swr/mutation';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+
+import type { MessageWithCustomMetaData } from '../../interfaces/message';
+
+export const useSWRMUTxMessages = (aiAssistantId: string, threadId?: string): SWRMutationResponse<MessageWithCustomMetaData | null> => {
+  const key = threadId != null ? [`/openai/messages/${aiAssistantId}/${threadId}`] : null;
+  return useSWRMutation(
+    key,
+    ([endpoint]) => apiv3Get(endpoint).then(response => response.data.messages),
+  );
+};

+ 0 - 26
apps/app/src/features/openai/client/stores/rag-search.ts

@@ -1,26 +0,0 @@
-import { useCallback } from 'react';
-
-import { useSWRStatic } from '@growi/core/dist/swr';
-import type { SWRResponse } from 'swr';
-
-
-type RagSearchMoldalStatus = {
-  isOpened: boolean,
-}
-
-type RagSearchUtils = {
-  open(): void
-  close(): void
-}
-export const useRagSearchModal = (status?: RagSearchMoldalStatus): SWRResponse<RagSearchMoldalStatus, Error> & RagSearchUtils => {
-  const initialStatus = { isOpened: false };
-  const swrResponse = useSWRStatic<RagSearchMoldalStatus, Error>('RagSearchModal', status, { fallbackData: initialStatus });
-
-  return {
-    ...swrResponse,
-    open: useCallback(() => {
-      swrResponse.mutate({ isOpened: true });
-    }, [swrResponse]),
-    close: useCallback(() => swrResponse.mutate({ isOpened: false }), [swrResponse]),
-  };
-};

+ 26 - 0
apps/app/src/features/openai/client/stores/thread.tsx

@@ -0,0 +1,26 @@
+import { type SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
+import useSWRMutation, { type SWRMutationResponse } from 'swr/mutation';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+
+import type { IThreadRelationHasId } from '../../interfaces/thread-relation';
+
+const getKey = (aiAssistantId: string) => [`/openai/threads/${aiAssistantId}`];
+
+export const useSWRxThreads = (aiAssistantId: string): SWRResponse<IThreadRelationHasId[], Error> => {
+  const key = getKey(aiAssistantId);
+  return useSWRImmutable<IThreadRelationHasId[]>(
+    key,
+    ([endpoint]) => apiv3Get(endpoint).then(response => response.data.threads),
+  );
+};
+
+
+export const useSWRMUTxThreads = (aiAssistantId: string): SWRMutationResponse<IThreadRelationHasId[], Error> => {
+  const key = getKey(aiAssistantId);
+  return useSWRMutation(
+    key,
+    ([endpoint]) => apiv3Get(endpoint).then(response => response.data.threads),
+  );
+};

+ 55 - 0
apps/app/src/features/openai/interfaces/ai-assistant.ts

@@ -0,0 +1,55 @@
+import type {
+  IGrantedGroup, IUserHasId, Ref, HasObjectId,
+} from '@growi/core';
+
+import type { IVectorStore } from './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<IVectorStore>
+  owner: Ref<IUserHasId>
+  grantedGroupsForShareScope?: IGrantedGroup[]
+  grantedGroupsForAccessScope?: IGrantedGroup[]
+  shareScope: AiAssistantShareScope
+  accessScope: AiAssistantAccessScope
+  isDefault: boolean
+}
+
+export type AiAssistantHasId = AiAssistant & HasObjectId
+
+export type UpsertAiAssistantData = Omit<AiAssistant, 'owner' | 'vectorStore' | 'isDefault'>
+
+export type AccessibleAiAssistants = {
+  myAiAssistants: AiAssistant[],
+  teamAiAssistants: AiAssistant[],
+}
+
+export type AccessibleAiAssistantsHasId = {
+  myAiAssistants: AiAssistantHasId[],
+  teamAiAssistants: AiAssistantHasId[],
+}

+ 13 - 0
apps/app/src/features/openai/interfaces/message.ts

@@ -0,0 +1,13 @@
+import type OpenAI from 'openai';
+
+export const shouldHideMessageKey = 'shouldHideMessage';
+
+export type MessageWithCustomMetaData = Omit<OpenAI.Beta.Threads.Messages.MessagesPage, 'data'> & {
+  data: Array<OpenAI.Beta.Threads.Message & {
+    metadata?: {
+      shouldHideMessage?: 'true' | 'false',
+    }
+  }>;
+};
+
+export type MessageListParams = OpenAI.Beta.Threads.Messages.MessageListParams;

+ 6 - 0
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,
+}

+ 18 - 0
apps/app/src/features/openai/interfaces/thread-relation.ts

@@ -0,0 +1,18 @@
+import type { IUser, Ref, HasObjectId } from '@growi/core';
+
+import type { AiAssistant } from './ai-assistant';
+
+export interface IThreadRelation {
+  userId: Ref<IUser>
+  aiAssistant: Ref<AiAssistant>
+  threadId: string;
+  title?: string;
+  expiredAt: Date;
+}
+
+export type IThreadRelationHasId = IThreadRelation & HasObjectId;
+
+export type IApiv3DeleteThreadParams = {
+  aiAssistantId: string
+  threadRelationId: string;
+}

+ 4 - 0
apps/app/src/features/openai/interfaces/vector-store.ts

@@ -0,0 +1,4 @@
+export interface IVectorStore {
+  vectorStoreId: string
+  isDeleted: boolean
+}

+ 145 - 0
apps/app/src/features/openai/server/models/ai-assistant.ts

@@ -0,0 +1,145 @@
+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 {}
+
+interface AiAssistantModel extends Model<AiAssistantDocument> {
+  setDefault(id: string, isDefault: boolean): Promise<AiAssistantDocument>;
+}
+
+/*
+ * Schema Definition
+ */
+const schema = new Schema<AiAssistantDocument>(
+  {
+    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: 'grantedGroupsForShareScope.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: 'grantedGroupsForAccessScope.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,
+    },
+    isDefault: {
+      type: Boolean,
+      required: true,
+      default: false,
+    },
+  },
+  {
+    timestamps: true,
+  },
+);
+
+
+schema.statics.setDefault = async function(id: string, isDefault: boolean): Promise<AiAssistantDocument> {
+  if (isDefault) {
+    await this.bulkWrite([
+      {
+        updateOne: {
+          filter: {
+            _id: id,
+            shareScope:  AiAssistantShareScope.PUBLIC_ONLY,
+          },
+          update: { $set: { isDefault: true } },
+        },
+      },
+      {
+        updateMany: {
+          filter: {
+            _id: { $ne: id },
+            isDefault: true,
+          },
+          update: { $set: { isDefault: false } },
+        },
+      },
+    ]);
+  }
+  else {
+    await this.findByIdAndUpdate(id, { isDefault: false });
+  }
+
+  const updatedAiAssistant = await this.findById(id);
+  return updatedAiAssistant;
+};
+
+
+export default getOrCreateModel<AiAssistantDocument, AiAssistantModel>('AiAssistant', schema);

+ 11 - 8
apps/app/src/features/openai/server/models/thread-relation.ts

@@ -1,22 +1,17 @@
 import { addDays } from 'date-fns';
-import type mongoose from 'mongoose';
 import { type Model, type Document, Schema } from 'mongoose';
 
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
+import type { IThreadRelation } from '../../interfaces/thread-relation';
+
 const DAYS_UNTIL_EXPIRATION = 3;
 
 const generateExpirationDate = (): Date => {
   return addDays(new Date(), DAYS_UNTIL_EXPIRATION);
 };
 
-interface ThreadRelation {
-  userId: mongoose.Types.ObjectId;
-  threadId: string;
-  expiredAt: Date;
-}
-
-interface ThreadRelationDocument extends ThreadRelation, Document {
+export interface ThreadRelationDocument extends IThreadRelation, Document {
   updateThreadExpiration(): Promise<void>;
 }
 
@@ -30,11 +25,19 @@ const schema = new Schema<ThreadRelationDocument, ThreadRelationModel>({
     ref: 'User',
     required: true,
   },
+  aiAssistant: {
+    type: Schema.Types.ObjectId,
+    ref: 'AiAssistant',
+    required: true,
+  },
   threadId: {
     type: String,
     required: true,
     unique: true,
   },
+  title: {
+    type: String,
+  },
   expiredAt: {
     type: Date,
     default: generateExpirationDate,

+ 3 - 19
apps/app/src/features/openai/server/models/vector-store.ts

@@ -2,24 +2,13 @@ import { type Model, type Document, Schema } from 'mongoose';
 
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
-export const VectorStoreScopeType = {
-  PUBLIC: 'public',
-} as const;
+import type { IVectorStore } from '../../interfaces/vector-store';
 
-export type VectorStoreScopeType = typeof VectorStoreScopeType[keyof typeof VectorStoreScopeType];
-
-const VectorStoreScopeTypes = Object.values(VectorStoreScopeType);
-interface VectorStore {
-  vectorStoreId: string
-  scopeType: VectorStoreScopeType
-  isDeleted: boolean
-}
-
-export interface VectorStoreDocument extends VectorStore, Document {
+export interface VectorStoreDocument extends IVectorStore, Document {
   markAsDeleted(): Promise<void>
 }
 
-type VectorStoreModel = Model<VectorStore>
+type VectorStoreModel = Model<VectorStoreDocument>;
 
 const schema = new Schema<VectorStoreDocument, VectorStoreModel>({
   vectorStoreId: {
@@ -27,11 +16,6 @@ const schema = new Schema<VectorStoreDocument, VectorStoreModel>({
     required: true,
     unique: true,
   },
-  scopeType: {
-    enum: VectorStoreScopeTypes,
-    type: String,
-    required: true,
-  },
   isDeleted: {
     type: Boolean,
     default: false,

+ 56 - 0
apps/app/src/features/openai/server/routes/ai-assistant.ts

@@ -0,0 +1,56 @@
+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<undefined, Response, ReqBody> & {
+  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 isLearnablePageLimitExceeded = await openaiService.isLearnablePageLimitExceeded(req.user, aiAssistantData.pagePathPatterns);
+        if (isLearnablePageLimitExceeded) {
+          return res.apiv3Err(new ErrorV3('The number of learnable pages exceeds the limit'), 400);
+        }
+
+        const aiAssistant = await openaiService.createAiAssistant(req.body, req.user);
+
+        return res.apiv3({ aiAssistant });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3('AiAssistant creation failed'));
+      }
+    },
+  ];
+};

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

+ 59 - 0
apps/app/src/features/openai/server/routes/delete-ai-assistant.ts

@@ -0,0 +1,59 @@
+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 { deleteAiAssistant } from '../services/delete-ai-assistant';
+
+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<ReqParams, Response, undefined> & {
+  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;
+
+      try {
+        const deletedAiAssistant = await 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'));
+      }
+    },
+  ];
+};

+ 68 - 0
apps/app/src/features/openai/server/routes/delete-thread.ts

@@ -0,0 +1,68 @@
+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 { IApiv3DeleteThreadParams } from '../../interfaces/thread-relation';
+import { getOpenaiService } from '../services/openai';
+
+import { certifyAiService } from './middlewares/certify-ai-service';
+
+const logger = loggerFactory('growi:routes:apiv3:openai:delete-thread');
+
+type DeleteThreadFactory = (crowi: Crowi) => RequestHandler[];
+
+type ReqParams = IApiv3DeleteThreadParams;
+
+type Req = Request<ReqParams, Response, undefined> & {
+  user: IUserHasId,
+}
+
+export const deleteThreadFactory: DeleteThreadFactory = (crowi) => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+
+  const validator: ValidationChain[] = [
+    param('aiAssistantId').isMongoId().withMessage('threadId is required'),
+    param('threadRelationId').isMongoId().withMessage('threadRelationId is required'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const { aiAssistantId, threadRelationId } = req.params;
+      const { user } = req;
+
+      const openaiService = getOpenaiService();
+      if (openaiService == null) {
+        return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
+      }
+
+      const isAiAssistantUsable = openaiService.isAiAssistantUsable(aiAssistantId, user);
+      if (!isAiAssistantUsable) {
+        return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
+      }
+
+      try {
+        const deletedThreadRelation = await openaiService.deleteThread(threadRelationId);
+        return res.apiv3({ deletedThreadRelation });
+      }
+      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 thread'));
+      }
+    },
+  ];
+};

+ 73 - 0
apps/app/src/features/openai/server/routes/get-messages.ts

@@ -0,0 +1,73 @@
+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 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:get-message');
+
+type GetMessagesFactory = (crowi: Crowi) => RequestHandler[];
+
+type ReqParam = {
+  threadId: string,
+  aiAssistantId: string,
+  before?: string,
+  after?: string,
+  limit?: number,
+}
+
+type Req = Request<ReqParam, Response, undefined> & {
+  user: IUserHasId,
+}
+
+export const getMessagesFactory: GetMessagesFactory = (crowi) => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+
+  const validator: ValidationChain[] = [
+    param('threadId').isString().withMessage('threadId must be string'),
+    param('aiAssistantId').isMongoId().withMessage('aiAssistantId must be string'),
+    param('limit').optional().isInt().withMessage('limit must be integer'),
+    param('before').optional().isString().withMessage('before must be string'),
+    param('after').optional().isString().withMessage('after must be string'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, 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 {
+          threadId, aiAssistantId, limit, before, after,
+        } = req.params;
+
+        const isAiAssistantUsable = await openaiService.isAiAssistantUsable(aiAssistantId, req.user);
+        if (!isAiAssistantUsable) {
+          return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
+        }
+
+        const messages = await openaiService.getMessageData(threadId, req.user.lang, {
+          limit, before, after, order: 'desc',
+        });
+
+        return res.apiv3({ messages });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3('Failed to get messages'));
+      }
+    },
+  ];
+};

+ 61 - 0
apps/app/src/features/openai/server/routes/get-threads.ts

@@ -0,0 +1,61 @@
+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 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:get-threads');
+
+type GetThreadsFactory = (crowi: Crowi) => RequestHandler[];
+
+type ReqParams = {
+  aiAssistantId: string,
+}
+
+type Req = Request<ReqParams, Response, undefined> & {
+  user: IUserHasId,
+}
+
+export const getThreadsFactory: GetThreadsFactory = (crowi) => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+
+  const validator: ValidationChain[] = [
+    param('aiAssistantId').isMongoId().withMessage('aiAssistantId must be string'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, 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 { aiAssistantId } = req.params;
+
+        const isAiAssistantUsable = openaiService.isAiAssistantUsable(aiAssistantId, req.user);
+        if (!isAiAssistantUsable) {
+          return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
+        }
+
+        const threads = await openaiService.getThreadsByAiAssistantId(aiAssistantId);
+
+        return res.apiv3({ threads });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3('Failed to get threads'));
+      }
+    },
+  ];
+};

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

@@ -19,17 +19,45 @@ export const factory = (crowi: Crowi): express.Router => {
   }
   // enabled
   else {
-    import('./rebuild-vector-store').then(({ rebuildVectorStoreHandlersFactory }) => {
-      router.post('/rebuild-vector-store', rebuildVectorStoreHandlersFactory(crowi));
-    });
-
     import('./thread').then(({ createThreadHandlersFactory }) => {
       router.post('/thread', createThreadHandlersFactory(crowi));
     });
 
+    import('./get-threads').then(({ getThreadsFactory }) => {
+      router.get('/threads/:aiAssistantId', getThreadsFactory(crowi));
+    });
+
+    import('./delete-thread').then(({ deleteThreadFactory }) => {
+      router.delete('/thread/:aiAssistantId/:threadRelationId', deleteThreadFactory(crowi));
+    });
+
     import('./message').then(({ postMessageHandlersFactory }) => {
       router.post('/message', postMessageHandlersFactory(crowi));
     });
+
+    import('./get-messages').then(({ getMessagesFactory }) => {
+      router.get('/messages/:aiAssistantId/:threadId', getMessagesFactory(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('./set-default-ai-assistant').then(({ setDefaultAiAssistantFactory }) => {
+      router.put('/ai-assistant/:id/set-default', setDefaultAiAssistantFactory(crowi));
+    });
+
+    import('./delete-ai-assistant').then(({ deleteAiAssistantsFactory }) => {
+      router.delete('/ai-assistant/:id', deleteAiAssistantsFactory(crowi));
+    });
   }
 
   return router;

+ 33 - 3
apps/app/src/features/openai/server/routes/message.ts

@@ -13,9 +13,13 @@ import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
+import { shouldHideMessageKey } from '../../interfaces/message';
 import { MessageErrorCode, type StreamErrorCode } from '../../interfaces/message-error';
+import AiAssistantModel from '../models/ai-assistant';
+import ThreadRelationModel from '../models/thread-relation';
 import { openaiClient } from '../services/client';
 import { getStreamErrorCode } from '../services/getStreamErrorCode';
+import { getOpenaiService } from '../services/openai';
 import { replaceAnnotationWithPageLink } from '../services/replace-annotation-with-page-link';
 
 import { certifyAiService } from './middlewares/certify-ai-service';
@@ -25,6 +29,7 @@ const logger = loggerFactory('growi:routes:apiv3:openai:message');
 
 type ReqBody = {
   userMessage: string,
+  aiAssistantId: string,
   threadId?: string,
   summaryMode?: boolean,
 }
@@ -44,26 +49,47 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) =>
       .withMessage('userMessage must be string')
       .notEmpty()
       .withMessage('userMessage must be set'),
+    body('aiAssistantId').isMongoId().withMessage('aiAssistantId must be string'),
     body('threadId').optional().isString().withMessage('threadId must be string'),
   ];
 
   return [
     accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
-
-      const threadId = req.body.threadId;
+      const { aiAssistantId, threadId } = req.body;
 
       if (threadId == null) {
         return res.apiv3Err(new ErrorV3('threadId is not set', MessageErrorCode.THREAD_ID_IS_NOT_SET), 400);
       }
 
+      const openaiService = getOpenaiService();
+      if (openaiService == null) {
+        return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
+      }
+
+      const isAiAssistantUsable = await openaiService.isAiAssistantUsable(aiAssistantId, req.user);
+      if (!isAiAssistantUsable) {
+        return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
+      }
+
+      const aiAssistant = await AiAssistantModel.findById(aiAssistantId);
+      if (aiAssistant == null) {
+        return res.apiv3Err(new ErrorV3('AI assistant not found'), 404);
+      }
+
+      const threadRelation = await ThreadRelationModel.findOne({ threadId });
+      if (threadRelation == null) {
+        return res.apiv3Err(new ErrorV3('ThreadRelation not found'), 404);
+      }
+
+      threadRelation.updateThreadExpiration();
+
       let stream: AssistantStream;
 
       try {
         const assistant = await getOrCreateChatAssistant();
 
         const thread = await openaiClient.beta.threads.retrieve(threadId);
-
         stream = openaiClient.beta.threads.runs.stream(thread.id, {
           assistant_id: assistant.id,
           additional_messages: [
@@ -72,9 +98,13 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) =>
               content: req.body.summaryMode
                 ? 'Turn on summary mode: I will try to answer concisely, aiming for 1-3 sentences.'
                 : 'I will turn off summary mode and answer.',
+              metadata: {
+                [shouldHideMessageKey]: 'true',
+              },
             },
             { role: 'user', content: req.body.userMessage },
           ],
+          additional_instructions: aiAssistant.additionalInstruction,
         });
 
       }

+ 86 - 0
apps/app/src/features/openai/server/routes/middlewares/upsert-ai-assistant-validator.ts

@@ -0,0 +1,86 @@
+import { GroupType } from '@growi/core';
+import { isGlobPatternPath, 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'),
+
+  body('description')
+    .optional()
+    .isString()
+    .withMessage('description must be a string'),
+
+  body('additionalInstruction')
+    .optional()
+    .isString()
+    .withMessage('additionalInstruction must be a string'),
+
+  body('pagePathPatterns')
+    .isArray()
+    .withMessage('pagePathPatterns must be an array of strings')
+    .not()
+    .isEmpty()
+    .withMessage('pagePathPatterns must not be empty')
+    .custom((pagePathPattens: string[]) => {
+      if (pagePathPattens.length > 300) {
+        throw new Error('pagePathPattens must be an array of strings with a maximum length of 300');
+      }
+
+      return true;
+    }),
+
+  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 glob pattern path
+      if (value.includes('*')) {
+        return isGlobPatternPath(value) && isCreatablePage(value.replaceAll('*', ''));
+      }
+
+      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'),
+];

+ 0 - 43
apps/app/src/features/openai/server/routes/rebuild-vector-store.ts

@@ -1,43 +0,0 @@
-import { ErrorV3 } from '@growi/core/dist/models';
-import type { Request, RequestHandler } from 'express';
-import type { ValidationChain } from 'express-validator';
-
-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:rebuild-vector-store');
-
-type RebuildVectorStoreFactory = (crowi: Crowi) => RequestHandler[];
-
-export const rebuildVectorStoreHandlersFactory: RebuildVectorStoreFactory = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
-  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
-
-  const validator: ValidationChain[] = [
-    //
-  ];
-
-  return [
-    accessTokenParser, loginRequiredStrictly, adminRequired, certifyAiService, validator, apiV3FormValidator,
-    async(req: Request, res: ApiV3Response) => {
-
-      try {
-        const openaiService = getOpenaiService();
-        await openaiService?.rebuildVectorStoreAll();
-        return res.apiv3({});
-
-      }
-      catch (err) {
-        logger.error(err);
-        return res.apiv3Err(new ErrorV3('Vector Store rebuild failed'));
-      }
-    },
-  ];
-};

+ 66 - 0
apps/app/src/features/openai/server/routes/set-default-ai-assistant.ts

@@ -0,0 +1,66 @@
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import { type ValidationChain, param, body } 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 AiAssistantModel from '../models/ai-assistant';
+import { getOpenaiService } from '../services/openai';
+
+import { certifyAiService } from './middlewares/certify-ai-service';
+
+const logger = loggerFactory('growi:routes:apiv3:openai:set-default-ai-assistants');
+
+type setDefaultAiAssistantFactory = (crowi: Crowi) => RequestHandler[];
+
+type ReqParams = {
+  id: string,
+}
+
+type ReqBody = {
+  isDefault: boolean,
+}
+
+type Req = Request<ReqParams, Response, ReqBody>
+
+export const setDefaultAiAssistantFactory: setDefaultAiAssistantFactory = (crowi) => {
+  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+
+  const validator: ValidationChain[] = [
+    param('id').isMongoId().withMessage('aiAssistant id is required'),
+    body('isDefault').isBoolean().withMessage('isDefault is required'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly, adminRequired, certifyAiService, validator, 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 { id } = req.params;
+        const { isDefault } = req.body;
+
+        const updatedAiAssistant = await AiAssistantModel.setDefault(id, isDefault);
+        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 AiAssistant'));
+      }
+    },
+  ];
+};

+ 25 - 8
apps/app/src/features/openai/server/routes/thread.ts

@@ -1,8 +1,8 @@
 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';
-import { filterXSS } from 'xss';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
@@ -16,7 +16,12 @@ import { certifyAiService } from './middlewares/certify-ai-service';
 
 const logger = loggerFactory('growi:routes:apiv3:openai:thread');
 
-type CreateThreadReq = Request<undefined, ApiV3Response, { threadId?: string }> & { user: IUserHasId };
+type ReqBody = {
+  aiAssistantId: string,
+  initialUserMessage: string,
+}
+
+type CreateThreadReq = Request<undefined, ApiV3Response, ReqBody> & { user: IUserHasId };
 
 type CreateThreadFactory = (crowi: Crowi) => RequestHandler[];
 
@@ -24,18 +29,30 @@ export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
 
   const validator: ValidationChain[] = [
-    body('threadId').optional().isString().withMessage('threadId must be string'),
+    body('aiAssistantId').isMongoId().withMessage('aiAssistantId must be string'),
+    body('initialUserMessage').isString().withMessage('initialUserMessage must be string'),
   ];
 
   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 { aiAssistantId, initialUserMessage } = req.body;
+
+        const isAiAssistantUsable = await openaiService.isAiAssistantUsable(aiAssistantId, req.user);
+        if (!isAiAssistantUsable) {
+          return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
+        }
+
+        const thread = await openaiService.createThread(req.user._id, aiAssistantId, initialUserMessage);
+
+        return res.apiv3(thread);
       }
       catch (err) {
         logger.error(err);

+ 73 - 0
apps/app/src/features/openai/server/routes/update-ai-assistant.ts

@@ -0,0 +1,73 @@
+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<ReqParams, Response, ReqBody> & {
+  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 isLearnablePageLimitExceeded = await openaiService.isLearnablePageLimitExceeded(user, req.body.pagePathPatterns);
+        if (isLearnablePageLimitExceeded) {
+          return res.apiv3Err(new ErrorV3('The number of learnable pages exceeds the limit'), 400);
+        }
+
+        const updatedAiAssistant = await openaiService.updateAiAssistant(id, req.body, user);
+
+        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'));
+      }
+    },
+  ];
+};

+ 27 - 3
apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts

@@ -3,7 +3,8 @@ 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 { MessageListParams } from '../../../interfaces/message';
+
 
 import type { IOpenaiClientDelegator } from './interfaces';
 
@@ -32,6 +33,16 @@ export class AzureOpenaiClientDelegator implements IOpenaiClientDelegator {
     });
   }
 
+  async updateThread(threadId: string, vectorStoreId: string): Promise<OpenAI.Beta.Threads.Thread> {
+    return this.client.beta.threads.update(threadId, {
+      tool_resources: {
+        file_search: {
+          vector_store_ids: [vectorStoreId],
+        },
+      },
+    });
+  }
+
   async retrieveThread(threadId: string): Promise<OpenAI.Beta.Threads.Thread> {
     return this.client.beta.threads.retrieve(threadId);
   }
@@ -40,8 +51,17 @@ export class AzureOpenaiClientDelegator implements IOpenaiClientDelegator {
     return this.client.beta.threads.del(threadId);
   }
 
-  async createVectorStore(scopeType:VectorStoreScopeType): Promise<OpenAI.Beta.VectorStores.VectorStore> {
-    return this.client.beta.vectorStores.create({ name: `growi-vector-store-{${scopeType}` });
+  async getMessages(threadId: string, options?: MessageListParams): Promise<OpenAI.Beta.Threads.Messages.MessagesPage> {
+    return this.client.beta.threads.messages.list(threadId, {
+      order: options?.order,
+      limit: options?.limit,
+      before: options?.before,
+      after: options?.after,
+    });
+  }
+
+  async createVectorStore(name: string): Promise<OpenAI.Beta.VectorStores.VectorStore> {
+    return this.client.beta.vectorStores.create({ name: `growi-vector-store-for-${name}` });
   }
 
   async retrieveVectorStore(vectorStoreId: string): Promise<OpenAI.Beta.VectorStores.VectorStore> {
@@ -68,4 +88,8 @@ export class AzureOpenaiClientDelegator implements IOpenaiClientDelegator {
     return this.client.beta.vectorStores.fileBatches.uploadAndPoll(vectorStoreId, { files });
   }
 
+  async chatCompletion(body: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming): Promise<OpenAI.Chat.Completions.ChatCompletion> {
+    return this.client.chat.completions.create(body);
+  }
+
 }

+ 5 - 2
apps/app/src/features/openai/server/services/client-delegator/interfaces.ts

@@ -1,16 +1,19 @@
 import type OpenAI from 'openai';
 import type { Uploadable } from 'openai/uploads';
 
-import type { VectorStoreScopeType } from '~/features/openai/server/models/vector-store';
+import type { MessageListParams } from '../../../interfaces/message';
 
 export interface IOpenaiClientDelegator {
   createThread(vectorStoreId: string): Promise<OpenAI.Beta.Threads.Thread>
+  updateThread(threadId: string, vectorStoreId: string): Promise<OpenAI.Beta.Threads.Thread>
   retrieveThread(threadId: string): Promise<OpenAI.Beta.Threads.Thread>
   deleteThread(threadId: string): Promise<OpenAI.Beta.Threads.ThreadDeleted>
+  getMessages(threadId: string, options?: MessageListParams): Promise<OpenAI.Beta.Threads.Messages.MessagesPage>
   retrieveVectorStore(vectorStoreId: string): Promise<OpenAI.Beta.VectorStores.VectorStore>
-  createVectorStore(scopeType:VectorStoreScopeType): Promise<OpenAI.Beta.VectorStores.VectorStore>
+  createVectorStore(name: string): Promise<OpenAI.Beta.VectorStores.VectorStore>
   deleteVectorStore(vectorStoreId: string): Promise<OpenAI.Beta.VectorStores.VectorStoreDeleted>
   uploadFile(file: Uploadable): Promise<OpenAI.Files.FileObject>
   createVectorStoreFileBatch(vectorStoreId: string, fileIds: string[]): Promise<OpenAI.Beta.VectorStores.FileBatches.VectorStoreFileBatch>
   deleteFile(fileId: string): Promise<OpenAI.Files.FileDeleted>;
+  chatCompletion(body: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming): Promise<OpenAI.Chat.Completions.ChatCompletion>
 }

+ 27 - 4
apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts

@@ -1,11 +1,11 @@
 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';
+import type { MessageListParams } from '../../../interfaces/message';
 
+import type { IOpenaiClientDelegator } from './interfaces';
 
 export class OpenaiClientDelegator implements IOpenaiClientDelegator {
 
@@ -38,12 +38,31 @@ export class OpenaiClientDelegator implements IOpenaiClientDelegator {
     return this.client.beta.threads.retrieve(threadId);
   }
 
+  async updateThread(threadId: string, vectorStoreId: string): Promise<OpenAI.Beta.Threads.Thread> {
+    return this.client.beta.threads.update(threadId, {
+      tool_resources: {
+        file_search: {
+          vector_store_ids: [vectorStoreId],
+        },
+      },
+    });
+  }
+
   async deleteThread(threadId: string): Promise<OpenAI.Beta.Threads.ThreadDeleted> {
     return this.client.beta.threads.del(threadId);
   }
 
-  async createVectorStore(scopeType:VectorStoreScopeType): Promise<OpenAI.Beta.VectorStores.VectorStore> {
-    return this.client.beta.vectorStores.create({ name: `growi-vector-store-${scopeType}` });
+  async getMessages(threadId: string, options?: MessageListParams): Promise<OpenAI.Beta.Threads.Messages.MessagesPage> {
+    return this.client.beta.threads.messages.list(threadId, {
+      order: options?.order,
+      limit: options?.limit,
+      before: options?.before,
+      after: options?.after,
+    });
+  }
+
+  async createVectorStore(name: string): Promise<OpenAI.Beta.VectorStores.VectorStore> {
+    return this.client.beta.vectorStores.create({ name: `growi-vector-store-for-${name}` });
   }
 
   async retrieveVectorStore(vectorStoreId: string): Promise<OpenAI.Beta.VectorStores.VectorStore> {
@@ -70,4 +89,8 @@ export class OpenaiClientDelegator implements IOpenaiClientDelegator {
     return this.client.beta.vectorStores.fileBatches.uploadAndPoll(vectorStoreId, { files });
   }
 
+  async chatCompletion(body: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming): Promise<OpenAI.Chat.Completions.ChatCompletion> {
+    return this.client.chat.completions.create(body);
+  }
+
 }

+ 1 - 1
apps/app/src/features/openai/server/services/cron/vector-store-file-deletion-cron.ts

@@ -47,7 +47,7 @@ export class VectorStoreFileDeletionCronService {
   }
 
   private async executeJob(): Promise<void> {
-    await this.openaiService.deleteObsolatedVectorStoreRelations();
+    await this.openaiService.deleteObsoletedVectorStoreRelations();
     await this.openaiService.deleteObsoleteVectorStoreFile(this.vectorStoreFileDeletionBarchSize, this.vectorStoreFileDeletionApiCallInterval);
   }
 

+ 51 - 0
apps/app/src/features/openai/server/services/delete-ai-assistant.ts

@@ -0,0 +1,51 @@
+import {
+  getIdStringForRef, type IUserHasId,
+} from '@growi/core';
+import createError from 'http-errors';
+
+import loggerFactory from '~/utils/logger';
+
+import type { AiAssistantDocument } from '../models/ai-assistant';
+import AiAssistantModel from '../models/ai-assistant';
+
+import { isAiEnabled } from './is-ai-enabled';
+import { getOpenaiService } from './openai';
+
+const logger = loggerFactory('growi:service:openai:delete-ai-assistant');
+
+
+export const deleteAiAssistant = async(ownerId: string, aiAssistantId: string): Promise<AiAssistantDocument> => {
+  const openaiService = getOpenaiService();
+  if (openaiService == null) {
+    throw createError(500, 'openaiService is not initialized');
+  }
+
+  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 openaiService.deleteVectorStore(vectorStoreRelationId);
+
+  const deletedAiAssistant = await aiAssistant.remove();
+  return deletedAiAssistant;
+};
+
+export const deleteUserAiAssistant = async(user: IUserHasId): Promise<void> => {
+  if (isAiEnabled()) {
+    const aiAssistants = await AiAssistantModel.find({ owner: user });
+    for await (const aiAssistant of aiAssistants) {
+      try {
+        await deleteAiAssistant(user._id, aiAssistant._id);
+      }
+      catch (err) {
+        logger.error(`Failed to delete AiAssistant ${aiAssistant._id}`);
+      }
+    }
+  }
+
+  // Cannot delete OpenAI VectorStore entities without enabling openaiService.
+  // Delete OpenAI VectorStore entities through "deleteVectorStoresOrphanedFromAiAssistant" when app starts with openaiService enabled
+  await AiAssistantModel.deleteMany({ owner: user });
+};

+ 0 - 573
apps/app/src/features/openai/server/services/markdown-splitter/markdown-splitter.spec.ts

@@ -1,573 +0,0 @@
-import { encodingForModel, type TiktokenModel } from 'js-tiktoken';
-
-import { splitMarkdownIntoFragments, type MarkdownFragment } from './markdown-splitter';
-
-const MODEL: TiktokenModel = 'gpt-4';
-const encoder = encodingForModel(MODEL);
-
-describe('splitMarkdownIntoFragments', () => {
-
-  test('handles empty markdown string', async() => {
-    const markdown = '';
-    const expected: MarkdownFragment[] = [];
-    const result = await splitMarkdownIntoFragments(markdown, MODEL);
-    expect(result).toEqual(expected);
-  });
-
-  test('handles markdown with only content and no headers', async() => {
-    const markdown = `This is some content without any headers.
-It spans multiple lines.
-
-Another paragraph.
-    `;
-
-    const expected: MarkdownFragment[] = [
-      {
-        label: '0-content-1',
-        type: 'paragraph',
-        text: 'This is some content without any headers.\nIt spans multiple lines.',
-        tokenCount: encoder.encode('This is some content without any headers.\nIt spans multiple lines.').length,
-      },
-      {
-        label: '0-content-2',
-        type: 'paragraph',
-        text: 'Another paragraph.',
-        tokenCount: encoder.encode('Another paragraph.').length,
-      },
-    ];
-
-    const result = await splitMarkdownIntoFragments(markdown, MODEL);
-    expect(result).toEqual(expected);
-  });
-
-  test('handles markdown starting with a header', async() => {
-    const markdown = `
-# Header 1
-Content under header 1.
-
-## Header 1.1
-Content under header 1.1.
-
-# Header 2
-Content under header 2.
-    `;
-
-    const expected: MarkdownFragment[] = [
-      {
-        label: '1-heading',
-        type: 'heading',
-        text: '# Header 1',
-        tokenCount: encoder.encode('# Header 1').length,
-      },
-      {
-        label: '1-content-1',
-        type: 'paragraph',
-        text: 'Content under header 1.',
-        tokenCount: encoder.encode('Content under header 1.').length,
-      },
-      {
-        label: '1-1-heading',
-        type: 'heading',
-        text: '## Header 1.1',
-        tokenCount: encoder.encode('## Header 1.1').length,
-      },
-      {
-        label: '1-1-content-1',
-        type: 'paragraph',
-        text: 'Content under header 1.1.',
-        tokenCount: encoder.encode('Content under header 1.1.').length,
-      },
-      {
-        label: '2-heading',
-        type: 'heading',
-        text: '# Header 2',
-        tokenCount: encoder.encode('# Header 2').length,
-      },
-      {
-        label: '2-content-1',
-        type: 'paragraph',
-        text: 'Content under header 2.',
-        tokenCount: encoder.encode('Content under header 2.').length,
-      },
-    ];
-
-    const result = await splitMarkdownIntoFragments(markdown, MODEL);
-    expect(result).toEqual(expected);
-  });
-
-  test('handles markdown with non-consecutive heading levels', async() => {
-    const markdown = `
-Introduction without a header.
-
-# Chapter 1
-Content of chapter 1.
-
-### Section 1.1.1
-Content of section 1.1.1.
-
-## Section 1.2
-Content of section 1.2.
-
-# Chapter 2
-Content of chapter 2.
-
-## Section 2.1
-Content of section 2.1.
-    `;
-
-    const expected: MarkdownFragment[] = [
-      {
-        label: '0-content-1',
-        type: 'paragraph',
-        text: 'Introduction without a header.',
-        tokenCount: encoder.encode('Introduction without a header.').length,
-      },
-      {
-        label: '1-heading',
-        type: 'heading',
-        text: '# Chapter 1',
-        tokenCount: encoder.encode('# Chapter 1').length,
-      },
-      {
-        label: '1-content-1',
-        type: 'paragraph',
-        text: 'Content of chapter 1.',
-        tokenCount: encoder.encode('Content of chapter 1.').length,
-      },
-      {
-        label: '1-1-1-heading',
-        type: 'heading',
-        text: '### Section 1.1.1',
-        tokenCount: encoder.encode('### Section 1.1.1').length,
-      },
-      {
-        label: '1-1-1-content-1',
-        type: 'paragraph',
-        text: 'Content of section 1.1.1.',
-        tokenCount: encoder.encode('Content of section 1.1.1.').length,
-      },
-      {
-        label: '1-2-heading',
-        type: 'heading',
-        text: '## Section 1.2',
-        tokenCount: encoder.encode('## Section 1.2').length,
-      },
-      {
-        label: '1-2-content-1',
-        type: 'paragraph',
-        text: 'Content of section 1.2.',
-        tokenCount: encoder.encode('Content of section 1.2.').length,
-      },
-      {
-        label: '2-heading',
-        type: 'heading',
-        text: '# Chapter 2',
-        tokenCount: encoder.encode('# Chapter 2').length,
-      },
-      {
-        label: '2-content-1',
-        type: 'paragraph',
-        text: 'Content of chapter 2.',
-        tokenCount: encoder.encode('Content of chapter 2.').length,
-      },
-      {
-        label: '2-1-heading',
-        type: 'heading',
-        text: '## Section 2.1',
-        tokenCount: encoder.encode('## Section 2.1').length,
-      },
-      {
-        label: '2-1-content-1',
-        type: 'paragraph',
-        text: 'Content of section 2.1.',
-        tokenCount: encoder.encode('Content of section 2.1.').length,
-      },
-    ];
-
-    const result = await splitMarkdownIntoFragments(markdown, MODEL);
-    expect(result).toEqual(expected);
-  });
-
-  test('handles markdown with skipped heading levels', async() => {
-    const markdown = `
-# Header 1
-Content under header 1.
-
-#### Header 1.1.1.1
-Content under header 1.1.1.1.
-
-## Header 1.2
-Content under header 1.2.
-
-# Header 2
-Content under header 2.
-    `;
-
-    const expected: MarkdownFragment[] = [
-      {
-        label: '1-heading',
-        type: 'heading',
-        text: '# Header 1',
-        tokenCount: encoder.encode('# Header 1').length,
-      },
-      {
-        label: '1-content-1',
-        type: 'paragraph',
-        text: 'Content under header 1.',
-        tokenCount: encoder.encode('Content under header 1.').length,
-      },
-      {
-        label: '1-1-1-1-heading',
-        type: 'heading',
-        text: '#### Header 1.1.1.1',
-        tokenCount: encoder.encode('#### Header 1.1.1.1').length,
-      },
-      {
-        label: '1-1-1-1-content-1',
-        type: 'paragraph',
-        text: 'Content under header 1.1.1.1.',
-        tokenCount: encoder.encode('Content under header 1.1.1.1.').length,
-      },
-      {
-        label: '1-2-heading',
-        type: 'heading',
-        text: '## Header 1.2',
-        tokenCount: encoder.encode('## Header 1.2').length,
-      },
-      {
-        label: '1-2-content-1',
-        type: 'paragraph',
-        text: 'Content under header 1.2.',
-        tokenCount: encoder.encode('Content under header 1.2.').length,
-      },
-      {
-        label: '2-heading',
-        type: 'heading',
-        text: '# Header 2',
-        tokenCount: encoder.encode('# Header 2').length,
-      },
-      {
-        label: '2-content-1',
-        type: 'paragraph',
-        text: 'Content under header 2.',
-        tokenCount: encoder.encode('Content under header 2.').length,
-      },
-    ];
-
-    const result = await splitMarkdownIntoFragments(markdown, MODEL);
-    expect(result).toEqual(expected);
-  });
-
-  test('handles malformed headings', async() => {
-    const markdown = `
-# Header 1
-Content under header 1.
-
-#### Header 1.1.1.1
-Content under header 1.1.1.1.
-    `;
-
-    const expected: MarkdownFragment[] = [
-      {
-        label: '1-heading',
-        type: 'heading',
-        text: '# Header 1',
-        tokenCount: encoder.encode('# Header 1').length,
-      },
-      {
-        label: '1-content-1',
-        type: 'paragraph',
-        text: 'Content under header 1.',
-        tokenCount: encoder.encode('Content under header 1.').length,
-      },
-      {
-        label: '1-1-1-1-heading',
-        type: 'heading',
-        text: '#### Header 1.1.1.1',
-        tokenCount: encoder.encode('#### Header 1.1.1.1').length,
-      },
-      {
-        label: '1-1-1-1-content-1',
-        type: 'paragraph',
-        text: 'Content under header 1.1.1.1.',
-        tokenCount: encoder.encode('Content under header 1.1.1.1.').length,
-      },
-    ];
-
-    const result = await splitMarkdownIntoFragments(markdown, MODEL);
-    expect(result).toEqual(expected);
-  });
-
-  test('handles multiple content blocks before any headers', async() => {
-    const markdown = `
-This is the first paragraph without a header.
-
-This is the second paragraph without a header.
-
-# Header 1
-Content under header 1.
-    `;
-
-    const expected: MarkdownFragment[] = [
-      {
-        label: '0-content-1',
-        type: 'paragraph',
-        text: 'This is the first paragraph without a header.',
-        tokenCount: encoder.encode('This is the first paragraph without a header.').length,
-      },
-      {
-        label: '0-content-2',
-        type: 'paragraph',
-        text: 'This is the second paragraph without a header.',
-        tokenCount: encoder.encode('This is the second paragraph without a header.').length,
-      },
-      {
-        label: '1-heading',
-        type: 'heading',
-        text: '# Header 1',
-        tokenCount: encoder.encode('# Header 1').length,
-      },
-      {
-        label: '1-content-1',
-        type: 'paragraph',
-        text: 'Content under header 1.',
-        tokenCount: encoder.encode('Content under header 1.').length,
-      },
-    ];
-
-    const result = await splitMarkdownIntoFragments(markdown, MODEL);
-    expect(result).toEqual(expected);
-  });
-
-  test('handles markdown with only headers and no content', async() => {
-    const markdown = `
-# Header 1
-
-## Header 1.1
-
-### Header 1.1.1
-    `;
-
-    const expected: MarkdownFragment[] = [
-      {
-        label: '1-heading',
-        type: 'heading',
-        text: '# Header 1',
-        tokenCount: encoder.encode('# Header 1').length,
-      },
-      {
-        label: '1-1-heading',
-        type: 'heading',
-        text: '## Header 1.1',
-        tokenCount: encoder.encode('## Header 1.1').length,
-      },
-      {
-        label: '1-1-1-heading',
-        type: 'heading',
-        text: '### Header 1.1.1',
-        tokenCount: encoder.encode('### Header 1.1.1').length,
-      },
-    ];
-
-    const result = await splitMarkdownIntoFragments(markdown, MODEL);
-    expect(result).toEqual(expected);
-  });
-
-  test('handles markdown with mixed content and headers', async() => {
-    const markdown = `
-# Header 1
-Content under header 1.
-
-## Header 1.1
-Content under header 1.1.
-Another piece of content.
-
-# Header 2
-Content under header 2.
-    `;
-
-    const expected: MarkdownFragment[] = [
-      {
-        label: '1-heading',
-        type: 'heading',
-        text: '# Header 1',
-        tokenCount: encoder.encode('# Header 1').length,
-      },
-      {
-        label: '1-content-1',
-        type: 'paragraph',
-        text: 'Content under header 1.',
-        tokenCount: encoder.encode('Content under header 1.').length,
-      },
-      {
-        label: '1-1-heading',
-        type: 'heading',
-        text: '## Header 1.1',
-        tokenCount: encoder.encode('## Header 1.1').length,
-      },
-      {
-        label: '1-1-content-1',
-        type: 'paragraph',
-        text: 'Content under header 1.1.\nAnother piece of content.',
-        tokenCount: encoder.encode('Content under header 1.1.\nAnother piece of content.').length,
-      },
-      {
-        label: '2-heading',
-        type: 'heading',
-        text: '# Header 2',
-        tokenCount: encoder.encode('# Header 2').length,
-      },
-      {
-        label: '2-content-1',
-        type: 'paragraph',
-        text: 'Content under header 2.',
-        tokenCount: encoder.encode('Content under header 2.').length,
-      },
-    ];
-
-    const result = await splitMarkdownIntoFragments(markdown, MODEL);
-    expect(result).toEqual(expected);
-  });
-
-  test('preserves list indentation and reduces unnecessary line breaks', async() => {
-    const markdown = `
-# Header 1
-Content under header 1.
-
-- Item 1
-  - Subitem 1
-- Item 2
-
-
-# Header 2
-Content under header 2.
-    `;
-
-    const expected: MarkdownFragment[] = [
-      {
-        label: '1-heading',
-        type: 'heading',
-        text: '# Header 1',
-        tokenCount: encoder.encode('# Header 1').length,
-      },
-      {
-        label: '1-content-1',
-        type: 'paragraph',
-        text: 'Content under header 1.',
-        tokenCount: encoder.encode('Content under header 1.').length,
-      },
-      {
-        label: '1-content-2',
-        type: 'list',
-        text: '- Item 1\n  - Subitem 1\n- Item 2',
-        tokenCount: encoder.encode('- Item 1\n  - Subitem 1\n- Item 2').length,
-      },
-      {
-        label: '2-heading',
-        type: 'heading',
-        text: '# Header 2',
-        tokenCount: encoder.encode('# Header 2').length,
-      },
-      {
-        label: '2-content-1',
-        type: 'paragraph',
-        text: 'Content under header 2.',
-        tokenCount: encoder.encode('Content under header 2.').length,
-      },
-    ];
-
-    const result = await splitMarkdownIntoFragments(markdown, MODEL);
-    expect(result).toEqual(expected);
-  });
-
-  test('code blocks containing # are not treated as headings', async() => {
-    const markdown = `
-# Header 1
-Some introductory content.
-\`\`\`
-# This is a comment with a # symbol
-Some code line
-\`\`\`
-Additional content.
-# Header 2
-Content under header 2.
-    `;
-
-    const expected: MarkdownFragment[] = [
-      {
-        label: '1-heading',
-        type: 'heading',
-        text: '# Header 1',
-        tokenCount: encoder.encode('# Header 1').length,
-      },
-      {
-        label: '1-content-1',
-        type: 'paragraph',
-        text: 'Some introductory content.',
-        tokenCount: encoder.encode('Some introductory content.').length,
-      },
-      {
-        label: '1-content-2',
-        type: 'code',
-        text: '```\n# This is a comment with a # symbol\nSome code line\n```',
-        tokenCount: encoder.encode('```\n# This is a comment with a # symbol\nSome code line\n```').length,
-      },
-      {
-        label: '1-content-3',
-        type: 'paragraph',
-        text: 'Additional content.',
-        tokenCount: encoder.encode('Additional content.').length,
-      },
-      {
-        label: '2-heading',
-        type: 'heading',
-        text: '# Header 2',
-        tokenCount: encoder.encode('# Header 2').length,
-      },
-      {
-        label: '2-content-1',
-        type: 'paragraph',
-        text: 'Content under header 2.',
-        tokenCount: encoder.encode('Content under header 2.').length,
-      },
-    ];
-
-    const result = await splitMarkdownIntoFragments(markdown, MODEL);
-    expect(result).toEqual(expected);
-  });
-
-  test('frontmatter is processed and labeled correctly', async() => {
-    const markdown = `---
-title: Test Document
-author: John Doe
----
-
-# Header 1
-Some introductory content.
-    `;
-
-    const expected: MarkdownFragment[] = [
-      {
-        label: 'frontmatter',
-        type: 'yaml',
-        text: JSON.stringify({ title: 'Test Document', author: 'John Doe' }, null, 2),
-        tokenCount: encoder.encode(JSON.stringify({ title: 'Test Document', author: 'John Doe' }, null, 2)).length,
-      },
-      {
-        label: '1-heading',
-        type: 'heading',
-        text: '# Header 1',
-        tokenCount: encoder.encode('# Header 1').length,
-      },
-      {
-        label: '1-content-1',
-        type: 'paragraph',
-        text: 'Some introductory content.',
-        tokenCount: encoder.encode('Some introductory content.').length,
-      },
-    ];
-
-    const result = await splitMarkdownIntoFragments(markdown, MODEL);
-    expect(result).toEqual(expected);
-  });
-});

+ 0 - 133
apps/app/src/features/openai/server/services/markdown-splitter/markdown-splitter.ts

@@ -1,133 +0,0 @@
-import { dynamicImport } from '@cspell/dynamic-import';
-import type { TiktokenModel } from 'js-tiktoken';
-import { encodingForModel } from 'js-tiktoken';
-import yaml from 'js-yaml';
-import type * as RemarkFrontmatter from 'remark-frontmatter';
-import type * as RemarkGfm from 'remark-gfm';
-import type * as RemarkParse from 'remark-parse';
-import type * as RemarkStringify from 'remark-stringify';
-import type * as Unified from 'unified';
-
-
-export type MarkdownFragment = {
-  label: string;
-  type: string;
-  text: string;
-  tokenCount: number;
-};
-
-/**
- * Updates the section numbers based on the heading depth and returns the updated section label.
- * Handles non-consecutive heading levels by initializing missing levels with 1.
- * @param sectionNumbers - The current section numbers.
- * @param headingDepth - The depth of the heading (e.g., # is depth 1).
- * @returns The updated section label.
- */
-function updateSectionNumbers(sectionNumbers: number[], headingDepth: number): string {
-  if (headingDepth > sectionNumbers.length) {
-    // Initialize missing levels with 1
-    while (sectionNumbers.length < headingDepth) {
-      sectionNumbers.push(1);
-    }
-  }
-  else if (headingDepth === sectionNumbers.length) {
-    // Increment the last number for the same level
-    sectionNumbers[headingDepth - 1]++;
-  }
-  else {
-    // Remove deeper levels and increment the current level
-    sectionNumbers.splice(headingDepth);
-    sectionNumbers[headingDepth - 1]++;
-  }
-  return sectionNumbers.join('-');
-}
-
-/**
- * Splits Markdown text into labeled markdownFragments using remark-parse and remark-stringify,
- * processing each content node separately and labeling them as 1-content-1, 1-content-2, etc.
- * @param markdownText - The input Markdown string.
- * @returns An array of labeled markdownFragments.
- */
-export async function splitMarkdownIntoFragments(markdownText: string, model: TiktokenModel): Promise<MarkdownFragment[]> {
-  const markdownFragments: MarkdownFragment[] = [];
-  const sectionNumbers: number[] = [];
-  let currentSectionLabel = '';
-  const contentCounters: Record<string, number> = {};
-
-  if (typeof markdownText !== 'string' || markdownText.trim() === '') {
-    return markdownFragments;
-  }
-
-  const encoder = encodingForModel(model);
-
-  const remarkParse = (await dynamicImport<typeof RemarkParse>('remark-parse', __dirname)).default;
-  const remarkFrontmatter = (await dynamicImport<typeof RemarkFrontmatter>('remark-frontmatter', __dirname)).default;
-  const remarkGfm = (await dynamicImport<typeof RemarkGfm>('remark-gfm', __dirname)).default;
-  const remarkStringify = (await dynamicImport<typeof RemarkStringify>('remark-stringify', __dirname)).default;
-  const unified = (await dynamicImport<typeof Unified>('unified', __dirname)).unified;
-
-  const parser = unified()
-    .use(remarkParse)
-    .use(remarkFrontmatter, ['yaml'])
-    .use(remarkGfm); // Enable GFM extensions
-
-  const stringifyOptions: RemarkStringify.Options = {
-    bullet: '-', // Set list bullet to hyphen
-    rule: '-', // Use hyphen for horizontal rules
-  };
-
-  const stringifier = unified()
-    .use(remarkFrontmatter, ['yaml'])
-    .use(remarkGfm)
-    .use(remarkStringify, stringifyOptions);
-
-  const parsedTree = parser.parse(markdownText);
-
-  // Iterate over top-level nodes to prevent duplication
-  for (const node of parsedTree.children) {
-    if (node.type === 'yaml') {
-      // Frontmatter block found, handle only the first instance
-      const frontmatter = yaml.load(node.value) as Record<string, unknown>;
-      const frontmatterText = JSON.stringify(frontmatter, null, 2);
-      const tokenCount = encoder.encode(frontmatterText).length;
-      markdownFragments.push({
-        label: 'frontmatter',
-        type: 'yaml',
-        text: frontmatterText,
-        tokenCount,
-      });
-    }
-    else if (node.type === 'heading') {
-      const headingDepth = node.depth;
-      currentSectionLabel = updateSectionNumbers(sectionNumbers, headingDepth);
-
-      const headingMarkdown = stringifier.stringify(node as any).trim(); // eslint-disable-line @typescript-eslint/no-explicit-any
-      const tokenCount = encoder.encode(headingMarkdown).length;
-      markdownFragments.push({
-        label: `${currentSectionLabel}-heading`, type: node.type, text: headingMarkdown, tokenCount,
-      });
-    }
-    else {
-      // Process non-heading content individually
-      const contentMarkdown = stringifier.stringify(node as any).trim(); // eslint-disable-line @typescript-eslint/no-explicit-any
-      if (contentMarkdown !== '') {
-        const contentCountKey = currentSectionLabel || '0';
-        if (!contentCounters[contentCountKey]) {
-          contentCounters[contentCountKey] = 1;
-        }
-        else {
-          contentCounters[contentCountKey]++;
-        }
-        const contentLabel = currentSectionLabel !== ''
-          ? `${currentSectionLabel}-content-${contentCounters[contentCountKey]}`
-          : `0-content-${contentCounters[contentCountKey]}`;
-        const tokenCount = encoder.encode(contentMarkdown).length;
-        markdownFragments.push({
-          label: contentLabel, type: node.type, text: contentMarkdown, tokenCount,
-        });
-      }
-    }
-  }
-
-  return markdownFragments;
-}

+ 0 - 134
apps/app/src/features/openai/server/services/markdown-splitter/markdown-token-splitter.spec.ts

@@ -1,134 +0,0 @@
-import type { TiktokenModel } from 'js-tiktoken';
-import { encodingForModel } from 'js-tiktoken';
-
-import { splitMarkdownIntoChunks } from './markdown-token-splitter';
-
-const MODEL: TiktokenModel = 'gpt-4';
-const encoder = encodingForModel(MODEL);
-
-describe('splitMarkdownIntoChunks', () => {
-  const repeatedText = 'This is a repeated sentence for testing purposes. '.repeat(100);
-  const markdown = `---
-title: Test Document
-author: John Doe
----
-
-${repeatedText}
-
-# Header 1
-
-This is the first paragraph under header 1. It contains some text to simulate a longer paragraph for testing.
-This paragraph is extended with more content to ensure proper chunking behavior.${repeatedText}
-
-## Header 1-1
-
-This is the first paragraph under header 1-1. The text is a bit longer to ensure proper chunking. More text follows.
-
-
-### Header 1-1-1
-
-This is the first paragraph under header 1-1-1. The content is nested deeper,
-making sure that the chunking algorithm works properly with multiple levels of headers.
-
-This is another paragraph under header 1-1-1, continuing the content at this deeper level.
-
-#### Header 1-1-1-1
-
-Now we have reached the fourth level of headers. The text here should also be properly chunked and grouped with its parent headers.
-
-This is another paragraph under header 1-1-1-1. It should be grouped with the correct higher-level headers.
-
-# Header 2
-
-Here is some content under header 2. This section should also be sufficiently long to ensure that the token count threshold is reached in the test.
-
-## Header 2-1
-
-${repeatedText}
-
-${repeatedText}
-
-Another sub-header under header 2 with text for testing chunking behavior. This is a fairly lengthy paragraph as well.
-
-We now have a fourth-level sub-header under header 2-1. This ensures that the chunking logic can handle deeply nested content.
-
-### Header 2-1-1
-
-Here is another paragraph under header 2-1-1. This paragraph is part of a more deeply nested section.
-
-# Header 3
-
-Continuing with more headers and content to make sure the markdown document is sufficiently large. This is a new header with more paragraphs under it.
-
-### Header 3-1
-
-This is a sub-header under header 3. The content here continues to grow, ensuring that the markdown is long enough to trigger multiple chunks.
-
-#### Header 3-1-1
-
-Here is a fourth-level sub-header under header 3-1. This paragraph is designed to create a larger markdown file for testing purposes.
-`;
-  test('Each chunk should not exceed the specified token count', async() => {
-    const maxToken = 800;
-    const result = await splitMarkdownIntoChunks(markdown, MODEL, maxToken);
-
-    result.forEach((chunk) => {
-      const tokenCount = encoder.encode(chunk).length;
-      expect(tokenCount).toBeLessThanOrEqual(maxToken * 1.1);
-    });
-  });
-  test('Each chunk should include the relevant top-level header', async() => {
-    const result = await splitMarkdownIntoChunks(markdown, MODEL, 800);
-
-    result.forEach((chunk) => {
-      const containsHeader1 = chunk.includes('# Header 1');
-      const containsHeader2 = chunk.includes('# Header 2');
-      const containsHeader3 = chunk.includes('# Header 3');
-      const doesNotContainHash = !chunk.includes('# ');
-
-      expect(containsHeader1 || containsHeader2 || containsHeader3 || doesNotContainHash).toBe(true);
-    });
-  });
-  test('Should throw an error if a header exceeds half of maxToken size with correct error message', async() => {
-    const maxToken = 800;
-    const markdownWithLongHeader = `
-# Short Header 1
-
-This is the first paragraph under short header 1. It contains some text for testing purposes.
-
-## ${repeatedText}
-
-This is the first paragraph under the long header. It contains text to ensure that the header length check is triggered if the header is too long.
-
-# Short Header 2
-
-Another section with a shorter header, but enough content to ensure proper chunking.
-`;
-
-    try {
-      await splitMarkdownIntoChunks(markdownWithLongHeader, MODEL, maxToken);
-    }
-    catch (error) {
-      if (error instanceof Error) {
-        expect(error.message).toContain('Heading token count is too large');
-      }
-      else {
-        throw new Error('An unknown error occurred');
-      }
-    }
-  });
-
-  test('Should return the entire markdown as a single chunk if token count is less than or equal to maxToken', async() => {
-    const markdownText = `
-    # Header 1
-    This is a short paragraph under header 1. It contains only a few sentences to ensure that the total token count remains under the maxToken limit.
-    `;
-
-    const maxToken = 800;
-
-    const result = await splitMarkdownIntoChunks(markdownText, MODEL, maxToken);
-
-    expect(result).toHaveLength(1);
-    expect(result[0]).toBe(markdownText);
-  });
-});

+ 0 - 188
apps/app/src/features/openai/server/services/markdown-splitter/markdown-token-splitter.ts

@@ -1,188 +0,0 @@
-import { encodingForModel, type TiktokenModel } from 'js-tiktoken';
-
-import { splitMarkdownIntoFragments, type MarkdownFragment } from './markdown-splitter';
-
-type MarkdownFragmentGroups = MarkdownFragment[][] ;
-
-function groupMarkdownFragments(
-    markdownFragments: MarkdownFragment[],
-    maxToken: number,
-): MarkdownFragmentGroups {
-
-  const prefixes = markdownFragments.map(({ label }) => {
-    if (label === 'frontmatter') return 'frontmatter';
-    const match = label.match(/^\d+(?:-\d+)*/)!; // eslint-disable-line @typescript-eslint/no-non-null-assertion
-    return match[0];
-  });
-
-  const uniquePrefixes = [...new Set(prefixes.filter(Boolean))];
-
-  // Group chunks by prefix
-  const fragmentGroupes: MarkdownFragmentGroups = [];
-  let remainingPrefixes = [...uniquePrefixes];
-
-  // Process chunks so that the total token count per level doesn't exceed maxToken
-  while (remainingPrefixes.length > 0) {
-    const prefix = remainingPrefixes[0]; // Get the first prefix
-    const hasNextLevelPrefix = uniquePrefixes.some(p => p !== prefix && p.startsWith(prefix));
-
-    if (!hasNextLevelPrefix) {
-      // If there is no prefix that starts with the current prefix, group the chunks directly
-      let matchingFragments = markdownFragments.filter(fragment => fragment.label.startsWith(prefix));
-
-      // Add parent heading if it exists
-      const parts = prefix.split('-');
-      for (let i = 1; i < parts.length; i++) {
-        const parentPrefix = parts.slice(0, i).join('-');
-        const parentHeading = markdownFragments.find(fragment => fragment.label === `${parentPrefix}-heading`);
-        if (parentHeading) {
-          matchingFragments = [parentHeading, ...matchingFragments]; // Add the heading at the front
-        }
-      }
-
-      fragmentGroupes.push(matchingFragments);
-    }
-    else {
-      // Filter chunks that start with the current prefix
-      let matchingFragments = markdownFragments.filter(fragment => fragment.label.startsWith(prefix));
-
-      // Add parent heading if it exists
-      const parts = prefix.split('-');
-      for (let i = 1; i < parts.length; i++) {
-        const parentPrefix = parts.slice(0, i).join('-');
-        const parentHeading = markdownFragments.find(fragment => fragment.label === `${parentPrefix}-heading`);
-        if (parentHeading) {
-          matchingFragments = [parentHeading, ...matchingFragments];
-        }
-      }
-
-      // Calculate total token count including parent headings
-      const totalTokenCount = matchingFragments.reduce((sum, fragment) => sum + fragment.tokenCount, 0);
-
-      // If the total token count doesn't exceed maxToken, group the chunks
-      if (totalTokenCount <= maxToken) {
-        fragmentGroupes.push(matchingFragments);
-        remainingPrefixes = remainingPrefixes.filter(p => !p.startsWith(`${prefix}-`));
-      }
-      else {
-        // If it exceeds maxToken, strictly filter chunks by the exact numeric prefix
-        const strictMatchingFragments = markdownFragments.filter((fragment) => {
-          const match = fragment.label.match(/^\d+(-\d+)*(?=-)/);
-          return match && match[0] === prefix;
-        });
-
-        // Add parent heading if it exists
-        for (let i = 1; i < parts.length; i++) {
-          const parentPrefix = parts.slice(0, i).join('-');
-          const parentHeading = markdownFragments.find(fragment => fragment.label === `${parentPrefix}-heading`);
-          if (parentHeading) {
-            strictMatchingFragments.unshift(parentHeading); // Add the heading at the front
-          }
-        }
-
-        fragmentGroupes.push(strictMatchingFragments);
-      }
-    }
-    remainingPrefixes.shift();
-  }
-
-  return fragmentGroupes;
-}
-
-// Function to group markdown into chunks based on token count
-export async function splitMarkdownIntoChunks(
-    markdownText: string,
-    model: TiktokenModel,
-    maxToken = 800,
-): Promise<string[]> {
-  const encoder = encodingForModel(model);
-
-  // If the total token count for the entire markdown text is less than or equal to maxToken,
-  // return the entire markdown as a single chunk.
-  if (encoder.encode(markdownText).length <= maxToken) {
-    return [markdownText];
-  }
-
-  // Split markdown text into chunks
-  const markdownFragments = await splitMarkdownIntoFragments(markdownText, model);
-  const chunks: string[] = [];
-
-  // Group the chunks based on token count
-  const fragmentGroupes = groupMarkdownFragments(markdownFragments, maxToken);
-
-  fragmentGroupes.forEach((fragmentGroupe) => {
-    // Calculate the total token count for each group
-    const totalTokenCount = fragmentGroupe.reduce((sum, fragment) => sum + fragment.tokenCount, 0);
-
-    // If the total token count doesn't exceed maxToken, combine the chunks into one
-    if (totalTokenCount <= maxToken) {
-      const chunk = fragmentGroupe.map((fragment, index) => {
-        const nextFragment = fragmentGroupe[index + 1];
-        if (nextFragment) {
-          // If both the current and next chunks are headings, add a single newline
-          if (fragment.type === 'heading' && nextFragment.type === 'heading') {
-            return `${fragment.text}\n`;
-          }
-          // Add two newlines for other cases
-          return `${fragment.text}\n\n`;
-        }
-        return fragment.text; // No newlines for the last chunk
-      }).join('');
-
-      chunks.push(chunk);
-    }
-    else {
-      // If the total token count exceeds maxToken, split content
-      const headingFragments = fragmentGroupe.filter(fragment => fragment.type === 'heading'); // Find all headings
-      const headingText = headingFragments.map(heading => heading.text).join('\n'); // Combine headings with one newline
-
-      for (const fragment of fragmentGroupe) {
-        if (fragment.label.includes('content')) {
-          // Combine heading and paragraph content
-          const combinedTokenCount = headingFragments.reduce((sum, heading) => sum + heading.tokenCount, 0) + fragment.tokenCount;
-          // Check if headingChunks alone exceed maxToken
-          const headingTokenCount = headingFragments.reduce((sum, heading) => sum + heading.tokenCount, 0);
-
-          if (headingTokenCount > maxToken / 2) {
-            throw new Error(
-              `Heading token count is too large. Heading token count: ${headingTokenCount}, allowed maximum: ${Math.ceil(maxToken / 2)}`,
-            );
-          }
-
-          // If the combined token count exceeds maxToken, split the content by character count
-          if (combinedTokenCount > maxToken) {
-            const headingTokenCount = headingFragments.reduce((sum, heading) => sum + heading.tokenCount, 0);
-            const remainingTokenCount = maxToken - headingTokenCount;
-
-            // Calculate the total character count and token count
-            const fragmentCharCount = fragment.text.length;
-            const fragmenTokenCount = fragment.tokenCount;
-
-            // Calculate the character count for splitting
-            const charCountForSplit = Math.floor((remainingTokenCount / fragmenTokenCount) * fragmentCharCount);
-
-            // Split content based on character count
-            const splitContents: string[] = [];
-            for (let i = 0; i < fragment.text.length; i += charCountForSplit) {
-              splitContents.push(fragment.text.slice(i, i + charCountForSplit));
-            }
-
-            // Add each split content to the new group of chunks
-            splitContents.forEach((splitText) => {
-              const chunk = headingText
-                ? `${headingText}\n\n${splitText}`
-                : `${splitText}`;
-              chunks.push(chunk);
-            });
-          }
-          else {
-            const chunk = `${headingText}\n\n${fragment.text}`;
-            chunks.push(chunk);
-          }
-        }
-      }
-    }
-  });
-
-  return chunks;
-}

+ 3 - 0
apps/app/src/features/openai/server/services/normalize-data/normalize-thread-relation-expired-at/normalize-thread-relation-expired-at.integ.ts

@@ -15,6 +15,7 @@ describe('normalizeExpiredAtForThreadRelations', () => {
     const threadRelation = new ThreadRelation({
       userId: new Types.ObjectId(),
       threadId: 'test-thread',
+      aiAssistant: new Types.ObjectId(),
       expiredAt: expiredDate,
     });
     await threadRelation.save();
@@ -36,6 +37,7 @@ describe('normalizeExpiredAtForThreadRelations', () => {
     const threadRelation = new ThreadRelation({
       userId: new Types.ObjectId(),
       threadId: 'test-thread-2',
+      aiAssistant: new Types.ObjectId(),
       expiredAt: nonExpiredDate,
     });
     await threadRelation.save();
@@ -55,6 +57,7 @@ describe('normalizeExpiredAtForThreadRelations', () => {
     const threadRelation = new ThreadRelation({
       userId: new Types.ObjectId(),
       threadId: 'test-thread-3',
+      aiAssistant: new Types.ObjectId(),
       expiredAt: nonExpiredDate,
     });
     await threadRelation.save();

+ 1 - 1
apps/app/src/features/openai/server/services/openai-api-error-handler.ts

@@ -14,7 +14,7 @@ type ErrorHandler = {
   notFoundError?: () => Promise<void>;
 }
 
-export const oepnaiApiErrorHandler = async(error: unknown, handler: ErrorHandler): Promise<void> => {
+export const openaiApiErrorHandler = async(error: unknown, handler: ErrorHandler): Promise<void> => {
   if (!(error instanceof OpenAI.APIError)) {
     return;
   }

+ 673 - 99
apps/app/src/features/openai/server/services/openai.ts

@@ -2,50 +2,88 @@ 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 ThreadRelationModel from '~/features/openai/server/models/thread-relation';
-import VectorStoreModel, { VectorStoreScopeType, type VectorStoreDocument } from '~/features/openai/server/models/vector-store';
+import type {
+  IUser, Ref, Lang, IPage,
+} from '@growi/core';
+import {
+  PageGrant, getIdForRef, getIdStringForRef, isPopulated, type IUserHasId,
+} from '@growi/core';
+import { deepEquals } from '@growi/core/dist/utils';
+import { isGlobPatternPath } 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, { type ThreadRelationDocument } from '~/features/openai/server/models/thread-relation';
+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 { UpsertAiAssistantData } from '../../interfaces/ai-assistant';
+import {
+  type AccessibleAiAssistants, type AiAssistant, AiAssistantAccessScope, AiAssistantShareScope,
+} from '../../interfaces/ai-assistant';
+import type { MessageListParams } from '../../interfaces/message';
+import { removeGlobPath } from '../../utils/remove-glob-path';
+import AiAssistantModel, { type AiAssistantDocument } from '../models/ai-assistant';
 import { convertMarkdownToHtml } from '../utils/convert-markdown-to-html';
+import { generateGlobPatterns } from '../utils/generate-glob-patterns';
 
 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';
+import { replaceAnnotationWithPageLink } from './replace-annotation-with-page-link';
 
+const { isDeepEquals } = deepEquals;
 
 const BATCH_SIZE = 100;
 
 const logger = loggerFactory('growi:service:openai');
 
-let isVectorStoreForPublicScopeExist = false;
 
 type VectorStoreFileRelationsMap = Map<string, VectorStoreFileRelation>
 
+
+const convertPathPatternsToRegExp = (pagePathPatterns: string[]): Array<string | RegExp> => {
+  return pagePathPatterns.map((pagePathPattern) => {
+    if (isGlobPatternPath(pagePathPattern)) {
+      const trimedPagePathPattern = pagePathPattern.replace('/*', '');
+      const escapedPagePathPattern = escapeStringRegexp(trimedPagePathPattern);
+      // https://regex101.com/r/x5KIZL/1
+      return new RegExp(`^${escapedPagePathPattern}($|/)`);
+    }
+    return pagePathPattern;
+  });
+};
+
 export interface IOpenaiService {
-  getOrCreateThread(userId: string, vectorStoreId?: string, threadId?: string): Promise<OpenAI.Beta.Threads.Thread | undefined>;
-  getOrCreateVectorStoreForPublicScope(): Promise<VectorStoreDocument>;
+  createThread(userId: string, aiAssistantId: string, initialUserMessage: string): Promise<ThreadRelationDocument>;
+  getThreadsByAiAssistantId(aiAssistantId: string): Promise<ThreadRelationDocument[]>
+  deleteThread(threadRelationId: string): Promise<ThreadRelationDocument>;
   deleteExpiredThreads(limit: number, apiCallInterval: number): Promise<void>; // for CronJob
-  deleteObsolatedVectorStoreRelations(): Promise<void> // for CronJob
-  createVectorStoreFile(pages: PageDocument[]): Promise<void>;
+  deleteObsoletedVectorStoreRelations(): Promise<void> // for CronJob
+  deleteVectorStore(vectorStoreRelationId: string): Promise<void>;
+  getMessageData(threadId: string, lang?: Lang, options?: MessageListParams): Promise<OpenAI.Beta.Threads.Messages.MessagesPage>;
+  createVectorStoreFile(vectorStoreRelation: VectorStoreDocument, pages: PageDocument[]): Promise<void>;
+  createVectorStoreFileOnPageCreate(pages: PageDocument[]): Promise<void>;
+  updateVectorStoreFileOnPageUpdate(page: HydratedDocument<PageDocument>): Promise<void>;
   deleteVectorStoreFile(vectorStoreRelationId: Types.ObjectId, pageId: Types.ObjectId): Promise<void>;
+  deleteVectorStoreFilesByPageIds(pageIds: Types.ObjectId[]): Promise<void>;
   deleteObsoleteVectorStoreFile(limit: number, apiCallInterval: number): Promise<void>; // for CronJob
-  rebuildVectorStoreAll(): Promise<void>;
-  rebuildVectorStore(page: HydratedDocument<PageDocument>): Promise<void>;
+  isAiAssistantUsable(aiAssistantId: string, user: IUserHasId): Promise<boolean>;
+  createAiAssistant(data: UpsertAiAssistantData, user: IUserHasId): Promise<AiAssistantDocument>;
+  updateAiAssistant(aiAssistantId: string, data: UpsertAiAssistantData, user: IUserHasId): Promise<AiAssistantDocument>;
+  getAccessibleAiAssistants(user: IUserHasId): Promise<AccessibleAiAssistants>
+  isLearnablePageLimitExceeded(user: IUserHasId, pagePathPatterns: string[]): Promise<boolean>;
 }
 class OpenaiService implements IOpenaiService {
 
@@ -54,37 +92,95 @@ class OpenaiService implements IOpenaiService {
     return getClient({ openaiServiceType });
   }
 
-  public async getOrCreateThread(userId: string, vectorStoreId?: string, threadId?: string): Promise<OpenAI.Beta.Threads.Thread> {
-    if (vectorStoreId != null && threadId == null) {
+  async generateThreadTitle(message: string): Promise<string | null> {
+    const model = configManager.getConfig('openai:assistantModel:chat');
+    const systemMessage = [
+      'Create a brief title (max 5 words) from your message.',
+      'Respond in the same language the user uses in their input.',
+      'Response should only contain the title.',
+    ].join('');
+
+    const threadTitleCompletion = await this.client.chatCompletion({
+      model,
+      messages: [
+        {
+          role: 'system',
+          content: systemMessage,
+        },
+        {
+          role: 'user',
+          content: message,
+        },
+      ],
+    });
+
+    const threadTitle = threadTitleCompletion.choices[0].message.content;
+    return threadTitle;
+  }
+
+  async createThread(userId: string, aiAssistantId: string, initialUserMessage: string): Promise<ThreadRelationDocument> {
+    const vectorStoreRelation = await this.getVectorStoreRelationByAiAssistantId(aiAssistantId);
+
+    let threadTitle: string | null = null;
+    if (initialUserMessage != null) {
       try {
-        const thread = await this.client.createThread(vectorStoreId);
-        await ThreadRelationModel.create({ userId, threadId: thread.id });
-        return thread;
+        threadTitle = await this.generateThreadTitle(initialUserMessage);
       }
       catch (err) {
-        throw new Error(err);
+        logger.error(err);
       }
     }
 
-    const threadRelation = await ThreadRelationModel.findOne({ threadId });
-    if (threadRelation == null) {
-      throw new Error('ThreadRelation document is not exists');
+    try {
+      const thread = await this.client.createThread(vectorStoreRelation.vectorStoreId);
+      const threadRelation = await ThreadRelationModel.create({
+        userId,
+        aiAssistant: aiAssistantId,
+        threadId: thread.id,
+        title: threadTitle,
+      });
+      return threadRelation;
+    }
+    catch (err) {
+      throw err;
     }
+  }
 
-    // Check if a thread entity exists
-    // If the thread entity does not exist, the thread-relation document is deleted
-    try {
-      const thread = await this.client.retrieveThread(threadRelation.threadId);
+  async updateThreads(aiAssistantId: string, vectorStoreId: string): Promise<void> {
+    const threadRelations = await this.getThreadsByAiAssistantId(aiAssistantId);
+    for await (const threadRelation of threadRelations) {
+      try {
+        const updatedThreadResponse = await this.client.updateThread(threadRelation.threadId, vectorStoreId);
+        logger.debug('Update thread', updatedThreadResponse);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    }
+  }
 
-      // Update expiration date if thread entity exists
-      await threadRelation.updateThreadExpiration();
+  async getThreadsByAiAssistantId(aiAssistantId: string): Promise<ThreadRelationDocument[]> {
+    const threadRelations = await ThreadRelationModel.find({ aiAssistant: aiAssistantId });
+    return threadRelations;
+  }
+
+  async deleteThread(threadRelationId: string): Promise<ThreadRelationDocument> {
+    const threadRelation = await ThreadRelationModel.findById(threadRelationId);
+    if (threadRelation == null) {
+      throw createError(404, 'ThreadRelation document does not exist');
+    }
 
-      return thread;
+    try {
+      const deletedThreadResponse = await this.client.deleteThread(threadRelation.threadId);
+      logger.debug('Delete thread', deletedThreadResponse);
+      await threadRelation.remove();
     }
     catch (err) {
-      await oepnaiApiErrorHandler(err, { notFoundError: async() => { await threadRelation.remove() } });
-      throw new Error(err);
+      await openaiApiErrorHandler(err, { notFoundError: async() => { await threadRelation.remove() } });
+      throw err;
     }
+
+    return threadRelation;
   }
 
   public async deleteExpiredThreads(limit: number, apiCallInterval: number): Promise<void> {
@@ -111,52 +207,98 @@ class OpenaiService implements IOpenaiService {
     await ThreadRelationModel.deleteMany({ threadId: { $in: deletedThreadIds } });
   }
 
-  public async getOrCreateVectorStoreForPublicScope(): Promise<VectorStoreDocument> {
-    const vectorStoreDocument: VectorStoreDocument | null = await VectorStoreModel.findOne({ scopeType: VectorStoreScopeType.PUBLIC, isDeleted: false });
+  async getMessageData(threadId: string, lang?: Lang, options?: MessageListParams): Promise<OpenAI.Beta.Threads.Messages.MessagesPage> {
+    const messages = await this.client.getMessages(threadId, options);
 
-    if (vectorStoreDocument != null && isVectorStoreForPublicScopeExist) {
-      return vectorStoreDocument;
+    for await (const message of messages.data) {
+      for await (const content of message.content) {
+        if (content.type === 'text') {
+          await replaceAnnotationWithPageLink(content, lang);
+        }
+      }
     }
 
-    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);
-      }
+    return messages;
+  }
+
+
+  async getVectorStoreRelationByAiAssistantId(aiAssistantId: string): Promise<VectorStoreDocument> {
+    const aiAssistant = await AiAssistantModel.findById({ _id: aiAssistantId }).populate('vectorStore');
+    if (aiAssistant == null) {
+      throw createError(404, 'AiAssistant document does not exist');
     }
 
-    const newVectorStore = await this.client.createVectorStore(VectorStoreScopeType.PUBLIC);
-    const newVectorStoreDocument = await VectorStoreModel.create({
-      vectorStoreId: newVectorStore.id,
-      scopeType: VectorStoreScopeType.PUBLIC,
-    }) as VectorStoreDocument;
+    return aiAssistant.vectorStore as VectorStoreDocument;
+  }
 
-    isVectorStoreForPublicScopeExist = true;
+  async getVectorStoreRelationsByPageIds(pageIds: Types.ObjectId[]): Promise<VectorStoreDocument[]> {
+    const pipeline = [
+      // Stage 1: Match documents with the given pageId
+      {
+        $match: {
+          page: {
+            $in: pageIds,
+          },
+        },
+      },
+      // Stage 2: Lookup VectorStore documents
+      {
+        $lookup: {
+          from: 'vectorstores',
+          localField: 'vectorStoreRelationId',
+          foreignField: '_id',
+          as: 'vectorStore',
+        },
+      },
+      // Stage 3: Unwind the vectorStore array
+      {
+        $unwind: '$vectorStore',
+      },
+      // Stage 4: Match non-deleted vector stores
+      {
+        $match: {
+          'vectorStore.isDeleted': false,
+        },
+      },
+      // Stage 5: Replace the root with vectorStore document
+      {
+        $replaceRoot: {
+          newRoot: '$vectorStore',
+        },
+      },
+      // Stage 6: Group by _id to remove duplicates
+      {
+        $group: {
+          _id: '$_id',
+          doc: { $first: '$$ROOT' },
+        },
+      },
+      // Stage 7: Restore the document structure
+      {
+        $replaceRoot: {
+          newRoot: '$doc',
+        },
+      },
+    ];
 
-    return newVectorStoreDocument;
+    const vectorStoreRelations = await VectorStoreFileRelationModel.aggregate<VectorStoreDocument>(pipeline);
+    return vectorStoreRelations;
   }
 
-  // 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');
-  //   for await (const [index, chunk] of chunks.entries()) {
-  //     try {
-  //       const file = await toFile(Readable.from(chunk), `${pageId}-chunk-${index}.md`);
-  //       const uploadedFile = await this.client.uploadFile(file);
-  //       prepareVectorStoreFileRelations(pageId, uploadedFile.id, vectorStoreFileRelationsMap);
-  //     }
-  //     catch (err) {
-  //       logger.error(err);
-  //     }
-  //   }
-  // }
+  private async createVectorStore(name: string): Promise<VectorStoreDocument> {
+    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);
+    }
+  }
 
   private async uploadFile(pageId: Types.ObjectId, pagePath: string, revisionBody: string): Promise<OpenAI.Files.FileObject> {
     const convertedHtml = await convertMarkdownToHtml({ pagePath, revisionBody });
@@ -165,37 +307,38 @@ class OpenaiService implements IOpenaiService {
     return uploadedFile;
   }
 
-  private async deleteVectorStore(vectorStoreScopeType: VectorStoreScopeType): Promise<void> {
-    const vectorStoreDocument: VectorStoreDocument | null = await VectorStoreModel.findOne({ scopeType: vectorStoreScopeType, isDeleted: false });
+  async deleteVectorStore(vectorStoreRelationId: string): Promise<void> {
+    const vectorStoreDocument: VectorStoreDocument | null = await VectorStoreModel.findOne({ _id: vectorStoreRelationId, isDeleted: false });
     if (vectorStoreDocument == null) {
       return;
     }
 
     try {
-      await this.client.deleteVectorStore(vectorStoreDocument.vectorStoreId);
+      const deleteVectorStoreResponse = await this.client.deleteVectorStore(vectorStoreDocument.vectorStoreId);
+      logger.debug('Delete vector store', deleteVectorStoreResponse);
       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<HydratedDocument<PageDocument>>): Promise<void> {
-    const vectorStore = await this.getOrCreateVectorStoreForPublicScope();
+  async createVectorStoreFile(vectorStoreRelation: VectorStoreDocument, pages: Array<HydratedDocument<PageDocument>>): Promise<void> {
+    // const vectorStore = await this.getOrCreateVectorStoreForPublicScope();
     const vectorStoreFileRelationsMap: VectorStoreFileRelationsMap = new Map();
     const processUploadFile = async(page: HydratedDocument<PageDocument>) => {
-      if (page._id != null && page.grant === PageGrant.GRANT_PUBLIC && page.revision != null) {
+      if (page._id != null && 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 +369,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,14 +380,14 @@ 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);
       }
     }
 
   }
 
   // Deletes all VectorStore documents that are marked as deleted (isDeleted: true) and have no associated VectorStoreFileRelation documents
-  async deleteObsolatedVectorStoreRelations(): Promise<void> {
+  async deleteObsoletedVectorStoreRelations(): Promise<void> {
     const deletedVectorStoreRelations = await VectorStoreModel.find({ isDeleted: true });
     if (deletedVectorStoreRelations.length === 0) {
       return;
@@ -287,7 +430,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);
       }
     }
@@ -303,6 +446,16 @@ class OpenaiService implements IOpenaiService {
     await vectorStoreFileRelation.save();
   }
 
+  async deleteVectorStoreFilesByPageIds(pageIds: Types.ObjectId[]): Promise<void> {
+    const vectorStoreRelations = await this.getVectorStoreRelationsByPageIds(pageIds);
+    if (vectorStoreRelations != null && vectorStoreRelations.length !== 0) {
+      for await (const pageId of pageIds) {
+        const deleteVectorStoreFilePromises = vectorStoreRelations.map(vectorStoreRelation => this.deleteVectorStoreFile(vectorStoreRelation._id, pageId));
+        await Promise.allSettled(deleteVectorStoreFilePromises);
+      }
+    }
+  }
+
   async deleteObsoleteVectorStoreFile(limit: number, apiCallInterval: number): Promise<void> {
     // Retrieves all VectorStore documents that are marked as deleted
     const deletedVectorStoreRelations = await VectorStoreModel.find({ isDeleted: true });
@@ -329,31 +482,452 @@ class OpenaiService implements IOpenaiService {
     }
   }
 
-  async rebuildVectorStoreAll() {
-    await this.deleteVectorStore(VectorStoreScopeType.PUBLIC);
+  async filterPagesByAccessScope(aiAssistant: AiAssistantDocument, pages: HydratedDocument<PageDocument>[]) {
+    const isPublicPage = (page :HydratedDocument<PageDocument>) => page.grant === PageGrant.GRANT_PUBLIC;
+
+    const isUserGroupAccessible = (page :HydratedDocument<PageDocument>, ownerUserGroupIds: string[]) => {
+      if (page.grant !== PageGrant.GRANT_USER_GROUP) return false;
+      return page.grantedGroups.some(group => ownerUserGroupIds.includes(getIdStringForRef(group.item)));
+    };
+
+    const isOwnerAccessible = (page: HydratedDocument<PageDocument>, ownerId: Ref<IUser>) => {
+      if (page.grant !== PageGrant.GRANT_OWNER) return false;
+      return page.grantedUsers.some(user => getIdStringForRef(user) === getIdStringForRef(ownerId));
+    };
+
+    const getOwnerUserGroupIds = async(owner: Ref<IUser>) => {
+      const userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(owner);
+      const externalGroups = await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(owner);
+      return [...userGroups, ...externalGroups].map(group => getIdStringForRef(group));
+    };
+
+    switch (aiAssistant.accessScope) {
+      case AiAssistantAccessScope.PUBLIC_ONLY:
+        return pages.filter(isPublicPage);
+
+      case AiAssistantAccessScope.GROUPS: {
+        const ownerUserGroupIds = await getOwnerUserGroupIds(aiAssistant.owner);
+        return pages.filter(page => isPublicPage(page) || isUserGroupAccessible(page, ownerUserGroupIds));
+      }
+
+      case AiAssistantAccessScope.OWNER: {
+        const ownerUserGroupIds = await getOwnerUserGroupIds(aiAssistant.owner);
+        return pages.filter(page => isPublicPage(page) || isOwnerAccessible(page, aiAssistant.owner) || isUserGroupAccessible(page, ownerUserGroupIds));
+      }
+
+      default:
+        return [];
+    }
+  }
+
+  async createVectorStoreFileOnPageCreate(pages: HydratedDocument<PageDocument>[]): Promise<void> {
+    const pagePaths = pages.map(page => page.path);
+    const aiAssistants = await this.findAiAssistantByPagePath(pagePaths, { shouldPopulateOwner: true, shouldPopulateVectorStore: true });
+
+    if (aiAssistants.length === 0) {
+      return;
+    }
+
+    for await (const aiAssistant of aiAssistants) {
+      if (!isPopulated(aiAssistant.owner)) {
+        continue;
+      }
+
+      const isLearnablePageLimitExceeded = await this.isLearnablePageLimitExceeded(aiAssistant.owner, aiAssistant.pagePathPatterns);
+      if (isLearnablePageLimitExceeded) {
+        continue;
+      }
+
+      const pagesToVectorize = await this.filterPagesByAccessScope(aiAssistant, pages);
+      const vectorStoreRelation = aiAssistant.vectorStore;
+      if (vectorStoreRelation == null || !isPopulated(vectorStoreRelation)) {
+        continue;
+      }
+
+      logger.debug('--------- createVectorStoreFileOnPageCreate ---------');
+      logger.debug('AccessScopeType of aiAssistant: ', aiAssistant.accessScope);
+      logger.debug('VectorStoreFile pagePath to be created: ', pagesToVectorize.map(page => page.path));
+      logger.debug('-----------------------------------------------------');
+
+      await this.createVectorStoreFile(vectorStoreRelation as VectorStoreDocument, pagesToVectorize);
+    }
+  }
+
+  async updateVectorStoreFileOnPageUpdate(page: HydratedDocument<PageDocument>) {
+    const aiAssistants = await this.findAiAssistantByPagePath([page.path], { shouldPopulateVectorStore: true });
+
+    if (aiAssistants.length === 0) {
+      return;
+    }
+
+    for await (const aiAssistant of aiAssistants) {
+      const pagesToVectorize = await this.filterPagesByAccessScope(aiAssistant, [page]);
+      const vectorStoreRelation = aiAssistant.vectorStore;
+      if (vectorStoreRelation == null || !isPopulated(vectorStoreRelation)) {
+        continue;
+      }
 
-    // Create all public pages VectorStoreFile
+      logger.debug('---------- updateVectorStoreOnPageUpdate ------------');
+      logger.debug('AccessScopeType of aiAssistant: ', aiAssistant.accessScope);
+      logger.debug('PagePath of VectorStoreFile to be deleted: ', page.path);
+      logger.debug('pagePath of VectorStoreFile to be created: ', pagesToVectorize.map(page => page.path));
+      logger.debug('-----------------------------------------------------');
+
+      // Do not create a new VectorStoreFile if page is changed to a permission that AiAssistant does not have access to
+      await this.createVectorStoreFile(vectorStoreRelation as VectorStoreDocument, pagesToVectorize);
+      await this.deleteVectorStoreFile((vectorStoreRelation as VectorStoreDocument)._id, page._id);
+    }
+  }
+
+  private async createVectorStoreFileWithStream(vectorStoreRelation: VectorStoreDocument, conditions: mongoose.FilterQuery<PageDocument>): Promise<void> {
     const Page = mongoose.model<HydratedDocument<PageDocument>, 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<PageDocument>[], 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<PageDocument>) {
-    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<mongoose.FilterQuery<PageDocument>> {
+
+    const convertedPagePathPatterns = 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 => !isGlobPatternPath(pagePathPattern));
+    const baseCondition: mongoose.FilterQuery<PageDocument> = {
+      grant: PageGrant.GRANT_RESTRICTED,
+      path: { $in: nonGrabPagePathPatterns },
+    };
+
+    if (accessScope === AiAssistantAccessScope.PUBLIC_ONLY) {
+      return {
+        $or: [
+          baseCondition,
+          {
+            grant: PageGrant.GRANT_PUBLIC,
+            path: { $in: convertedPagePathPatterns },
+          },
+        ],
+      };
+    }
+
+    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: convertedPagePathPatterns },
+            $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: convertedPagePathPatterns },
+            $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 isAiAssistantUsable(aiAssistantId: string, user: IUserHasId): Promise<boolean> {
+    const aiAssistant = await AiAssistantModel.findById(aiAssistantId);
+
+    if (aiAssistant == null) {
+      throw createError(404, 'AiAssistant document does not exist');
+    }
+
+    const isOwner = getIdStringForRef(aiAssistant.owner) === getIdStringForRef(user._id);
+
+    if (aiAssistant.shareScope === AiAssistantShareScope.PUBLIC_ONLY) {
+      return true;
+    }
+
+    if ((aiAssistant.shareScope === AiAssistantShareScope.OWNER) && isOwner) {
+      return true;
+    }
+
+    if ((aiAssistant.shareScope === AiAssistantShareScope.SAME_AS_ACCESS_SCOPE) && (aiAssistant.accessScope === AiAssistantAccessScope.OWNER) && isOwner) {
+      return true;
+    }
+
+    if ((aiAssistant.shareScope === AiAssistantShareScope.GROUPS)
+      || ((aiAssistant.shareScope === AiAssistantShareScope.SAME_AS_ACCESS_SCOPE) && (aiAssistant.accessScope === AiAssistantAccessScope.GROUPS))) {
+      const userGroupIds = [
+        ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+        ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ].map(group => group.toString());
+
+      const grantedGroupIdsForShareScope = aiAssistant.grantedGroupsForShareScope?.map(group => getIdStringForRef(group.item)) ?? [];
+      const isShared = userGroupIds.some(userGroupId => grantedGroupIdsForShareScope.includes(userGroupId));
+      return isShared;
+    }
+
+    return false;
+  }
+
+  async createAiAssistant(data: UpsertAiAssistantData, user: IUserHasId): Promise<AiAssistantDocument> {
+    await this.validateGrantedUserGroupsForAiAssistant(
+      user,
+      data.shareScope,
+      data.accessScope,
+      data.grantedGroupsForShareScope,
+      data.grantedGroupsForAccessScope,
+    );
+
+    const conditions = await this.createConditionForCreateVectorStoreFile(
+      user,
+      data.accessScope,
+      data.grantedGroupsForAccessScope,
+      data.pagePathPatterns,
+    );
+
+    const vectorStoreRelation = await this.createVectorStore(data.name);
+    const aiAssistant = await AiAssistantModel.create({
+      ...data, owner: user, vectorStore: vectorStoreRelation,
+    });
+
+    // VectorStore creation process does not await
+    this.createVectorStoreFileWithStream(vectorStoreRelation, conditions);
+
+    return aiAssistant;
+  }
+
+  async updateAiAssistant(aiAssistantId: string, data: UpsertAiAssistantData, user: IUserHasId): Promise<AiAssistantDocument> {
+    const aiAssistant = await AiAssistantModel.findOne({ owner: user, _id: aiAssistantId });
+    if (aiAssistant == null) {
+      throw createError(404, 'AiAssistant document does not exist');
+    }
+
+    await this.validateGrantedUserGroupsForAiAssistant(
+      user,
+      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(
+        user,
+        data.accessScope,
+        data.grantedGroupsForAccessScope,
+        data.pagePathPatterns,
+      );
+
+      // Delete obsoleted VectorStore
+      const obsoletedVectorStoreRelationId = getIdStringForRef(aiAssistant.vectorStore);
+      await this.deleteVectorStore(obsoletedVectorStoreRelationId);
+
+      newVectorStoreRelation = await this.createVectorStore(data.name);
+
+      this.updateThreads(aiAssistantId, newVectorStoreRelation.vectorStoreId);
+
+      // VectorStore creation process does not await
+      this.createVectorStoreFileWithStream(newVectorStoreRelation, conditions);
+    }
+
+    const newData = {
+      ...data,
+      vectorStore: newVectorStoreRelation ?? aiAssistant.vectorStore,
+    };
+
+    aiAssistant.set({ ...newData });
+    let updatedAiAssistant: AiAssistantDocument = await aiAssistant.save();
+
+    if (data.shareScope !== AiAssistantShareScope.PUBLIC_ONLY && aiAssistant.isDefault) {
+      updatedAiAssistant = await AiAssistantModel.setDefault(aiAssistant._id, false);
+    }
+
+    return updatedAiAssistant;
+  }
+
+  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 } },
+          ],
+        },
+      ],
+    })
+      .populate('grantedGroupsForShareScope.item')
+      .populate('grantedGroupsForAccessScope.item');
+
+    return {
+      myAiAssistants: assistants.filter(assistant => assistant.owner.toString() === user._id.toString()) ?? [],
+      teamAiAssistants: assistants.filter(assistant => assistant.owner.toString() !== user._id.toString()) ?? [],
+    };
+  }
+
+  async isLearnablePageLimitExceeded(user: IUserHasId, pagePathPatterns: string[]): Promise<boolean> {
+    const normalizedPagePathPatterns = removeGlobPath(pagePathPatterns);
+
+    const PageModel = mongoose.model<IPage, PageModel>('Page');
+    const pagePathsWithDescendantCount = await PageModel.descendantCountByPaths(normalizedPagePathPatterns, user, null, true, true);
+
+    const totalPageCount = pagePathsWithDescendantCount.reduce((total, pagePathWithDescendantCount) => {
+      const descendantCount = pagePathPatterns.includes(pagePathWithDescendantCount.path)
+        ? 0 // Treat as single page when included in "pagePathPatterns"
+        : pagePathWithDescendantCount.descendantCount;
+
+      const pageCount = descendantCount + 1;
+      return total + pageCount;
+    }, 0);
+
+    logger.debug('TotalPageCount: ', totalPageCount);
+
+    const limitLearnablePageCountPerAssistant = configManager.getConfig('openai:limitLearnablePageCountPerAssistant');
+    return totalPageCount > limitLearnablePageCountPerAssistant;
+  }
+
+  async findAiAssistantByPagePath(
+      pagePaths: string[], options?: { shouldPopulateOwner?: boolean, shouldPopulateVectorStore?: boolean },
+  ): Promise<AiAssistantDocument[]> {
+
+    const pagePathsWithGlobPattern = pagePaths.map(pagePath => generateGlobPatterns(pagePath)).flat();
+
+    const query = AiAssistantModel.find({
+      $or: [
+        // Case 1: Exact match
+        { pagePathPatterns: { $in: pagePaths } },
+        // Case 2: Glob pattern match
+        { pagePathPatterns: { $in: pagePathsWithGlobPattern } },
+      ],
+    });
+
+    if (options?.shouldPopulateOwner) {
+      query.populate('owner');
+    }
+
+    if (options?.shouldPopulateVectorStore) {
+      query.populate('vectorStore');
+    }
+
+    const aiAssistants = await query.exec();
+    return aiAssistants;
   }
 
 }

+ 5 - 5
apps/app/src/features/openai/server/services/replace-annotation-with-page-link.ts

@@ -1,14 +1,14 @@
 // See: https://platform.openai.com/docs/assistants/tools/file-search#step-5-create-a-run-and-check-the-output
 
 import type { IPageHasId, Lang } from '@growi/core/dist/interfaces';
-import type { MessageContentDelta } from 'openai/resources/beta/threads/messages.mjs';
+import type { MessageContentDelta, MessageContent } from 'openai/resources/beta/threads/messages.mjs';
 
 import VectorStoreFileRelationModel from '~/features/openai/server/models/vector-store-file-relation';
 import { getTranslation } from '~/server/service/i18next';
 
-export const replaceAnnotationWithPageLink = async(messageContentDelta: MessageContentDelta, lang?: Lang): Promise<void> => {
-  if (messageContentDelta?.type === 'text' && messageContentDelta?.text?.annotations != null) {
-    const annotations = messageContentDelta?.text?.annotations;
+export const replaceAnnotationWithPageLink = async(messageContent: MessageContentDelta | MessageContent, lang?: Lang): Promise<void> => {
+  if (messageContent?.type === 'text' && messageContent?.text?.annotations != null) {
+    const annotations = messageContent?.text?.annotations;
     for await (const annotation of annotations) {
       if (annotation.type === 'file_citation' && annotation.text != null) {
 
@@ -18,7 +18,7 @@ export const replaceAnnotationWithPageLink = async(messageContentDelta: MessageC
 
         if (vectorStoreFileRelation != null) {
           const { t } = await getTranslation({ lang });
-          messageContentDelta.text.value = messageContentDelta.text.value?.replace(
+          messageContent.text.value = messageContent.text.value?.replace(
             annotation.text,
             ` [${t('source')}: [${vectorStoreFileRelation.page.path}](/${vectorStoreFileRelation.page._id})]`,
           );

+ 48 - 0
apps/app/src/features/openai/server/utils/generate-glob-patterns.spec.ts

@@ -0,0 +1,48 @@
+import { describe, test, expect } from 'vitest';
+
+import { generateGlobPatterns } from './generate-glob-patterns';
+
+describe('generateGlobPatterns', () => {
+  test('generates glob patterns for basic path with trailing slash', () => {
+    const path = '/Sandbox/Bootstrap5/';
+    const patterns = generateGlobPatterns(path);
+
+    expect(patterns).toEqual([
+      '/Sandbox/*',
+      '/Sandbox/Bootstrap5/*',
+    ]);
+  });
+
+  test('generates glob patterns for multi-level path with trailing slash', () => {
+    const path = '/user/admin/memo/';
+    const patterns = generateGlobPatterns(path);
+
+    expect(patterns).toEqual([
+      '/user/*',
+      '/user/admin/*',
+      '/user/admin/memo/*',
+    ]);
+  });
+
+  test('generates glob patterns for path without trailing slash', () => {
+    const path = '/path/to/directory';
+    const patterns = generateGlobPatterns(path);
+
+    expect(patterns).toEqual([
+      '/path/*',
+      '/path/to/*',
+      '/path/to/directory/*',
+    ]);
+  });
+
+  test('handles path with empty segments correctly', () => {
+    const path = '/path//to///dir';
+    const patterns = generateGlobPatterns(path);
+
+    expect(patterns).toEqual([
+      '/path/*',
+      '/path/to/*',
+      '/path/to/dir/*',
+    ]);
+  });
+});

+ 28 - 0
apps/app/src/features/openai/server/utils/generate-glob-patterns.ts

@@ -0,0 +1,28 @@
+import { pathUtils } from '@growi/core/dist/utils';
+
+/**
+  * @example
+  * // Input: '/Sandbox/Bootstrap5/'
+  * // Output: ['/Sandbox/*', '/Sandbox/Bootstrap5/*']
+  *
+  * // Input: '/user/admin/memo/'
+  * // Output: ['/user/*', '/user/admin/*', '/user/admin/memo/*']
+  */
+export const generateGlobPatterns = (path: string): string[] => {
+  // Remove trailing slash if exists
+  const normalizedPath = pathUtils.removeTrailingSlash(path);
+
+  // Split path into segments
+  const segments = normalizedPath.split('/').filter(Boolean);
+
+  // Generate patterns
+  const patterns: string[] = [];
+  let currentPath = '';
+
+  for (let i = 0; i < segments.length; i++) {
+    currentPath += `/${segments[i]}`;
+    patterns.push(`${currentPath}/*`);
+  }
+
+  return patterns;
+};

+ 6 - 0
apps/app/src/features/openai/utils/determine-share-scope.ts

@@ -0,0 +1,6 @@
+import type { AiAssistantAccessScope } from '../interfaces/ai-assistant';
+import { AiAssistantShareScope } from '../interfaces/ai-assistant';
+
+export const determineShareScope = (shareScope: AiAssistantShareScope, accessScope: AiAssistantAccessScope): AiAssistantShareScope => {
+  return shareScope === AiAssistantShareScope.SAME_AS_ACCESS_SCOPE ? accessScope : shareScope;
+};

+ 8 - 0
apps/app/src/features/openai/utils/remove-glob-path.ts

@@ -0,0 +1,8 @@
+export const removeGlobPath = (pagePathPattens?: string[]): string[] => {
+  if (pagePathPattens == null) {
+    return [];
+  }
+  return pagePathPattens.map((pagePathPattern) => {
+    return pagePathPattern.endsWith('/*') ? pagePathPattern.slice(0, -2) : pagePathPattern;
+  });
+};

Некоторые файлы не были показаны из-за большого количества измененных файлов