Procházet zdrojové kódy

Merge pull request #9779 from weseek/release-pdf-converter-1.0

Release pdf converter 1.0
Futa Arai před 1 rokem
rodič
revize
4b3c68a627
100 změnil soubory, kde provedl 4268 přidání a 693 odebrání
  1. 2 1
      .devcontainer/app/devcontainer.json
  2. 9 0
      .devcontainer/app/initializeCommand.sh
  3. 5 0
      .devcontainer/app/postCreateCommand.sh
  4. 12 0
      .devcontainer/compose.extend.template.yml
  5. 0 10
      .devcontainer/compose.yml
  6. 2 1
      .devcontainer/pdf-converter/devcontainer.json
  7. 9 0
      .devcontainer/pdf-converter/initializeCommand.sh
  8. 5 0
      .devcontainer/pdf-converter/postCreateCommand.sh
  9. 1 1
      .github/workflows/auto-approve.yml
  10. 1 1
      .github/workflows/auto-labeling.yml
  11. 1 1
      .github/workflows/draft-release.yml
  12. 7 12
      .github/workflows/release-pdf-converter.yml
  13. 1 6
      .github/workflows/release-slackbot-proxy.yml
  14. 2 0
      .gitignore
  15. 38 1
      CHANGELOG.md
  16. 2 0
      apps/app/bin/swagger-jsdoc/definition-apiv3.js
  17. 1 0
      apps/app/bin/swagger-jsdoc/generate-spec-apiv3.sh
  18. 1 1
      apps/app/package.json
  19. 4 8
      apps/app/public/static/locales/en_US/admin.json
  20. 97 7
      apps/app/public/static/locales/en_US/translation.json
  21. 4 8
      apps/app/public/static/locales/fr_FR/admin.json
  22. 97 6
      apps/app/public/static/locales/fr_FR/translation.json
  23. 4 9
      apps/app/public/static/locales/ja_JP/admin.json
  24. 97 6
      apps/app/public/static/locales/ja_JP/translation.json
  25. 4 8
      apps/app/public/static/locales/zh_CN/admin.json
  26. 100 9
      apps/app/public/static/locales/zh_CN/translation.json
  27. 8 5
      apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx
  28. 14 0
      apps/app/src/client/components/Admin/Customize/CustomizeFunctionSetting.tsx
  29. 1 1
      apps/app/src/client/components/AuthorInfo/AuthorInfo.module.scss
  30. 9 9
      apps/app/src/client/components/AuthorInfo/AuthorInfo.tsx
  31. 1 1
      apps/app/src/client/components/Bookmarks/BookmarkFolderMenuItem.tsx
  32. 0 5
      apps/app/src/client/components/PageAuthorInfo/PageAuthorInfo.module.scss
  33. 0 45
      apps/app/src/client/components/PageAuthorInfo/PageAuthorInfo.tsx
  34. 2 2
      apps/app/src/client/components/PageControls/PageControls.tsx
  35. 19 6
      apps/app/src/client/components/PageHeader/PagePathHeader.tsx
  36. 52 26
      apps/app/src/client/components/PageSelectModal/PageSelectModal.tsx
  37. 15 1
      apps/app/src/client/components/PageSideContents/PageSideContents.tsx
  38. 1 1
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  39. 3 0
      apps/app/src/client/components/Sidebar/SidebarContents.tsx
  40. 6 2
      apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItem.tsx
  41. 12 0
      apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItems.tsx
  42. 6 0
      apps/app/src/client/services/AdminAppContainer.js
  43. 11 0
      apps/app/src/client/services/AdminCustomizeContainer.js
  44. 18 15
      apps/app/src/components/Admin/Common/AdminNavigation.tsx
  45. 15 2
      apps/app/src/components/Layout/BasicLayout.tsx
  46. 4 0
      apps/app/src/components/PageView/PageViewLayout.module.scss
  47. 498 0
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  48. 2 1
      apps/app/src/features/growi-plugin/client/components/GrowiPluginsActivator.tsx
  49. 38 7
      apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.ts
  50. 0 329
      apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.tsx
  51. 0 1
      apps/app/src/features/openai/chat/components/AiChatModal/index.ts
  52. 8 4
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.module.scss
  53. 455 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.tsx
  54. 0 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/MessageCard.module.scss
  55. 5 5
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/MessageCard.tsx
  56. 0 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/ResizableTextArea.tsx
  57. 66 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AccessScopeDropdown.tsx
  58. 40 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditInstruction.tsx
  59. 58 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditPages.tsx
  60. 149 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditShare.tsx
  61. 26 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHeader.tsx
  62. 221 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHome.tsx
  63. 15 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.module.scss
  64. 293 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx
  65. 74 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectUserGroupModal.tsx
  66. 43 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectedPageList.tsx
  67. 52 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeSwitch.tsx
  68. 81 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeWarningModal.tsx
  69. 2 2
      apps/app/src/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton.module.scss
  70. 55 0
      apps/app/src/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton.tsx
  71. 37 0
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistant.tsx
  72. 5 0
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.module.scss
  73. 57 0
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.tsx
  74. 45 0
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.module.scss
  75. 319 0
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx
  76. 0 31
      apps/app/src/features/openai/client/components/AiIntegration/AiIntegration.tsx
  77. 0 36
      apps/app/src/features/openai/client/components/RagSearchButton.tsx
  78. 19 0
      apps/app/src/features/openai/client/services/ai-assistant.ts
  79. 7 0
      apps/app/src/features/openai/client/services/thread.ts
  80. 87 0
      apps/app/src/features/openai/client/stores/ai-assistant.tsx
  81. 13 0
      apps/app/src/features/openai/client/stores/message.tsx
  82. 0 26
      apps/app/src/features/openai/client/stores/rag-search.ts
  83. 26 0
      apps/app/src/features/openai/client/stores/thread.tsx
  84. 55 0
      apps/app/src/features/openai/interfaces/ai-assistant.ts
  85. 13 0
      apps/app/src/features/openai/interfaces/message.ts
  86. 6 0
      apps/app/src/features/openai/interfaces/selected-page.ts
  87. 18 0
      apps/app/src/features/openai/interfaces/thread-relation.ts
  88. 4 0
      apps/app/src/features/openai/interfaces/vector-store.ts
  89. 145 0
      apps/app/src/features/openai/server/models/ai-assistant.ts
  90. 11 8
      apps/app/src/features/openai/server/models/thread-relation.ts
  91. 3 19
      apps/app/src/features/openai/server/models/vector-store.ts
  92. 56 0
      apps/app/src/features/openai/server/routes/ai-assistant.ts
  93. 46 0
      apps/app/src/features/openai/server/routes/ai-assistants.ts
  94. 59 0
      apps/app/src/features/openai/server/routes/delete-ai-assistant.ts
  95. 68 0
      apps/app/src/features/openai/server/routes/delete-thread.ts
  96. 73 0
      apps/app/src/features/openai/server/routes/get-messages.ts
  97. 61 0
      apps/app/src/features/openai/server/routes/get-threads.ts
  98. 32 4
      apps/app/src/features/openai/server/routes/index.ts
  99. 33 3
      apps/app/src/features/openai/server/routes/message.ts
  100. 86 0
      apps/app/src/features/openai/server/routes/middlewares/upsert-ai-assistant-validator.ts

+ 2 - 1
.devcontainer/app/devcontainer.json

@@ -2,7 +2,7 @@
 // README at: https://github.com/devcontainers/templates/tree/main/src/ubuntu
 {
   "name": "GROWI-Dev",
-  "dockerComposeFile": "../compose.yml",
+  "dockerComposeFile": ["../compose.yml", "../compose.extend.yml"],
   "service": "app",
   "workspaceFolder": "/workspace/growi",
 
@@ -15,6 +15,7 @@
   // Use 'forwardPorts' to make a list of ports inside the container available locally.
   // "forwardPorts": [],
 
+  "initializeCommand": "/bin/bash .devcontainer/pdf-converter/initializeCommand.sh",
   // Use 'postCreateCommand' to run commands after the container is created.
   "postCreateCommand": "/bin/bash ./.devcontainer/app/postCreateCommand.sh",
 

+ 9 - 0
.devcontainer/app/initializeCommand.sh

@@ -0,0 +1,9 @@
+# prevent file not found error on docker compose up
+if [ ! -f ".devcontainer/compose.extend.yml" ]; then
+
+cat > ".devcontainer/compose.extend.yml" <<EOF
+services:
+  {}
+EOF
+
+fi

+ 5 - 0
.devcontainer/app/postCreateCommand.sh

@@ -6,6 +6,11 @@ sudo apt-get install -y --no-install-recommends \
   iputils-ping net-tools dnsutils
 sudo apt-get clean -y
 
+# Set permissions for shared directory for bulk export
+mkdir -p /tmp/page-bulk-export
+sudo chown -R vscode:vscode /tmp/page-bulk-export
+sudo chmod 700 /tmp/page-bulk-export
+
 # Setup pnpm
 SHELL=bash pnpm setup
 eval "$(cat /home/vscode/.bashrc)"

+ 12 - 0
.devcontainer/compose.extend.template.yml

@@ -0,0 +1,12 @@
+# A template of the file for extending the primary docker compose configuration.
+# To actually use this file, create a `compose.extend.yml` file and copy the contents of this file into it.
+services:
+  pdf-converter:
+    # enabling devcontainer 'features' was not working for secondary devcontainer (https://github.com/devcontainers/features/issues/1175)
+    image: mcr.microsoft.com/vscode/devcontainers/javascript-node:1-20
+    volumes:
+      - ..:/workspace/growi:delegated
+      - pnpm-store:/workspace/growi/.pnpm-store
+      - node_modules:/workspace/growi/node_modules
+      - page_bulk_export_tmp:/tmp/page-bulk-export
+    tty: true

+ 0 - 10
.devcontainer/compose.yml

@@ -45,16 +45,6 @@ services:
       - /usr/share/elasticsearch/data
       - ../../growi-docker-compose/elasticsearch/v8/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
 
-  pdf-converter:
-    # enabling devcontainer 'features' was not working for secondary devcontainer (https://github.com/devcontainers/features/issues/1175)
-    image: mcr.microsoft.com/vscode/devcontainers/javascript-node:1-20
-    volumes:
-      - ..:/workspace/growi:delegated
-      - pnpm-store:/workspace/growi/.pnpm-store
-      - node_modules:/workspace/growi/node_modules
-      - page_bulk_export_tmp:/tmp/page-bulk-export
-    tty: true
-
 volumes:
   pnpm-store:
   node_modules:

+ 2 - 1
.devcontainer/pdf-converter/devcontainer.json

@@ -1,12 +1,13 @@
 {
   "name": "GROWI-PDF-Converter",
-  "dockerComposeFile": "../compose.yml",
+  "dockerComposeFile": ["../compose.yml", "../compose.extend.yml"],
   "service": "pdf-converter",
   "workspaceFolder": "/workspace/growi",
 
   // Use 'forwardPorts' to make a list of ports inside the container available locally.
   // "forwardPorts": [],
 
+  "initializeCommand": "/bin/bash .devcontainer/pdf-converter/initializeCommand.sh",
   // Use 'postCreateCommand' to run commands after the container is created.
   "postCreateCommand": "/bin/bash ./.devcontainer/pdf-converter/postCreateCommand.sh",
 

+ 9 - 0
.devcontainer/pdf-converter/initializeCommand.sh

@@ -0,0 +1,9 @@
+# prevent file not found error on docker compose up
+if [ ! -f ".devcontainer/compose.extend.yml" ]; then
+
+cat > ".devcontainer/compose.extend.yml" <<EOF
+services:
+  {}
+EOF
+
+fi

+ 5 - 0
.devcontainer/pdf-converter/postCreateCommand.sh

@@ -4,6 +4,11 @@ sudo apt-get install -y --no-install-recommends \
   chromium fonts-lato fonts-ipafont-gothic fonts-noto-cjk
 sudo apt-get clean -y
 
+# Set permissions for shared directory for bulk export
+mkdir -p /tmp/page-bulk-export
+sudo chown -R node:node /tmp/page-bulk-export
+sudo chmod 700 /tmp/page-bulk-export
+
 # Setup pnpm
 SHELL=bash pnpm setup
 eval "$(cat /home/node/.bashrc)"

+ 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

+ 7 - 12
.github/workflows/release-pdf-converter.yml

@@ -38,34 +38,29 @@ 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/pdf-converter/docker/Dockerfile
-        platforms: linux/amd64
+        platforms: linux/amd64,linux/arm64
         push: true
         builder: ${{ steps.buildx.outputs.name }}
         cache-from: type=gha
         cache-to: type=gha,mode=max
         tags: ${{ steps.meta.outputs.tags }}
 
-    - name: Move cache
-      run: |
-        rm -rf /tmp/.buildx-cache
-        mv /tmp/.buildx-cache-new /tmp/.buildx-cache
-
     - name: Add tag
       uses: anothrNick/github-tag-action@v1
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-        CUSTOM_TAG: v${{ steps.package-json.outputs.packageVersion }}
+        CUSTOM_TAG: pdf-converter/v${{ steps.package-json.outputs.packageVersion }}
         VERBOSE : true
 
     - name: Update Docker Hub Description
       uses: peter-evans/dockerhub-description@v3
       with:
-        username: wsmoogle
-        password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+        username: growimoogle
+        password: ${{ secrets.DOCKER_REGISTRY_PASSWORD_GROWIMOOGLE }}
         repository: growilabs/pdf-converter
         readme-filepath: ./apps/pdf-converter/docker/README.md
 
@@ -119,7 +114,7 @@ jobs:
       with:
         source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
         destination_branch: master
-        pr_title: Prepare v${{ steps.package-json.outputs.packageVersion }}
+        pr_title: Prepare pdf-converter v${{ steps.package-json.outputs.packageVersion }}
         pr_label: flag/exclude-from-changelog,type/prepare-next-version
-        pr_body: "An automated PR generated by ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
+        pr_body: "An automated PR generated by ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
         github_token: ${{ secrets.GITHUB_TOKEN }}

+ 1 - 6
.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
@@ -62,11 +62,6 @@ jobs:
         cache-to: type=gha,mode=max
         tags: ${{ steps.meta.outputs.tags }}
 
-    - name: Move cache
-      run: |
-        rm -rf /tmp/.buildx-cache
-        mv /tmp/.buildx-cache-new /tmp/.buildx-cache
-
     - name: Add tag
       uses: anothrNick/github-tag-action@v1
       env:

+ 2 - 0
.gitignore

@@ -44,3 +44,5 @@ yarn-error.log*
 
 # pnpm deploy target dir
 out
+
+.devcontainer/compose.extend.yml

+ 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": {

+ 4 - 8
apps/app/public/static/locales/en_US/admin.json

@@ -499,7 +499,9 @@
       "show_all_reply_comments": "Show all reply comments",
       "show_all_reply_comments_desc": "When the setting value is off, comments other than the latest two are omitted.",
       "select_search_scope_children_as_default": "Select 'Only children of this tree' as default value of search range",
-      "select_search_scope_children_as_default_desc": "When the setting value is off, 'All pages' is used as default value of search range."
+      "select_search_scope_children_as_default_desc": "When the setting value is off, 'All pages' is used as default value of search range.",
+      "show_page_side_authors": "Always display creators and updaters above the table of contents",
+      "show_page_side_authors_desc": "Displays information about the creator and the last updater above the table of contents in the page sidebar."
     },
       "presentation": "Presentation",
     "presentation_options": {
@@ -1146,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"
   }
 }

+ 97 - 7
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",
@@ -184,7 +186,9 @@
   },
   "author_info": {
     "created_at": "Created at",
-    "last_revision_posted_at": "Last revision posted at"
+    "created_by": "Created by",
+    "last_revision_posted_at": "Last revision posted at",
+    "updated_by": "Updated by"
   },
   "installer": {
     "tab": "Create account",
@@ -489,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",
@@ -502,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",
@@ -888,8 +979,7 @@
   },
   "sidebar_header": {
     "show_wip_page": "Show WIP",
-    "size_s": "Size: S",
-    "size_l": "Size: L"
+    "compact_view": "Compact View"
   },
   "create_page": {
     "untitled": "Untitled"

+ 4 - 8
apps/app/public/static/locales/fr_FR/admin.json

@@ -499,7 +499,9 @@
       "show_all_reply_comments": "Afficher tout les commentaires",
       "show_all_reply_comments_desc": "Lorsque désactivé, seul les deux commentaires les plus récents sont affichés",
       "select_search_scope_children_as_default": "'Seulement enfant de ce chemin' lors de la recherche",
-      "select_search_scope_children_as_default_desc": "Lorsque désactivé, utilise 'Toutes les pages' en portée de recherche."
+      "select_search_scope_children_as_default_desc": "Lorsque désactivé, utilise 'Toutes les pages' en portée de recherche.",
+      "show_page_side_authors": "Toujours afficher les créateurs et les modificateurs au-dessus de la table des matières",
+      "show_page_side_authors_desc": "Affiche les informations sur le créateur et le dernier modificateur au-dessus de la table des matières dans la barre latérale de la page."
     },
     "presentation": "Présentation",
     "presentation_options": {
@@ -1145,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"
   }
 }

+ 97 - 6
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é",
@@ -185,7 +187,9 @@
   },
   "author_info": {
     "created_at": "Crée le",
-    "last_revision_posted_at": "Dernière révision le"
+    "created_by": "Créé par",
+    "last_revision_posted_at": "Dernière révision le",
+    "updated_by": "Mis à jour par"
   },
   "installer": {
     "tab": "Créer compte",
@@ -484,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",
@@ -498,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",
@@ -882,8 +974,7 @@
   },
   "sidebar_header": {
     "show_wip_page": "Voir brouillon",
-    "size_s": "Taille: P",
-    "size_l": "Taille: G"
+    "compact_view": "Vue compacte"
   },
   "sync-latest-revision-body": {
     "menuitem": "Synchroniser avec la dernière révision",

+ 4 - 9
apps/app/public/static/locales/ja_JP/admin.json

@@ -508,8 +508,9 @@
       "show_all_reply_comments": "返信コメントを全て表示する",
       "show_all_reply_comments_desc": "OFFの場合、最新2件のコメント以外が省略されます。",
       "select_search_scope_children_as_default": "検索範囲のデフォルト設定を「この階層下の子ページ」にする",
-      "select_search_scope_children_as_default_desc": "OFFの場合、検索範囲のデフォルト設定は「全てのページ」になります。"
-
+      "select_search_scope_children_as_default_desc": "OFFの場合、検索範囲のデフォルト設定は「全てのページ」になります。",
+      "show_page_side_authors": "作成者・更新者を目次上部に常時表示する",
+      "show_page_side_authors_desc": "ページサイドバーの目次上部に作成者と最終更新者の情報を表示します。"
     },
     "presentation":"プレゼンテーション",
     "presentation_options":{
@@ -1156,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 検索管理"
   }
 }

+ 97 - 6
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": "重複したパス",
@@ -185,7 +187,9 @@
   },
   "author_info": {
     "created_at": "作成日",
-    "last_revision_posted_at": "最終更新日"
+    "created_by": "作成者:",
+    "last_revision_posted_at": "最終更新日",
+    "updated_by": "最終更新者:"
   },
   "installer": {
     "tab": "アカウント作成",
@@ -522,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文以内の簡潔な回答",
@@ -536,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": "リンク情報",
@@ -920,8 +1012,7 @@
   },
   "sidebar_header": {
     "show_wip_page": "WIP を表示",
-    "size_s": "サイズ: S",
-    "size_l": "サイズ: L"
+    "compact_view": "コンパクト表示"
   },
   "create_page": {
     "untitled": "無題のページ"

+ 4 - 8
apps/app/public/static/locales/zh_CN/admin.json

@@ -508,7 +508,9 @@
       "show_all_reply_comments": "显示所有回复评论",
       "show_all_reply_comments_desc": "当设置值为“关”时,将忽略最近两个之外的注释。",
       "select_search_scope_children_as_default": "选择“当前分支以下内容”, 作为搜索范围的默认值",
-      "select_search_scope_children_as_default_desc": "当设置值为“关”时,“所有页面”被作为搜索范围的默认值。"
+      "select_search_scope_children_as_default_desc": "当设置值为“关”时,“所有页面”被作为搜索范围的默认值。",
+      "show_page_side_authors": "在目录上方始终显示创建者和更新者",
+      "show_page_side_authors_desc": "在页面侧边栏的目录上方显示创建者和最后更新者的信息。"
     },
       "presentation": "表达",
       "presentation_options": {
@@ -1155,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 搜索管理"
   }
 }

+ 100 - 9
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",
@@ -189,10 +191,12 @@
   "custom_navigation": {
     "no_pages_under_this_page": "There are no pages under this page."
   },
-  "author_info": {
-    "created_at": "Created at",
-    "last_revision_posted_at": "Last revision posted at"
-  },
+"author_info": {
+  "created_at": "创建日期",
+  "created_by": "创建者:",
+  "last_revision_posted_at": "最后更新日期",
+  "updated_by": "更新者:"
+},
   "installer": {
     "tab": "创建账户",
     "title": "安装",
@@ -479,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句话内",
@@ -493,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",
@@ -891,8 +983,7 @@
   },
   "sidebar_header": {
     "show_wip_page": "显示 WIP",
-    "size_s": "尺寸: S",
-    "size_l": "尺寸: L"
+    "compact_view": "紧凑视图"
   },
   "create_page": {
     "untitled": "Untitled"

+ 8 - 5
apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx

@@ -109,12 +109,15 @@ const AppSettingsPageContents = (props: Props) => {
         </div>
       </div>
 
-      <div className="row mt-5">
-        <div className="col-lg-12">
-          <h2 className="admin-setting-header">{t('admin:app_setting.page_bulk_export_settings')}</h2>
-          <PageBulkExportSettings />
+      {/* TODO: Enable configuring bulk export for GROWI.cloud when it can be relased for cloud (https://redmine.weseek.co.jp/issues/163220) */}
+      {!adminAppContainer.state.isBulkExportDisabledForCloud && (
+        <div className="row mt-5">
+          <div className="col-lg-12">
+            <h2 className="admin-setting-header">{t('admin:app_setting.page_bulk_export_settings')}</h2>
+            <PageBulkExportSettings />
+          </div>
         </div>
-      </div>
+      )}
 
       <div className="row mt-5">
         <div className="col-lg-12">

+ 14 - 0
apps/app/src/client/components/Admin/Customize/CustomizeFunctionSetting.tsx

@@ -133,6 +133,20 @@ const CustomizeFunctionSetting = (props: Props): JSX.Element => {
             </div>
           </div>
 
+          <div className="row">
+            <div className="offset-md-2 col-md-7 text-start">
+              <CustomizeFunctionOption
+                optionId="showPageSideAuthors"
+                label={t('admin:customize_settings.function_options.show_page_side_authors')}
+                isChecked={adminCustomizeContainer.state.showPageSideAuthors}
+                onChecked={() => { adminCustomizeContainer.switchShowPageSideAuthors() }}
+              >
+                <p className="form-text text-muted">
+                  {t('admin:customize_settings.function_options.show_page_side_authors_desc')}
+                </p>
+              </CustomizeFunctionOption>
+            </div>
+          </div>
 
           <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
         </div>

+ 1 - 1
apps/app/src/client/components/AuthorInfo/AuthorInfo.module.scss

@@ -1,7 +1,7 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 
 $author-font-size: 12px;
-$date-font-size: 11px;
+$date-font-size: 12px;
 
 .grw-author-info :global {
   font-size: $author-font-size;

+ 9 - 9
apps/app/src/client/components/AuthorInfo/AuthorInfo.tsx

@@ -28,20 +28,20 @@ type AuthorInfoProps = {
   date: Date,
   user?: IUserHasId | Ref<IUser>,
   mode: 'create' | 'update',
-  locate: 'subnav' | 'footer',
+  locate: 'pageSide' | 'footer',
 }
 
 export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
   const { t } = useTranslation();
   const {
-    date, user, mode = 'create', locate = 'subnav',
+    date, user, mode = 'create', locate = 'pageSide',
   } = props;
 
   const formatType = 'yyyy/MM/dd HH:mm';
 
-  const infoLabelForSubNav = mode === 'create'
-    ? 'Created by'
-    : 'Updated by';
+  const infoLabelForPageSide = mode === 'create'
+    ? t('author_info.created_by')
+    : t('author_info.updated_by');
   const nullinfoLabelForFooter = mode === 'create'
     ? 'Created by'
     : 'Updated by';
@@ -76,13 +76,13 @@ export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
   };
 
   return (
-    <div className={`grw-author-info ${styles['grw-author-info']} d-flex align-items-center`}>
-      <div className="me-2">
+    <div className={`grw-author-info ${styles['grw-author-info']} d-flex align-items-center mb-2`}>
+      <div className="me-2 d-none d-lg-block">
         <UserPicture user={user} size="sm" />
       </div>
       <div>
-        <div>{infoLabelForSubNav} {userLabel}</div>
-        <div className="text-muted text-date" data-vrt-blackout-datetime>
+        <div className="text-secondary mb-1">{infoLabelForPageSide} <br className="d-lg-none" />{userLabel}</div>
+        <div className="text-secondary text-date" data-vrt-blackout-datetime>
           {renderParsedDate()}
         </div>
       </div>

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

+ 0 - 5
apps/app/src/client/components/PageAuthorInfo/PageAuthorInfo.module.scss

@@ -1,5 +0,0 @@
-.grw-page-author-info :global {
-  li {
-    list-style: none;
-  }
-}

+ 0 - 45
apps/app/src/client/components/PageAuthorInfo/PageAuthorInfo.tsx

@@ -1,45 +0,0 @@
-import { memo } from 'react';
-
-import { pagePathUtils } from '@growi/core/dist/utils';
-
-import { useCurrentPathname } from '~/stores-universal/context';
-import { useSWRxCurrentPage } from '~/stores/page';
-import { useIsAbleToShowPageAuthors } from '~/stores/ui';
-
-import { AuthorInfo } from '../AuthorInfo';
-
-
-import styles from './PageAuthorInfo.module.scss';
-
-
-export const PageAuthorInfo = memo((): JSX.Element => {
-  const { data: currentPage } = useSWRxCurrentPage();
-
-  const { data: currentPathname } = useCurrentPathname();
-  const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
-
-  if (!isAbleToShowPageAuthors) {
-    return <></>;
-  }
-
-  const path = currentPage?.path ?? currentPathname;
-
-  if (pagePathUtils.isUsersHomepage(path ?? '')) {
-    return <></>;
-  }
-
-  return (
-    <ul className={`grw-page-author-info ${styles['grw-page-author-info']} text-nowrap border-start d-none d-lg-block d-edit-none py-2 ps-4 mb-0 ms-3`}>
-      <li className="pb-1">
-        {currentPage != null && (
-          <AuthorInfo user={currentPage.creator} date={currentPage.createdAt} mode="create" locate="subnav" />
-        )}
-      </li>
-      <li className="mt-1 pt-1 border-top">
-        {currentPage != null && (
-          <AuthorInfo user={currentPage.lastUpdateUser} date={currentPage.updatedAt} mode="update" locate="subnav" />
-        )}
-      </li>
-    </ul>
-  );
-});

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

+ 15 - 1
apps/app/src/client/components/PageSideContents/PageSideContents.tsx

@@ -6,7 +6,7 @@ import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import { scroller } from 'react-scroll';
 
-import { useIsGuestUser, useIsReadOnlyUser } from '~/stores-universal/context';
+import { useIsGuestUser, useIsReadOnlyUser, useShowPageSideAuthors } from '~/stores-universal/context';
 import { useDescendantsPageListModal, useTagEditModal } from '~/stores/modal';
 import { useSWRxPageInfo, useSWRxTagsInfo } from '~/stores/page';
 import { useIsAbleToShowTagLabel } from '~/stores/ui';
@@ -28,6 +28,7 @@ const PageTags = dynamic(() => import('../PageTags').then(mod => mod.PageTags),
   loading: PageTagsSkeleton,
 });
 
+const AuthorInfo = dynamic(() => import('~/client/components/AuthorInfo').then(mod => mod.AuthorInfo), { ssr: false });
 
 type TagsProps = {
   pageId: string,
@@ -84,6 +85,11 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
   const tagsRef = useRef<HTMLDivElement>(null);
 
   const { data: pageInfo } = useSWRxPageInfo(page._id);
+  const { data: showPageSideAuthors } = useShowPageSideAuthors();
+
+  const {
+    creator, lastUpdateUser, createdAt, updatedAt,
+  } = page;
 
   const pagePath = page.path;
   const isTopPagePath = isTopPage(pagePath);
@@ -92,6 +98,14 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
 
   return (
     <>
+      {/* AuthorInfo */}
+      {showPageSideAuthors && (
+        <div className="d-none d-md-block page-meta border-bottom pb-2 ms-lg-3 mb-3">
+          <AuthorInfo user={creator} date={createdAt} mode="create" locate="pageSide" />
+          <AuthorInfo user={lastUpdateUser} date={updatedAt} mode="update" locate="pageSide" />
+        </div>
+      )}
+
       {/* Tags */}
       { page.revision != null && (
         <div ref={tagsRef}>

+ 1 - 1
apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx

@@ -201,7 +201,7 @@ export const RecentChangesHeader = ({
                 onChange={() => {}}
               />
               <label className="form-check-label pe-none" aria-disabled="true">
-                {isSmall ? t('sidebar_header.size_s') : t('sidebar_header.size_l')}
+                {t('sidebar_header.compact_view')}
               </label>
             </div>
           </li>

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

+ 6 - 0
apps/app/src/client/services/AdminAppContainer.js

@@ -76,6 +76,9 @@ export default class AdminAppContainer extends Container {
       isEnabledPlugins: true,
 
       isMaintenanceMode: false,
+
+      // TODO: remove this property when bulk export can be relased for cloud (https://redmine.weseek.co.jp/issues/163220)
+      isBulkExportDisabledForCloud: false,
     };
 
   }
@@ -149,6 +152,9 @@ export default class AdminAppContainer extends Container {
 
       isEnabledPlugins: appSettingsParams.isEnabledPlugins,
       isMaintenanceMode: appSettingsParams.isMaintenanceMode,
+
+      // TODO: remove this property when bulk export can be relased for cloud (https://redmine.weseek.co.jp/issues/163220)
+      isBulkExportDisabledForCloud: appSettingsParams.isBulkExportDisabledForCloud,
     });
 
     // if useOnlyEnvVarForFileUploadType is true, get fileUploadType from only env var and make the forms fixed.

+ 11 - 0
apps/app/src/client/services/AdminCustomizeContainer.js

@@ -40,11 +40,13 @@ export default class AdminCustomizeContainer extends Container {
       currentCustomizeNoscript: '',
       currentCustomizeCss: '',
       currentCustomizeScript: '',
+      showPageSideAuthors: false,
     };
     this.switchPageListLimitationS = this.switchPageListLimitationS.bind(this);
     this.switchPageListLimitationM = this.switchPageListLimitationM.bind(this);
     this.switchPageListLimitationL = this.switchPageListLimitationL.bind(this);
     this.switchPageListLimitationXL = this.switchPageListLimitationXL.bind(this);
+    this.switchShowPageSideAuthors = this.switchShowPageSideAuthors.bind(this);
 
   }
 
@@ -78,6 +80,7 @@ export default class AdminCustomizeContainer extends Container {
         currentCustomizeNoscript: customizeParams.customizeNoscript,
         currentCustomizeCss: customizeParams.customizeCss,
         currentCustomizeScript: customizeParams.customizeScript,
+        showPageSideAuthors: customizeParams.showPageSideAuthors,
       });
     }
     catch (err) {
@@ -187,6 +190,12 @@ export default class AdminCustomizeContainer extends Container {
     this.setState({ currentCustomizeScript: inpuValue });
   }
 
+  /**
+   * Switch showPageSideAuthors
+   */
+  switchShowPageSideAuthors() {
+    this.setState({ showPageSideAuthors: !this.state.showPageSideAuthors });
+  }
 
   /**
    * Update function
@@ -204,6 +213,7 @@ export default class AdminCustomizeContainer extends Container {
         isEnabledStaleNotification: this.state.isEnabledStaleNotification,
         isAllReplyShown: this.state.isAllReplyShown,
         isSearchScopeChildrenAsDefault: this.state.isSearchScopeChildrenAsDefault,
+        showPageSideAuthors: this.state.showPageSideAuthors,
       });
       const { customizedParams } = response.data;
       this.setState({
@@ -216,6 +226,7 @@ export default class AdminCustomizeContainer extends Container {
         isEnabledStaleNotification: customizedParams.isEnabledStaleNotification,
         isAllReplyShown: customizedParams.isAllReplyShown,
         isSearchScopeChildrenAsDefault: customizedParams.isSearchScopeChildrenAsDefault,
+        showPageSideAuthors: customizedParams.showPageSideAuthors,
       });
     }
     catch (err) {

+ 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 />

+ 4 - 0
apps/app/src/components/PageView/PageViewLayout.module.scss

@@ -29,6 +29,10 @@ $page-view-layout-margin-top: 32px;
       margin-left: 30px;
     }
 
+    @include bs.media-breakpoint-up(md) {
+      max-width: 170px;
+    }
+
     @include bs.media-breakpoint-down(sm) {
       position: fixed;
       right: 1rem;

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

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů